openapi: 3.1.0
info:
  title: Joinways Public API
  version: 1.1.0
  description: |
    The Joinways REST API lets you read and manage your CRM and event data —
    events, quotes, companies, contacts, leads, venues, catalog, tasks and more.
    It's a standard **JSON over HTTPS** API described by this OpenAPI 3.1 document.

    ## Authentication

    Every request needs a **Bearer token** in the `Authorization` header:

    ```
    Authorization: Bearer jw_oat_xxxxxxxx
    ```

    Two token types work, both created from **Settings → Integrations → API** in
    your workspace:

    - **API key** (`jw_…`) — the simplest: create one, paste it, done.
    - **OAuth 2.1 access token** (`jw_oat_…`) — for apps acting on a user's behalf
      (Authorization Code + PKCE). Same flow as the Joinways MCP server.

    A token is **bound to a single workspace**. You never pass an `account_id` —
    tenant scoping is derived from the token, and the database enforces it.

    **Scopes:** `mcp.read` (GET/list/search) and `mcp.write` (create/update/delete).

    ### Token lifetime & revocation

    - **API keys** (`jw_…`) do not expire unless you set an expiry when creating
      them. Revoke one any time from **Settings → Integrations → API**.
    - **OAuth 2.1 access tokens** (`jw_oat_…`) expire **30 days** after issuance.
      There is no refresh-token grant — re-run the Authorization Code + PKCE flow
      to obtain a new one. Revoke from the connected app, the standard
      `POST /api/oauth/revoke` (RFC 7009) endpoint, or from your workspace
      settings.

    Revocation takes effect within a few seconds across our infrastructure
    (the shared token cache is evicted immediately; short-lived per-instance
    caches expire within ~60s). Treat tokens as secrets: they are transmitted
    only over HTTPS and are stored **hashed** (SHA-256) at rest.

    ## Pagination

    List endpoints take `page` (1-based, default `1`) and `per_page`
    (default `50`, max `500`) and return:

    ```json
    {
      "data": [ /* … */ ],
      "pagination": { "page": 1, "per_page": 50, "total": 1234, "total_pages": 25 }
    }
    ```

    Sort with `sort=<field>` and `order=asc|desc` (default `desc`).

    ## Errors

    Errors use standard HTTP status codes and a consistent body:

    ```json
    { "error": { "code": "not_found", "message": "Event not found", "details": null } }
    ```

    | Status | code | Meaning |
    |---|---|---|
    | 400 | `invalid_request` | Validation failed |
    | 401 | `unauthorized` | Missing / invalid / expired token |
    | 403 | `forbidden` | Missing scope or access denied |
    | 404 | `not_found` | Resource not in this workspace |
    | 409 | `conflict` | State conflict |
    | 429 | `rate_limited` | Too many requests — back off (see `Retry-After`) |
    | 500 | `server_error` | Unexpected error |

    ## Rate limits

    Two limits stack on every request:

    - **Per IP** — a hard ceiling (300 requests/minute) applied before
      authentication to absorb unauthenticated bursts.
    - **Per workspace** — a per-plan quota. The **Pro** plan allows **600
      requests/minute** and **100,000 requests/day**.

    Every response carries the current budget:

    ```
    X-RateLimit-Limit: 600
    X-RateLimit-Remaining: 587
    X-RateLimit-Reset: 2026-09-12T14:31:00Z
    ```

    On `429`, respect `Retry-After` and use exponential backoff.

    ## Security

    - **Transport:** HTTPS only.
    - **At rest:** API keys and OAuth tokens are stored as SHA-256 hashes; the
      raw value is shown once at creation and never again.
    - **OAuth 2.1:** Authorization Code flow with **mandatory PKCE (S256)**;
      `redirect_uri` values must match exactly the ones registered for the
      client; authorization codes are single-use.
    - **Tenant isolation:** every token is scoped to one workspace and the
      database enforces row-level access — cross-workspace reads/writes are
      rejected with `404`.

    ## Conventions

    - All timestamps are **ISO 8601 UTC** (`2026-09-12T14:30:00Z`).
    - All IDs are **UUID v4**.
    - The product term **venue** maps to the internal `location` (so events expose
      `venue_id`).
  contact:
    name: Joinways
    url: https://joinways.app
  license:
    name: Proprietary
  x-logo:
    url: /images/marketing/logo-header.svg
    altText: Joinways
    href: https://joinways.app

servers:
  - url: https://joinways.app/api/v1
    description: Production

security:
  - bearerAuth: []
  - oauth2:
      - mcp.read
      - mcp.write

tags:
  - name: Events
  - name: Quotes
  - name: Companies
  - name: People
  - name: Leads
  - name: Venues
  - name: Catalog
  - name: Tasks
  - name: Team
  - name: Analytics
  - name: Search
  - name: Notifications
  - name: Webhooks

paths:
  # ──────────────────────────────── Events ────────────────────────────────
  /events:
    get:
      tags: [Events]
      summary: List events
      operationId: listEvents
      security:
        - bearerAuth: []
        - oauth2: [mcp.read]
      parameters:
        - $ref: '#/components/parameters/Page'
        - $ref: '#/components/parameters/PerPage'
        - $ref: '#/components/parameters/Sort'
        - $ref: '#/components/parameters/Order'
        - name: status
          in: query
          description: Filter by one or more event statuses (repeat the param to pass several).
          schema:
            type: array
            items: { type: string, enum: [option, confirmed, lost, cancelled] }
          style: form
          explode: true
        - name: type
          in: query
          description: Filter by one or more event types (e.g. dinner, wedding).
          schema: { type: array, items: { type: string } }
          style: form
          explode: true
        - name: company_id
          in: query
          description: Only events for this company.
          schema: { type: string, format: uuid }
        - name: venue_id
          in: query
          description: Only events at this venue (location).
          schema: { type: string, format: uuid }
        - name: from
          in: query
          description: Lower bound on start_date (inclusive), YYYY-MM-DD.
          schema: { type: string, format: date }
        - name: to
          in: query
          description: Upper bound on start_date (inclusive), YYYY-MM-DD.
          schema: { type: string, format: date }
        - name: q
          in: query
          description: Free-text match on event title.
          schema: { type: string }
        - name: archived
          in: query
          description: Tri-state — hide archived (default), show only archived, or all.
          schema: { type: string, enum: [exclude, only, all], default: exclude }
      responses:
        '200':
          description: Paginated list of events.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Paginated'
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/Event' }
        '401': { $ref: '#/components/responses/Unauthorized' }
    post:
      tags: [Events]
      summary: Create an event
      operationId: createEvent
      security:
        - bearerAuth: []
        - oauth2: [mcp.write]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/EventCreate' }
      responses:
        '201':
          description: Created event.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Event' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /events/{id}:
    parameters:
      - $ref: '#/components/parameters/EventId'
    get:
      tags: [Events]
      summary: Get an event
      operationId: getEvent
      security: [ { bearerAuth: [] }, { oauth2: [mcp.read] } ]
      responses:
        '200':
          description: The event.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Event' }
        '404': { $ref: '#/components/responses/NotFound' }
    patch:
      tags: [Events]
      summary: Update an event
      operationId: updateEvent
      security: [ { bearerAuth: [] }, { oauth2: [mcp.write] } ]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/EventUpdate' }
      responses:
        '200':
          description: Updated event.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Event' }
        '404': { $ref: '#/components/responses/NotFound' }
    delete:
      tags: [Events]
      summary: Delete an event
      operationId: deleteEvent
      security: [ { bearerAuth: [] }, { oauth2: [mcp.write] } ]
      responses:
        '204': { description: Deleted. }
        '404': { $ref: '#/components/responses/NotFound' }

  /events/{id}/status:
    parameters:
      - $ref: '#/components/parameters/EventId'
    post:
      tags: [Events]
      summary: Update event status
      operationId: updateEventStatus
      security: [ { bearerAuth: [] }, { oauth2: [mcp.write] } ]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [status]
              properties:
                status:
                  type: string
                  description: Target status (see your workspace's configured pipeline).
      responses:
        '200':
          description: Updated event.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Event' }

  /events/{id}/notes:
    parameters:
      - $ref: '#/components/parameters/EventId'
    get:
      tags: [Events]
      summary: List event notes
      operationId: listEventNotes
      security: [ { bearerAuth: [] }, { oauth2: [mcp.read] } ]
      responses:
        '200':
          description: Notes.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/Note' }
    post:
      tags: [Events]
      summary: Add an event note
      operationId: addEventNote
      security: [ { bearerAuth: [] }, { oauth2: [mcp.write] } ]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [content]
              properties:
                content: { type: string }
      responses:
        '201':
          description: Created note.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Note' }

  /events/{id}/tasks:
    parameters:
      - $ref: '#/components/parameters/EventId'
    get:
      tags: [Events]
      summary: List tasks for an event
      operationId: listEventTasks
      security: [ { bearerAuth: [] }, { oauth2: [mcp.read] } ]
      responses:
        '200':
          description: Tasks.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/Task' }
    post:
      tags: [Events]
      summary: Create a task on an event
      operationId: createEventTask
      security: [ { bearerAuth: [] }, { oauth2: [mcp.write] } ]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/TaskCreate' }
      responses:
        '201':
          description: Created task.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Task' }

  /events/{id}/contacts:
    parameters:
      - $ref: '#/components/parameters/EventId'
    get:
      tags: [Events]
      summary: List event day-of contacts
      operationId: listEventContacts
      description: >-
        Day-of (on-site) contacts for the event — the people reachable during
        the event itself, distinct from the booking `contact_person_id`.
      security: [ { bearerAuth: [] }, { oauth2: [mcp.read] } ]
      responses:
        '200':
          description: Contacts.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/EventContact' }
        '404': { $ref: '#/components/responses/NotFound' }
    put:
      tags: [Events]
      summary: Replace event day-of contacts
      operationId: upsertEventContacts
      description: >-
        Replaces the whole day-of contact list. The submitted array is the new
        source of truth: entries with an `id` are updated, entries without an
        `id` are created, and any existing row omitted from the payload is
        deleted.
      security: [ { bearerAuth: [] }, { oauth2: [mcp.write] } ]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [contacts]
              properties:
                contacts:
                  type: array
                  maxItems: 20
                  items: { $ref: '#/components/schemas/EventContactInput' }
      responses:
        '200':
          description: Result.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  count: { type: integer }
        '404': { $ref: '#/components/responses/NotFound' }

  /events/{id}/days:
    parameters:
      - $ref: '#/components/parameters/EventId'
    get:
      tags: [Events]
      summary: List event days
      operationId: listEventDays
      description: >-
        Per-day breakdown of a multi-day event with its per-day BEO overrides.
        Mono-day events return an empty array — the flat event fields apply.
      security: [ { bearerAuth: [] }, { oauth2: [mcp.read] } ]
      responses:
        '200':
          description: Event days.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/EventDay' }
        '404': { $ref: '#/components/responses/NotFound' }
    put:
      tags: [Events]
      summary: Patch per-day overrides
      operationId: upsertEventDays
      description: >-
        Patches the per-day BEO overrides, keyed on `dayIndex`. The set of days
        itself is derived from the event's date range / `selected_dates`
        (managed on the event resource) — this endpoint never adds or removes
        days.
      security: [ { bearerAuth: [] }, { oauth2: [mcp.write] } ]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [days]
              properties:
                days:
                  type: array
                  maxItems: 60
                  items: { $ref: '#/components/schemas/EventDayInput' }
      responses:
        '200':
          description: Result.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  count: { type: integer }
        '404': { $ref: '#/components/responses/NotFound' }

  /events/{id}/staff:
    parameters:
      - $ref: '#/components/parameters/EventId'
    get:
      tags: [Events]
      summary: List event staff
      operationId: listEventStaff
      description: >-
        Operational day-of crew assigned to the event (régie, service,
        technique…). This is NOT the internal owner (`assigned_to_account_id`).
      security: [ { bearerAuth: [] }, { oauth2: [mcp.read] } ]
      responses:
        '200':
          description: Staff assignments.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/EventStaffMember' }
        '404': { $ref: '#/components/responses/NotFound' }
    put:
      tags: [Events]
      summary: Replace event staff
      operationId: upsertEventStaff
      description: >-
        Replaces the whole staff list (same diff semantics as contacts).
        Free-text entries carrying an email or phone are auto-linked to — or
        created in — the workspace address book.
      security: [ { bearerAuth: [] }, { oauth2: [mcp.write] } ]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [staff]
              properties:
                staff:
                  type: array
                  maxItems: 100
                  items: { $ref: '#/components/schemas/EventStaffMemberInput' }
      responses:
        '200':
          description: Result.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  count: { type: integer }
        '404': { $ref: '#/components/responses/NotFound' }

  /events/{id}/emails:
    parameters:
      - $ref: '#/components/parameters/EventId'
    get:
      tags: [Events]
      summary: List emails linked to an event
      operationId: listEventEmails
      security: [ { bearerAuth: [] }, { oauth2: [mcp.read] } ]
      parameters:
        - name: filter
          in: query
          required: false
          schema: { type: string, enum: [all, sent, received], default: all }
          description: Narrow by direction.
      responses:
        '200':
          description: Linked emails.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/Email' }
        '404': { $ref: '#/components/responses/NotFound' }
    post:
      tags: [Events]
      summary: Send an email on behalf of an event
      operationId: sendEventEmail
      description: >-
        Sends through a connected Gmail/Outlook mailbox owned by the
        authenticated user and links the message to the event. Requires a
        connection with send permission.
      security: [ { bearerAuth: [] }, { oauth2: [mcp.write] } ]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [to, subject, body]
              properties:
                to:
                  type: array
                  items: { type: string, format: email }
                cc:
                  type: array
                  items: { type: string, format: email }
                bcc:
                  type: array
                  items: { type: string, format: email }
                subject: { type: string }
                body: { type: string, description: HTML or plain text. }
                connection_id:
                  type: string
                  format: uuid
                  description: >-
                    Mailbox connection to send from. Defaults to the user's
                    primary active `events` connection in this workspace.
                template_id:
                  type: string
                  format: uuid
      responses:
        '202': { description: Email queued for sending. }
        '404': { $ref: '#/components/responses/NotFound' }

  /events/{id}/files:
    parameters:
      - $ref: '#/components/parameters/EventId'
    get:
      tags: [Events]
      summary: List event files
      operationId: listEventFiles
      description: Files attached to the event, each with a short-lived signed download URL.
      security: [ { bearerAuth: [] }, { oauth2: [mcp.read] } ]
      responses:
        '200':
          description: Files.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/EventFile' }
        '404': { $ref: '#/components/responses/NotFound' }

  /events/{id}/beo:
    parameters:
      - $ref: '#/components/parameters/EventId'
    get:
      tags: [Events]
      summary: Get the BEO (Banquet Event Order) for an event
      operationId: getBeo
      description: >-
        Full BEO snapshot: event-level fields (`beo`) plus the staff, day-of
        contacts and per-day overrides.
      security: [ { bearerAuth: [] }, { oauth2: [mcp.read] } ]
      responses:
        '200':
          description: BEO snapshot.
          content:
            application/json:
              schema:
                type: object
                properties:
                  beo:
                    type: object
                    additionalProperties: true
                    description: >-
                      Event-level BEO fields (schedule, setup, notes, vendors,
                      context…). Includes `public_url`: the shareable, no-login
                      URL of the public BEO page (null when the share token is
                      missing or expired).
                  staff:
                    type: array
                    items: { $ref: '#/components/schemas/EventStaffMember' }
                  contacts:
                    type: array
                    items: { $ref: '#/components/schemas/EventContact' }
                  days:
                    type: array
                    items: { $ref: '#/components/schemas/EventDay' }
        '404': { $ref: '#/components/responses/NotFound' }
    put:
      tags: [Events]
      summary: Update event-level BEO fields
      operationId: updateBeo
      description: >-
        Writes only event-level BEO fields. Staff, contacts and per-day
        overrides have their own endpoints. Omit a key to leave it untouched;
        pass `null` to clear a nullable field.
      security: [ { bearerAuth: [] }, { oauth2: [mcp.write] } ]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/BeoUpdate' }
      responses:
        '200':
          description: Updated BEO.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  beo: { type: object, additionalProperties: true }
        '404': { $ref: '#/components/responses/NotFound' }

  /events/{id}/beo/pdf:
    parameters:
      - $ref: '#/components/parameters/EventId'
    get:
      tags: [Events]
      summary: Download the BEO as PDF
      operationId: downloadBeoPdf
      security: [ { bearerAuth: [] }, { oauth2: [mcp.read] } ]
      parameters:
        - name: locale
          in: query
          required: false
          schema: { type: string, example: fr }
          description: Printed language (e.g. fr, en, es). Defaults to French.
      responses:
        '200':
          description: PDF document.
          content:
            application/pdf:
              schema: { type: string, format: binary }
        '404': { $ref: '#/components/responses/NotFound' }

  # ──────────────────────────────── Quotes ────────────────────────────────
  /quotes:
    get:
      tags: [Quotes]
      summary: List quotes
      operationId: listQuotes
      security: [ { bearerAuth: [] }, { oauth2: [mcp.read] } ]
      parameters:
        - $ref: '#/components/parameters/Page'
        - $ref: '#/components/parameters/PerPage'
        - name: event_id
          in: query
          schema: { type: string, format: uuid }
        - name: status
          in: query
          schema: { type: array, items: { type: string } }
          style: form
          explode: true
      responses:
        '200':
          description: Paginated list of quotes.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Paginated'
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/Quote' }
    post:
      tags: [Quotes]
      summary: Create a quote
      operationId: createQuote
      security: [ { bearerAuth: [] }, { oauth2: [mcp.write] } ]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/QuoteCreate' }
      responses:
        '201':
          description: Created quote.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Quote' }

  /quotes/{id}:
    parameters:
      - $ref: '#/components/parameters/QuoteId'
    get:
      tags: [Quotes]
      summary: Get a quote (with items)
      operationId: getQuote
      security: [ { bearerAuth: [] }, { oauth2: [mcp.read] } ]
      responses:
        '200':
          description: The quote and its line items.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Quote'
                  - type: object
                    properties:
                      items:
                        type: array
                        items: { $ref: '#/components/schemas/QuoteItem' }
        '404': { $ref: '#/components/responses/NotFound' }
    patch:
      tags: [Quotes]
      summary: Update a quote
      operationId: updateQuote
      security: [ { bearerAuth: [] }, { oauth2: [mcp.write] } ]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/QuoteUpdate' }
      responses:
        '200':
          description: Updated quote.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Quote' }
    delete:
      tags: [Quotes]
      summary: Delete a quote
      operationId: deleteQuote
      security: [ { bearerAuth: [] }, { oauth2: [mcp.write] } ]
      responses:
        '204': { description: Deleted. }

  /quotes/{id}/status:
    parameters:
      - $ref: '#/components/parameters/QuoteId'
    post:
      tags: [Quotes]
      summary: Update quote status
      operationId: updateQuoteStatus
      security: [ { bearerAuth: [] }, { oauth2: [mcp.write] } ]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [status]
              properties:
                status:
                  type: string
                  enum: [draft, sent, accepted, rejected, expired]
      responses:
        '200':
          description: Updated quote.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Quote' }

  /quotes/{id}/duplicate:
    parameters:
      - $ref: '#/components/parameters/QuoteId'
    post:
      tags: [Quotes]
      summary: Duplicate a quote
      operationId: duplicateQuote
      security: [ { bearerAuth: [] }, { oauth2: [mcp.write] } ]
      responses:
        '201':
          description: The new quote.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Quote' }

  /quotes/{id}/pdf:
    parameters:
      - $ref: '#/components/parameters/QuoteId'
    get:
      tags: [Quotes]
      summary: Download a quote as PDF
      operationId: downloadQuotePdf
      security: [ { bearerAuth: [] }, { oauth2: [mcp.read] } ]
      parameters:
        - name: locale
          in: query
          required: false
          schema: { type: string, example: fr }
          description: Printed language (e.g. fr, en, es). Defaults to French.
      responses:
        '200':
          description: PDF document.
          content:
            application/pdf:
              schema: { type: string, format: binary }
        '404': { $ref: '#/components/responses/NotFound' }

  /quotes/{id}/items:
    parameters:
      - $ref: '#/components/parameters/QuoteId'
    get:
      tags: [Quotes]
      summary: List quote line items
      operationId: listQuoteItems
      security: [ { bearerAuth: [] }, { oauth2: [mcp.read] } ]
      responses:
        '200':
          description: Line items.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/QuoteItem' }
    post:
      tags: [Quotes]
      summary: Add line items to a quote
      operationId: addQuoteItems
      security: [ { bearerAuth: [] }, { oauth2: [mcp.write] } ]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [items]
              properties:
                items:
                  type: array
                  items: { $ref: '#/components/schemas/QuoteItemCreate' }
      responses:
        '201':
          description: Created items.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/QuoteItem' }

  /quotes/{id}/items/reorder:
    parameters:
      - $ref: '#/components/parameters/QuoteId'
    post:
      tags: [Quotes]
      summary: Reorder quote line items
      operationId: reorderQuoteItems
      security: [ { bearerAuth: [] }, { oauth2: [mcp.write] } ]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [items]
              properties:
                items:
                  type: array
                  description: Each item with its new sort order (and event day).
                  items:
                    type: object
                    required: [id, sort_order]
                    properties:
                      id: { type: string, format: uuid }
                      sort_order: { type: integer }
                      event_day: { type: ['integer', 'null'] }
      responses:
        '200': { description: Reordered. }

  /quotes/{id}/items/{itemId}:
    parameters:
      - $ref: '#/components/parameters/QuoteId'
      - name: itemId
        in: path
        required: true
        schema: { type: string, format: uuid }
    patch:
      tags: [Quotes]
      summary: Update a quote line item
      operationId: updateQuoteItem
      security: [ { bearerAuth: [] }, { oauth2: [mcp.write] } ]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/QuoteItemCreate' }
      responses:
        '200':
          description: Updated item.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/QuoteItem' }
    delete:
      tags: [Quotes]
      summary: Delete a quote line item
      operationId: deleteQuoteItem
      security: [ { bearerAuth: [] }, { oauth2: [mcp.write] } ]
      responses:
        '204': { description: Deleted. }

  # ─────────────────────────────── Companies ──────────────────────────────
  /companies:
    get:
      tags: [Companies]
      summary: List companies
      operationId: listCompanies
      security: [ { bearerAuth: [] }, { oauth2: [mcp.read] } ]
      parameters:
        - $ref: '#/components/parameters/Page'
        - $ref: '#/components/parameters/PerPage'
        - $ref: '#/components/parameters/Sort'
        - $ref: '#/components/parameters/Order'
        - name: q
          in: query
          schema: { type: string }
        - name: country
          in: query
          schema: { type: array, items: { type: string } }
          style: form
          explode: true
        - name: has_website
          in: query
          schema: { type: boolean }
      responses:
        '200':
          description: Paginated list of companies.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Paginated'
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/Company' }
    post:
      tags: [Companies]
      summary: Create a company
      operationId: createCompany
      security: [ { bearerAuth: [] }, { oauth2: [mcp.write] } ]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CompanyCreate' }
      responses:
        '201':
          description: Created company.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Company' }

  /companies/search:
    get:
      tags: [Companies]
      summary: Search companies by name
      operationId: searchCompanies
      security: [ { bearerAuth: [] }, { oauth2: [mcp.read] } ]
      parameters:
        - name: q
          in: query
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Matching companies.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/Company' }

  /companies/{id}:
    parameters:
      - $ref: '#/components/parameters/CompanyId'
    get:
      tags: [Companies]
      summary: Get a company
      operationId: getCompany
      security: [ { bearerAuth: [] }, { oauth2: [mcp.read] } ]
      responses:
        '200':
          description: The company.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Company' }
        '404': { $ref: '#/components/responses/NotFound' }
    patch:
      tags: [Companies]
      summary: Update a company
      operationId: updateCompany
      security: [ { bearerAuth: [] }, { oauth2: [mcp.write] } ]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CompanyCreate' }
      responses:
        '200':
          description: Updated company.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Company' }

  /companies/{id}/notes:
    parameters:
      - $ref: '#/components/parameters/CompanyId'
    get:
      tags: [Companies]
      summary: List company notes
      operationId: listCompanyNotes
      security: [ { bearerAuth: [] }, { oauth2: [mcp.read] } ]
      responses:
        '200':
          description: Notes.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/Note' }
    post:
      tags: [Companies]
      summary: Add a company note
      operationId: addCompanyNote
      security: [ { bearerAuth: [] }, { oauth2: [mcp.write] } ]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [content]
              properties:
                content: { type: string }
      responses:
        '201':
          description: Created note.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Note' }

  # ──────────────────────────────── People ────────────────────────────────
  /people:
    get:
      tags: [People]
      summary: List people
      operationId: listPeople
      security: [ { bearerAuth: [] }, { oauth2: [mcp.read] } ]
      parameters:
        - $ref: '#/components/parameters/Page'
        - $ref: '#/components/parameters/PerPage'
        - name: q
          in: query
          schema: { type: string }
        - name: company_id
          in: query
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: Paginated list of people.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Paginated'
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/Person' }
    post:
      tags: [People]
      summary: Create a person
      operationId: createPerson
      security: [ { bearerAuth: [] }, { oauth2: [mcp.write] } ]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PersonCreate' }
      responses:
        '201':
          description: Created person.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Person' }

  /people/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string, format: uuid }
    get:
      tags: [People]
      summary: Get a person
      operationId: getPerson
      security: [ { bearerAuth: [] }, { oauth2: [mcp.read] } ]
      responses:
        '200':
          description: The person.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Person' }
        '404': { $ref: '#/components/responses/NotFound' }
    patch:
      tags: [People]
      summary: Update a person
      operationId: updatePerson
      security: [ { bearerAuth: [] }, { oauth2: [mcp.write] } ]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PersonCreate' }
      responses:
        '200':
          description: Updated person.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Person' }
    delete:
      tags: [People]
      summary: Delete a person
      operationId: deletePerson
      security: [ { bearerAuth: [] }, { oauth2: [mcp.write] } ]
      responses:
        '204': { description: Deleted. }

  # ──────────────────────────────── Leads ─────────────────────────────────
  /leads:
    get:
      tags: [Leads]
      summary: List leads
      operationId: listLeads
      security: [ { bearerAuth: [] }, { oauth2: [mcp.read] } ]
      parameters:
        - $ref: '#/components/parameters/Page'
        - $ref: '#/components/parameters/PerPage'
        - name: status
          in: query
          schema: { type: array, items: { type: string } }
          style: form
          explode: true
        - name: source
          in: query
          description: Channel — email, whatsapp, instagram, sms, phone, platform, other.
          schema: { type: array, items: { type: string } }
          style: form
          explode: true
        - name: is_read
          in: query
          schema: { type: boolean }
        - name: q
          in: query
          schema: { type: string }
      responses:
        '200':
          description: Paginated list of leads.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Paginated'
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/Lead' }

  /leads/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string, format: uuid }
    get:
      tags: [Leads]
      summary: Get a lead
      operationId: getLead
      security: [ { bearerAuth: [] }, { oauth2: [mcp.read] } ]
      responses:
        '200':
          description: The lead.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Lead' }
        '404': { $ref: '#/components/responses/NotFound' }

  /leads/{id}/status:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string, format: uuid }
    post:
      tags: [Leads]
      summary: Update lead status
      operationId: updateLeadStatus
      security: [ { bearerAuth: [] }, { oauth2: [mcp.write] } ]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [status]
              properties:
                status: { type: string }
      responses:
        '200':
          description: Updated lead.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Lead' }

  /leads/{id}/convert:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string, format: uuid }
    post:
      tags: [Leads]
      summary: Convert a lead into an event
      operationId: convertLeadToEvent
      security: [ { bearerAuth: [] }, { oauth2: [mcp.write] } ]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [venue_id, start_date, end_date]
              properties:
                venue_id: { type: string, format: uuid }
                start_date: { type: string, format: date }
                end_date: { type: string, format: date }
                title:
                  type: string
                  description: Defaults to the lead's event type / company name.
                status:
                  type: string
                  enum: [option, confirmed]
                  default: option
      responses:
        '200':
          description: The created event (with the linked lead id).
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  event: { $ref: '#/components/schemas/Event' }
                  lead_id: { type: string, format: uuid }

  /leads/{id}/link:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string, format: uuid }
    post:
      tags: [Leads]
      summary: Link a lead to an existing event
      operationId: linkLeadToEvent
      security: [ { bearerAuth: [] }, { oauth2: [mcp.write] } ]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [event_id]
              properties:
                event_id: { type: string, format: uuid }
      responses:
        '200': { description: Linked. }

  # ──────────────────────────────── Venues ────────────────────────────────
  /venues:
    get:
      tags: [Venues]
      summary: List venues
      operationId: listVenues
      security: [ { bearerAuth: [] }, { oauth2: [mcp.read] } ]
      responses:
        '200':
          description: Venues.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/Venue' }

  /venues/{venueId}:
    parameters:
      - $ref: '#/components/parameters/VenueId'
    get:
      tags: [Venues]
      summary: Get a venue
      operationId: getVenue
      security: [ { bearerAuth: [] }, { oauth2: [mcp.read] } ]
      responses:
        '200':
          description: The venue.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Venue' }
        '404': { $ref: '#/components/responses/NotFound' }

  /venues/{venueId}/availability:
    parameters:
      - $ref: '#/components/parameters/VenueId'
    get:
      tags: [Venues]
      summary: Get venue availability
      operationId: getVenueAvailability
      security: [ { bearerAuth: [] }, { oauth2: [mcp.read] } ]
      parameters:
        - name: from
          in: query
          schema: { type: string, format: date }
        - name: to
          in: query
          schema: { type: string, format: date }
      responses:
        '200':
          description: Business hours, closures and resolved availability.
          content:
            application/json:
              schema:
                type: object
                properties:
                  business_hours:
                    type: array
                    items: { $ref: '#/components/schemas/BusinessHours' }
                  closures:
                    type: array
                    items: { $ref: '#/components/schemas/Closure' }
    put:
      tags: [Venues]
      summary: Set venue business hours
      operationId: setVenueBusinessHours
      security: [ { bearerAuth: [] }, { oauth2: [mcp.write] } ]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [hours]
              properties:
                hours:
                  type: array
                  items: { $ref: '#/components/schemas/BusinessHours' }
      responses:
        '200': { description: Updated business hours. }

  /venues/{venueId}/closures:
    parameters:
      - $ref: '#/components/parameters/VenueId'
    get:
      tags: [Venues]
      summary: List venue closures
      operationId: listVenueClosures
      security: [ { bearerAuth: [] }, { oauth2: [mcp.read] } ]
      responses:
        '200':
          description: Closures.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/Closure' }
    post:
      tags: [Venues]
      summary: Add a venue closure (block dates)
      operationId: addVenueClosure
      security: [ { bearerAuth: [] }, { oauth2: [mcp.write] } ]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ClosureCreate' }
      responses:
        '201':
          description: Created closure.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Closure' }

  /venues/{venueId}/closures/{closureId}:
    parameters:
      - $ref: '#/components/parameters/VenueId'
      - name: closureId
        in: path
        required: true
        schema: { type: string, format: uuid }
    delete:
      tags: [Venues]
      summary: Remove a venue closure (unblock dates)
      operationId: deleteVenueClosure
      security: [ { bearerAuth: [] }, { oauth2: [mcp.write] } ]
      responses:
        '204': { description: Deleted. }

  /venues/{venueId}/conflicts:
    parameters:
      - $ref: '#/components/parameters/VenueId'
    get:
      tags: [Venues]
      summary: List venue booking conflicts
      operationId: listVenueConflicts
      security: [ { bearerAuth: [] }, { oauth2: [mcp.read] } ]
      responses:
        '200':
          description: Conflicts.
          content:
            application/json:
              schema:
                type: array
                items: { type: object, additionalProperties: true }

  /venues/{venueId}/spaces:
    parameters:
      - $ref: '#/components/parameters/VenueId'
    get:
      tags: [Venues]
      summary: List venue spaces
      operationId: listVenueSpaces
      security: [ { bearerAuth: [] }, { oauth2: [mcp.read] } ]
      responses:
        '200':
          description: Spaces.
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    id: { type: string, format: uuid }
                    name: { type: string }
                    capacity: { type: integer }

  /venues/{venueId}/holidays:
    parameters:
      - $ref: '#/components/parameters/VenueId'
    post:
      tags: [Venues]
      summary: Import public holidays as closures
      operationId: importVenueHolidays
      security: [ { bearerAuth: [] }, { oauth2: [mcp.write] } ]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [country, year, enable]
              properties:
                country: { type: string, description: ISO 3166 alpha-2/3 code., example: FR }
                year: { type: integer, minimum: 1970, maximum: 2100 }
                enable: { type: boolean }
      responses:
        '200': { description: Holidays imported. }

  # ─────────────────────────────── Catalog ────────────────────────────────
  /catalog-items:
    get:
      tags: [Catalog]
      summary: List catalog items
      operationId: listCatalogItems
      security: [ { bearerAuth: [] }, { oauth2: [mcp.read] } ]
      parameters:
        - name: venue_id
          in: query
          required: true
          description: Catalog items are scoped to a venue (location).
          schema: { type: string, format: uuid }
        - $ref: '#/components/parameters/Page'
        - $ref: '#/components/parameters/PerPage'
        - name: category
          in: query
          schema: { type: string }
        - name: q
          in: query
          schema: { type: string }
        - name: pax
          in: query
          description: >-
            Nombre de participants. Fourni seul ou avec `date`, il déclenche la
            résolution tarifaire : chaque article gagne alors `resolved_unit_price`,
            `resolved_min_price` et `applied_price_rule`, sans qu'aucun champ
            existant ne change. Omis, les tarifs renvoyés restent ceux du catalogue.
          schema: { type: integer, minimum: 0 }
        - name: date
          in: query
          description: >-
            Date de l'événement au format `YYYY-MM-DD`, répétable pour un
            événement sur plusieurs jours. Sert à retenir les règles saisonnières
            et les règles par jour de semaine.
          schema: { type: string, format: date }
      responses:
        '200':
          description: Paginated list of catalog items.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Paginated'
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/CatalogItem' }
    post:
      tags: [Catalog]
      summary: Create a catalog item
      operationId: createCatalogItem
      security: [ { bearerAuth: [] }, { oauth2: [mcp.write] } ]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CatalogItemCreate' }
      responses:
        '201':
          description: Created item.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CatalogItem' }

  /catalog-items/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string, format: uuid }
    patch:
      tags: [Catalog]
      summary: Update a catalog item
      operationId: updateCatalogItem
      security: [ { bearerAuth: [] }, { oauth2: [mcp.write] } ]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CatalogItemCreate' }
      responses:
        '200':
          description: Updated item.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CatalogItem' }
    delete:
      tags: [Catalog]
      summary: Delete a catalog item
      operationId: deleteCatalogItem
      security: [ { bearerAuth: [] }, { oauth2: [mcp.write] } ]
      responses:
        '204': { description: Deleted. }

  # ──────────────────────────────── Tasks ─────────────────────────────────
  /tasks:
    get:
      tags: [Tasks]
      summary: List tasks
      operationId: listTasks
      security: [ { bearerAuth: [] }, { oauth2: [mcp.read] } ]
      parameters:
        - $ref: '#/components/parameters/Page'
        - $ref: '#/components/parameters/PerPage'
        - name: status
          in: query
          schema: { type: array, items: { type: string } }
          style: form
          explode: true
        - name: event_id
          in: query
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: Paginated list of tasks.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Paginated'
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/Task' }
    post:
      tags: [Tasks]
      summary: Create a task
      operationId: createTask
      security: [ { bearerAuth: [] }, { oauth2: [mcp.write] } ]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/TaskCreate' }
      responses:
        '201':
          description: Created task.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Task' }

  /tasks/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string, format: uuid }
    patch:
      tags: [Tasks]
      summary: Update a task
      operationId: updateTask
      security: [ { bearerAuth: [] }, { oauth2: [mcp.write] } ]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/TaskCreate' }
      responses:
        '200':
          description: Updated task.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Task' }
    delete:
      tags: [Tasks]
      summary: Delete a task
      operationId: deleteTask
      security: [ { bearerAuth: [] }, { oauth2: [mcp.write] } ]
      responses:
        '204': { description: Deleted. }

  # ───────────────────────────────── Team ─────────────────────────────────
  /team-members:
    get:
      tags: [Team]
      summary: List team members
      operationId: listTeamMembers
      security: [ { bearerAuth: [] }, { oauth2: [mcp.read] } ]
      responses:
        '200':
          description: Members of the workspace.
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    account_id: { type: string, format: uuid }
                    name: { type: string }
                    email: { type: string, format: email }
                    role: { type: string }

  # ─────────────────────────────── Analytics ──────────────────────────────
  /analytics/events:
    get:
      tags: [Analytics]
      summary: Event analytics
      operationId: getEventsAnalytics
      security: [ { bearerAuth: [] }, { oauth2: [mcp.read] } ]
      parameters:
        - name: from
          in: query
          schema: { type: string, format: date }
        - name: to
          in: query
          schema: { type: string, format: date }
      responses:
        '200':
          description: Aggregated event metrics.
          content:
            application/json:
              schema: { type: object, additionalProperties: true }

  /analytics/commercial:
    get:
      tags: [Analytics]
      summary: Commercial analytics
      operationId: getCommercialAnalytics
      security: [ { bearerAuth: [] }, { oauth2: [mcp.read] } ]
      parameters:
        - name: from
          in: query
          schema: { type: string, format: date }
        - name: to
          in: query
          schema: { type: string, format: date }
      responses:
        '200':
          description: Aggregated commercial metrics.
          content:
            application/json:
              schema: { type: object, additionalProperties: true }

  /analytics/accounting:
    get:
      tags: [Analytics]
      summary: Accounting analytics
      operationId: getAccountingAnalytics
      security: [ { bearerAuth: [] }, { oauth2: [mcp.read] } ]
      parameters:
        - name: from
          in: query
          schema: { type: string, format: date }
        - name: to
          in: query
          schema: { type: string, format: date }
      responses:
        '200':
          description: Aggregated accounting metrics.
          content:
            application/json:
              schema: { type: object, additionalProperties: true }

  # ──────────────────────────────── Search ────────────────────────────────
  /search:
    get:
      tags: [Search]
      summary: Global search
      operationId: globalSearch
      security: [ { bearerAuth: [] }, { oauth2: [mcp.read] } ]
      parameters:
        - name: q
          in: query
          required: true
          schema: { type: string }
        - name: types
          in: query
          description: Restrict result types.
          schema:
            type: array
            items: { type: string, enum: [event, company, person] }
          style: form
          explode: true
      responses:
        '200':
          description: Search results.
          content:
            application/json:
              schema:
                type: object
                properties:
                  results:
                    type: array
                    items: { $ref: '#/components/schemas/SearchResult' }

  # ─────────────────────────────── Notifications ──────────────────────────
  /notifications:
    get:
      tags: [Notifications]
      summary: List notifications
      operationId: listNotifications
      security: [ { bearerAuth: [] }, { oauth2: [mcp.read] } ]
      parameters:
        - $ref: '#/components/parameters/Page'
        - $ref: '#/components/parameters/PerPage'
      responses:
        '200':
          description: Paginated notifications.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Paginated'
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          type: object
                          properties:
                            id: { type: string, format: uuid }
                            type: { type: string }
                            title: { type: string }
                            body: { type: string }
                            read: { type: boolean }
                            created_at: { type: string, format: date-time }

  # ─────────────────────────────── Webhooks ───────────────────────────────
  /webhooks:
    get:
      tags: [Webhooks]
      summary: List webhook subscriptions
      description: >
        Lists the workspace's outbound webhook subscriptions. The signing
        secret is never returned after creation.
      operationId: listWebhookSubscriptions
      security: [ { bearerAuth: [] }, { oauth2: [mcp.read] } ]
      responses:
        '200':
          description: Webhook subscriptions.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/WebhookSubscription' }
    post:
      tags: [Webhooks]
      summary: Create a webhook subscription
      description: >
        Subscribes an HTTPS endpoint to domain events (Zapier REST Hooks
        contract). Each delivery is a JSON POST signed with
        `X-Joinways-Signature: sha256=<hex>` — the HMAC-SHA256 of the raw
        request body using the `secret` returned by this call (only returned
        once). A `410 Gone` response from the endpoint deletes the
        subscription; repeated failures auto-disable it.
      operationId: createWebhookSubscription
      security: [ { bearerAuth: [] }, { oauth2: [mcp.write] } ]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [target_url, event_types]
              properties:
                target_url:
                  type: string
                  format: uri
                  description: HTTPS endpoint that receives the deliveries.
                event_types:
                  type: array
                  minItems: 1
                  items:
                    type: string
                    enum: [lead.created, event.created, event.status_changed, quote.status_changed]
                source:
                  type: string
                  enum: [zapier, custom]
                  default: custom
      responses:
        '201':
          description: Subscription created. `secret` is only returned here.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/WebhookSubscription'
                  - type: object
                    properties:
                      secret: { type: string }
        '422':
          description: Active subscription quota reached.

  /webhooks/{id}:
    delete:
      tags: [Webhooks]
      summary: Delete a webhook subscription
      operationId: deleteWebhookSubscription
      security: [ { bearerAuth: [] }, { oauth2: [mcp.write] } ]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: Subscription deleted (idempotent).
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: API key or OAuth token
      description: >
        The simplest way to authenticate. Paste a token in the `Authorization:
        Bearer …` header. The token is either an **API key** (`jw_…`) or an
        **OAuth 2.1 access token** (`jw_oat_…`) — both created from
        Settings → Integrations → API in your workspace. A token is bound to a
        single workspace.
    oauth2:
      type: oauth2
      description: >
        OAuth 2.1 authorization_code grant with mandatory PKCE (S256). Access tokens
        are opaque strings prefixed `jw_oat_`, valid for 30 days, presented as
        `Authorization: Bearer …`. Tokens are bound to a single workspace.
      flows:
        authorizationCode:
          authorizationUrl: https://joinways.app/api/oauth/authorize
          tokenUrl: https://joinways.app/api/oauth/token
          scopes:
            mcp.read: Read access to all resources.
            mcp.write: Create, update, delete and actions.

  parameters:
    Page:
      name: page
      in: query
      description: 1-based page number.
      schema: { type: integer, minimum: 1, default: 1 }
    PerPage:
      name: per_page
      in: query
      description: Items per page.
      schema: { type: integer, minimum: 1, maximum: 500, default: 50 }
    Sort:
      name: sort
      in: query
      description: Field to sort by.
      schema: { type: string }
    Order:
      name: order
      in: query
      schema: { type: string, enum: [asc, desc], default: desc }
    EventId:
      name: id
      in: path
      required: true
      schema: { type: string, format: uuid }
    QuoteId:
      name: id
      in: path
      required: true
      schema: { type: string, format: uuid }
    CompanyId:
      name: id
      in: path
      required: true
      schema: { type: string, format: uuid }
    VenueId:
      name: venueId
      in: path
      required: true
      schema: { type: string, format: uuid }

  responses:
    BadRequest:
      description: Validation failed.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    Unauthorized:
      description: Missing, invalid, or expired bearer token.
      headers:
        WWW-Authenticate:
          schema: { type: string }
          example: 'Bearer resource_metadata="https://mcp.joinways.app/.well-known/oauth-protected-resource"'
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    NotFound:
      description: Resource not found in this workspace.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }

  schemas:
    Error:
      type: object
      description: Standard error envelope returned on any non-2xx response.
      required: [error]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code:
              type: string
              description: Stable machine-readable error code.
              enum:
                - invalid_request
                - unauthorized
                - forbidden
                - not_found
                - conflict
                - unprocessable
                - rate_limited
                - server_error
              example: not_found
            message: { type: string, description: Human-readable message (do not parse)., example: Event not found }
            details:
              description: Optional structured details (e.g. field-level validation errors).
              oneOf:
                - type: object
                  additionalProperties: true
                - type: 'null'

    Paginated:
      type: object
      description: Envelope wrapping a page of results.
      required: [data, pagination]
      properties:
        data:
          type: array
          description: The page of results (item type depends on the endpoint).
          items: {}
        pagination:
          type: object
          required: [page, per_page, total, total_pages]
          properties:
            page: { type: integer, description: Current 1-based page., example: 1 }
            per_page: { type: integer, description: Items per page., example: 50 }
            total: { type: integer, description: Total matching items across all pages., example: 1234 }
            total_pages: { type: integer, example: 25 }

    WebhookSubscription:
      type: object
      description: An outbound webhook subscription (Zapier or custom endpoint).
      properties:
        id: { type: string, format: uuid }
        target_url: { type: string, format: uri }
        event_types:
          type: array
          items:
            type: string
            enum: [lead.created, event.created, event.status_changed, quote.status_changed]
        source: { type: string, enum: [zapier, custom] }
        disabled_at: { type: string, format: date-time, nullable: true }
        disabled_reason: { type: string, nullable: true }
        last_delivery_at: { type: string, format: date-time, nullable: true }
        created_at: { type: string, format: date-time }

    Event:
      type: object
      description: An event (a client request / booking) in the workspace.
      properties:
        id: { type: string, format: uuid }
        status:
          type: string
          enum: [option, confirmed, lost, cancelled]
          description: |
            Pipeline status. `option` = pending/tentative, `confirmed` = booked,
            `lost` = did not convert, `cancelled` = cancelled after booking.
          example: confirmed
        title: { type: string, example: 'Corporate dinner — Acme Corp' }
        type:
          type: string
          description: Free-text event category, e.g. dinner, wedding, seminar, cocktail.
          example: dinner
        start_date: { type: string, format: date, example: '2026-09-12' }
        end_date: { type: ['string', 'null'], format: date, example: '2026-09-12' }
        selected_dates:
          type: ['array', 'null']
          items: { type: string, format: date }
          description: >-
            Explicit non-contiguous calendar days (YYYY-MM-DD) for multi-day
            events that skip days. When set, `start_date`/`end_date` are the
            min/max envelope; null means a plain contiguous range.
          example: ['2026-09-12', '2026-09-14']
        timezone: { type: ['string', 'null'], example: Europe/Paris }
        flexible:
          type: boolean
          description: Whether the client's dates are flexible.
          example: false
        pax: { type: ['integer', 'null'], description: Number of guests., example: 80 }
        budget: { type: ['number', 'null'], description: Estimated budget (in the workspace currency)., example: 12000 }
        company_id: { type: ['string', 'null'], format: uuid }
        company_name: { type: ['string', 'null'], example: Acme Corp }
        company_website: { type: ['string', 'null'], example: 'https://acme.com' }
        venue_id:
          type: ['string', 'null']
          format: uuid
          description: The venue (internally `location_id`).
        venue_name: { type: ['string', 'null'], example: Le Grand Salon }
        contact_person_id: { type: ['string', 'null'], format: uuid }
        contact_first_name: { type: ['string', 'null'], example: Jane }
        contact_last_name: { type: ['string', 'null'], example: Doe }
        contact_email: { type: ['string', 'null'], format: email, example: jane@acme.com }
        contact_phone: { type: ['string', 'null'] }
        owner_name: { type: ['string', 'null'], description: Name of the team member the event is assigned to. }
        assigned_to_account_id:
          type: ['string', 'null']
          format: uuid
          description: The workspace member this record is assigned to.
        archived_at: { type: ['string', 'null'], format: date-time, description: Set when the event is archived; otherwise null. }
        event_group_id:
          type: ['string', 'null']
          format: uuid
          description: Groups sibling events (alternative venue options for one client request).
        tasks_count: { type: integer, description: Open task count. Only present when the list is requested with counts. }
        quotes_count: { type: integer, description: Quote count. Only present when the list is requested with counts. }
        beo_public_url:
          type: ['string', 'null']
          format: uri
          description: >-
            Shareable, no-login URL of the public BEO (Banquet Event Order)
            page. `null` when no share token has been issued or it has expired.
            Only returned by `GET`/`PATCH /events/{id}` (not by the list
            endpoint).
          example: 'https://joinways.app/beo/8f3c2a1e-...'
        created_at: { type: string, format: date-time }

    EventCreate:
      type: object
      required: [title, start_date]
      description: Payload to create an event. `venue_id` is required by the API in practice.
      properties:
        title: { type: string, example: 'Corporate dinner — Acme Corp' }
        type: { type: string, description: Free-text event category., example: dinner }
        status:
          type: string
          enum: [option, confirmed, lost, cancelled]
          default: option
        start_date: { type: string, format: date, example: '2026-09-12' }
        end_date: { type: string, format: date, example: '2026-09-12' }
        selected_dates:
          type: array
          items: { type: string, format: date }
          description: >-
            Optional non-contiguous calendar days (YYYY-MM-DD). When ≥2 distinct
            days are provided, `start_date`/`end_date` act as the time envelope
            and these become the per-day source of truth. On update, pass an
            empty array (or a single-day list) to revert to a contiguous range.
          example: ['2026-09-12', '2026-09-14']
        timezone: { type: string, example: Europe/Paris }
        flexible: { type: boolean, example: false }
        pax: { type: integer, example: 80 }
        budget: { type: number, example: 12000 }
        company_id:
          type: string
          format: uuid
          description: Link to an existing company. Alternatively pass `company_name` to auto-create one.
        company_name: { type: string, description: Auto-creates the company if it doesn't exist., example: Acme Corp }
        venue_id: { type: string, format: uuid, description: The venue to book (internally `location_id`). }
        contact_first_name: { type: string, example: Jane }
        contact_last_name: { type: string, example: Doe }
        contact_email: { type: string, format: email, description: Auto-creates the contact if it doesn't exist., example: jane@acme.com }
        contact_phone: { type: string }
        contact_person_id: { type: string, format: uuid, description: Link to an existing contact instead of creating one. }
        assigned_to_account_id:
          type: string
          format: uuid
          description: >-
            Must reference a member of the current workspace, otherwise the
            request fails with `400 invalid_request`.

    EventUpdate:
      allOf:
        - $ref: '#/components/schemas/EventCreate'
      description: All fields optional on update.

    Quote:
      type: object
      description: A quote (proposal) attached to an event.
      properties:
        id: { type: string, format: uuid }
        event_id: { type: string, format: uuid, description: The event this quote belongs to. }
        status:
          type: string
          enum: [draft, sent, accepted, rejected, expired]
          description: |
            `draft` = not yet sent, `sent` = sent to the client, `accepted` /
            `rejected` = client decision, `expired` = past `valid_until`.
          example: sent
        currency: { type: string, description: ISO 4217 currency code., example: EUR }
        subtotal: { type: number, description: Total before tax., example: 9500 }
        tax_total: { type: number, description: Total tax., example: 1900 }
        total: { type: number, description: Total including tax., example: 11400 }
        valid_until: { type: ['string', 'null'], format: date, example: '2026-08-31' }
        created_at: { type: string, format: date-time }

    QuoteCreate:
      type: object
      required: [event_id]
      description: Creates a quote header. Add line items via the items endpoint (or the optional `items` array).
      properties:
        event_id: { type: string, format: uuid }
        currency: { type: string, example: EUR }
        valid_until: { type: string, format: date, example: '2026-08-31' }
        items:
          type: array
          description: Optional initial line items.
          items: { $ref: '#/components/schemas/QuoteItemCreate' }

    QuoteUpdate:
      type: object
      description: >-
        Patch a quote header. Every field is optional; omit to leave untouched,
        pass `null` to clear a nullable field.
      properties:
        title: { type: string, maxLength: 200 }
        currency: { type: string, description: ISO 4217 currency code., example: EUR }
        signature_mode: { type: string }
        is_reverse_charge: { type: boolean }
        billing_profile_id:
          type: ['string', 'null']
          format: uuid
          description: >-
            Billing profile to invoice against. Must belong to the caller's
            workspace, otherwise the request fails with `400 invalid_request`.
        purchase_order: { type: ['string', 'null'] }
        expires_at: { type: ['string', 'null'], format: date-time }
        due_date: { type: ['string', 'null'], format: date-time }
        payment_date: { type: ['string', 'null'], format: date-time }
        payment_terms: { type: ['string', 'null'] }
        cancellation_terms: { type: ['string', 'null'] }
        postponement_terms: { type: ['string', 'null'] }

    QuoteItem:
      type: object
      description: A single line item on a quote.
      properties:
        id: { type: string, format: uuid }
        quote_id: { type: string, format: uuid }
        title: { type: string, example: 'Plated dinner — 3 courses' }
        description: { type: ['string', 'null'] }
        quantity: { type: number, example: 80 }
        unit_price: { type: number, description: Price per unit, excluding tax., example: 95 }
        vat: { type: number, description: VAT rate as a percentage (e.g. 20 for 20%)., example: 20 }
        total: { type: number, description: Computed line total., example: 7600 }
        location_item_id:
          type: ['string', 'null']
          format: uuid
          description: The catalog item this line was priced from, if any.
        is_option: { type: ['boolean', 'null'] }
        is_discount: { type: ['boolean', 'null'] }
        is_offered: { type: ['boolean', 'null'], description: Offered (free) line — bypasses the catalog minimum-price check. }
        discount_amount: { type: ['number', 'null'] }
        event_day: { type: ['integer', 'null'], description: 1-based day this line belongs to (multi-day events). }
        event_day_date: { type: ['string', 'null'], format: date }
        sort_order: { type: ['integer', 'null'], description: Display order within the quote. }

    QuoteItemCreate:
      type: object
      required: [title, unit_price]
      properties:
        title: { type: string, example: 'Plated dinner — 3 courses' }
        description: { type: string }
        quantity: { type: number, default: 1, example: 80 }
        unit_price: { type: number, description: Price per unit, excluding tax., example: 95 }
        vat: { type: number, default: 20, description: VAT rate as a percentage., example: 20 }
        location_item_id:
          type: string
          format: uuid
          description: >-
            Optional catalog item to price this line from. Must belong to the
            caller's workspace, otherwise the request fails with
            `400 invalid_request`. When set, the line's `unit_price` is checked
            against the catalog minimum price.
        is_option: { type: boolean, default: false }
        is_discount: { type: boolean, default: false }
        is_offered: { type: boolean, default: false }
        discount_amount: { type: number, default: 0 }
        event_day: { type: integer, default: 1, description: 1-based day for multi-day events. }
        event_day_date: { type: string, format: date, description: Stable per-day anchor (YYYY-MM-DD); preferred over event_day. }
        sort_order: { type: integer, default: 0 }

    Company:
      type: object
      description: A company (client account) in the CRM.
      properties:
        id: { type: string, format: uuid }
        name: { type: string, example: Acme Corp }
        website: { type: ['string', 'null'], example: 'https://acme.com' }
        email: { type: ['string', 'null'], format: email }
        phone: { type: ['string', 'null'] }
        vat_number: { type: ['string', 'null'], description: VAT / tax identifier., example: FR12345678901 }
        registration_number: { type: ['string', 'null'], description: Company registration number (e.g. SIRET). }
        address: { type: ['string', 'null'] }
        city: { type: ['string', 'null'], example: Paris }
        country: { type: ['string', 'null'], description: ISO 3166 alpha-2 code., example: FR }
        revenue: { type: ['number', 'null'], description: Computed total revenue from this company's events. }
        events_count: { type: ['integer', 'null'], description: Total events for this company. }
        confirmed_events_count: { type: ['integer', 'null'] }
        last_activity_date: { type: ['string', 'null'], format: date-time }
        has_upcoming_event: { type: ['boolean', 'null'] }
        created_at: { type: string, format: date-time }

    CompanyCreate:
      type: object
      required: [name]
      properties:
        name: { type: string, example: Acme Corp }
        website: { type: string, example: 'https://acme.com' }
        email: { type: string, format: email }
        phone: { type: string }
        vat_number: { type: string, description: VAT / tax identifier. }
        registration_number: { type: string, description: Company registration number (e.g. SIRET). }
        address: { type: string }
        city: { type: string, example: Paris }
        country: { type: string, description: ISO 3166 alpha-2 code., example: FR }

    Person:
      type: object
      description: A contact (person), optionally linked to a company.
      properties:
        id: { type: string, format: uuid }
        first_name: { type: ['string', 'null'], example: Jane }
        last_name: { type: ['string', 'null'], example: Doe }
        email: { type: ['string', 'null'], format: email, example: jane@acme.com }
        phone: { type: ['string', 'null'] }
        job_title: { type: ['string', 'null'], example: Head of Events }
        company_id: { type: ['string', 'null'], format: uuid, description: The company this contact belongs to. }
        created_at: { type: string, format: date-time }

    PersonCreate:
      type: object
      properties:
        first_name: { type: string, example: Jane }
        last_name: { type: string, example: Doe }
        email: { type: string, format: email, example: jane@acme.com }
        phone: { type: string }
        job_title: { type: string, example: Head of Events }
        company_id: { type: string, format: uuid }

    Lead:
      type: object
      description: An inbound enquiry (email, message, platform request) before it becomes an event.
      properties:
        id: { type: string, format: uuid }
        status:
          type: string
          description: Workflow status, e.g. `new`, `validated` (converted), `rejected`.
          example: new
        source:
          type: string
          description: 'Inbound channel.'
          enum: [email, whatsapp, instagram, sms, phone, platform, other]
          example: email
        source_type: { type: ['string', 'null'], description: Sub-type of the source (e.g. platform name). }
        subject: { type: ['string', 'null'] }
        content: { type: ['string', 'null'], description: Raw message body. }
        sender_name: { type: ['string', 'null'] }
        sender_email: { type: ['string', 'null'], format: email }
        is_read: { type: boolean }
        event_id: { type: ['string', 'null'], format: uuid, description: Set once the lead is converted or linked to an event. }
        event_date: { type: ['string', 'null'], format: date, description: Requested event date, if detected. }
        venue_id: { type: ['string', 'null'], format: uuid }
        received_at: { type: ['string', 'null'], format: date-time }
        created_at: { type: string, format: date-time }

    Venue:
      type: object
      description: A venue (internally a `location`). Sub-spaces of a venue are also locations.
      properties:
        id: { type: string, format: uuid }
        name: { type: string, example: Le Grand Salon }
        address: { type: ['string', 'null'] }
        timezone: { type: ['string', 'null'], example: Europe/Paris }
        color: { type: ['string', 'null'], description: Hex color used in the calendar UI., example: '#6358F4' }

    BusinessHours:
      type: object
      required: [dayOfWeek, openTime, closeTime]
      properties:
        dayOfWeek: { type: integer, minimum: 0, maximum: 6, description: 0 = Sunday. }
        openTime: { type: string, example: '09:00' }
        closeTime: { type: string, example: '23:00' }

    Closure:
      type: object
      properties:
        id: { type: string, format: uuid }
        startDate: { type: string, format: date }
        endDate: { type: string, format: date }
        startTime: { type: ['string', 'null'] }
        endTime: { type: ['string', 'null'] }
        reason: { type: ['string', 'null'] }
        color: { type: ['string', 'null'] }
        closureType: { type: string, enum: [manual, holiday] }

    ClosureCreate:
      type: object
      required: [startDate, endDate]
      properties:
        startDate: { type: string, format: date }
        endDate: { type: string, format: date }
        startTime: { type: string }
        endTime: { type: string }
        reason: { type: string, maxLength: 200 }
        color: { type: string, pattern: '^#([0-9a-fA-F]{3}){1,2}$' }
        closureType: { type: string, enum: [manual, holiday], default: manual }

    CatalogItem:
      type: object
      description: A priceable item (catering, service, equipment…) belonging to a venue.
      properties:
        id: { type: string, format: uuid }
        name: { type: string, example: 'Plated dinner — 3 courses' }
        description: { type: ['string', 'null'] }
        unit_price: { type: number, description: Price per unit, excluding tax., example: 95 }
        currency: { type: string, description: ISO 4217 currency code., example: EUR }
        vat_rate: { type: ['number', 'null'], description: VAT rate as a percentage (e.g. 20)., example: 20 }
        category: { type: ['string', 'null'], example: Catering }
        pricing_mode:
          type: ['string', 'null']
          enum: [flat, per_person, tiered, null]
          description: >-
            Indique laquelle des deux colonnes de prix fait foi : `flat` lit
            `unit_price`, `per_person` lit `unit_price_per_person`, `tiered` fait
            venir le prix des règles tarifaires de l'article.
          example: flat
        resolved_unit_price:
          type: number
          description: >-
            Présent UNIQUEMENT si `pax` ou `date` a été passé en requête. Prix
            retenu pour ce contexte, règles tarifaires appliquées.
        resolved_min_price:
          type: ['number', 'null']
          description: >-
            Présent avec `resolved_unit_price`. Prix plancher de la règle
            retenue, ou celui du catalogue à défaut.
        applied_price_rule:
          type: ['object', 'null']
          description: >-
            Présent avec `resolved_unit_price`. La règle retenue, ou `null` si le
            prix vient du catalogue.
          properties:
            id: { type: string, format: uuid }
            label: { type: ['string', 'null'], example: 'Haute saison' }
        created_at: { type: string, format: date-time }

    CatalogItemCreate:
      type: object
      required: [venue_id, name, unit_price]
      properties:
        venue_id: { type: string, format: uuid, description: The venue this catalog item belongs to. }
        name: { type: string, example: 'Plated dinner — 3 courses' }
        description: { type: string }
        unit_price: { type: number, description: Price per unit, excluding tax., example: 95 }
        currency: { type: string, example: EUR }
        vat_rate: { type: number, description: VAT rate as a percentage., example: 20 }
        category: { type: string, example: Catering }

    Task:
      type: object
      description: A to-do item, optionally linked to an event.
      properties:
        id: { type: string, format: uuid }
        title: { type: string, example: 'Send the signed contract' }
        status:
          type: string
          enum: [todo, in_progress, waiting, done, cancelled]
          example: todo
        priority:
          type: string
          enum: [low, medium, high, urgent]
          example: medium
        due_date: { type: ['string', 'null'], format: date, example: '2026-09-01' }
        event_id: { type: ['string', 'null'], format: uuid }
        assigned_to_account_id:
          type: ['string', 'null']
          format: uuid
          description: The workspace member this record is assigned to.
        created_at: { type: string, format: date-time }

    TaskCreate:
      type: object
      required: [title]
      properties:
        title: { type: string, example: 'Send the signed contract' }
        status: { type: string, enum: [todo, in_progress, waiting, done, cancelled], default: todo }
        priority: { type: string, enum: [low, medium, high, urgent], default: medium }
        due_date: { type: string, format: date, example: '2026-09-01' }
        event_id: { type: string, format: uuid }
        assigned_to_account_id:
          type: string
          format: uuid
          description: >-
            Must reference a member of the current workspace, otherwise the
            request fails with `400 invalid_request`.

    Note:
      type: object
      properties:
        id: { type: string, format: uuid }
        content: { type: string }
        author_name: { type: ['string', 'null'] }
        created_at: { type: string, format: date-time }

    EventContact:
      type: object
      description: A day-of (on-site) contact for an event.
      properties:
        id: { type: string, format: uuid }
        personId: { type: ['string', 'null'], format: uuid, description: Linked address-book person, if any. }
        displayName: { type: string, example: Marie Dupont }
        role: { type: ['string', 'null'], example: Wedding planner }
        phone: { type: ['string', 'null'] }
        email: { type: ['string', 'null'], format: email }
        isOnsite: { type: boolean, description: Physically present at the venue (vs. reachable remotely). }
        isPrimary: { type: boolean, description: The main day-of contact; mirrored to the event's on-site contact. }
        notes: { type: ['string', 'null'] }
        sortOrder: { type: integer }
        dayId: { type: ['string', 'null'], format: uuid, description: Pins the contact to a specific event day. null = all days. }

    EventContactInput:
      type: object
      required: [displayName, isOnsite]
      properties:
        id: { type: string, format: uuid, description: Include to update an existing row; omit to create. }
        personId: { type: ['string', 'null'], format: uuid }
        displayName: { type: string, maxLength: 200 }
        role: { type: ['string', 'null'], maxLength: 120 }
        phone: { type: ['string', 'null'] }
        email: { type: ['string', 'null'] }
        isOnsite: { type: boolean }
        isPrimary: { type: boolean }
        notes: { type: ['string', 'null'] }
        sortOrder: { type: integer, minimum: 0 }
        dayId:
          type: ['string', 'null']
          format: uuid
          description: Must reference a day of THIS event. null = all days.

    EventStaffMember:
      type: object
      description: An operational day-of staff assignment.
      properties:
        id: { type: string, format: uuid }
        personId: { type: ['string', 'null'], format: uuid }
        displayName: { type: string, example: Léa Martin }
        role: { type: ['string', 'null'], example: Régie }
        callTime: { type: ['string', 'null'], description: Arrival time (HH:MM)., example: '08:30' }
        leaveTime: { type: ['string', 'null'], description: Departure time (HH:MM). }
        phone: { type: ['string', 'null'] }
        email: { type: ['string', 'null'], format: email }
        notes: { type: ['string', 'null'] }
        sortOrder: { type: integer }
        dayId: { type: ['string', 'null'], format: uuid, description: Pins the assignment to a specific event day. null = all days. }

    EventStaffMemberInput:
      type: object
      required: [displayName]
      properties:
        id: { type: string, format: uuid, description: Include to update an existing row; omit to create. }
        personId: { type: ['string', 'null'], format: uuid }
        displayName: { type: string, maxLength: 200 }
        role: { type: ['string', 'null'], maxLength: 120 }
        callTime: { type: ['string', 'null'], description: 'HH:MM (24h) or empty.' }
        leaveTime: { type: ['string', 'null'], description: 'HH:MM (24h) or empty.' }
        phone: { type: ['string', 'null'] }
        email: { type: ['string', 'null'] }
        notes: { type: ['string', 'null'] }
        sortOrder: { type: integer, minimum: 0 }
        dayId:
          type: ['string', 'null']
          format: uuid
          description: Must reference a day of THIS event. null = all days.

    EventDay:
      type: object
      description: One day of a multi-day event, with its per-day BEO overrides.
      properties:
        id: { type: ['string', 'null'], format: uuid }
        dayIndex: { type: integer, description: 0-based position within the event. }
        dayDate: { type: string, format: date }
        setupType:
          type: ['string', 'null']
          description: 'One of: theatre, classroom, u-shape, boardroom, banquet, cocktail, custom.'
        setupNotes: { type: ['string', 'null'] }
        cateringNotes: { type: ['string', 'null'] }
        technicalNotes: { type: ['string', 'null'] }
        serviceNotes: { type: ['string', 'null'] }

    EventDayInput:
      type: object
      required: [dayIndex, dayDate]
      properties:
        id: { type: string, format: uuid }
        dayIndex: { type: integer, minimum: 0 }
        dayDate: { type: string, format: date, description: 'YYYY-MM-DD.' }
        setupType:
          type: ['string', 'null']
          description: 'One of: theatre, classroom, u-shape, boardroom, banquet, cocktail, custom.'
        setupNotes: { type: ['string', 'null'] }
        cateringNotes: { type: ['string', 'null'] }
        technicalNotes: { type: ['string', 'null'] }
        serviceNotes: { type: ['string', 'null'] }

    BeoUpdate:
      type: object
      description: Event-level BEO fields. Every field is optional; omit to leave untouched.
      properties:
        schedule:
          type: array
          description: Full schedule (replaces existing). Pass [] to clear.
          items: { type: object, additionalProperties: true }
        setup_type:
          type: ['string', 'null']
          description: 'One of: theatre, classroom, u-shape, boardroom, banquet, cocktail, custom.'
        setup_notes: { type: ['string', 'null'] }
        catering_notes: { type: ['string', 'null'] }
        technical_notes: { type: ['string', 'null'] }
        service_notes: { type: ['string', 'null'] }
        beo_remarks:
          type: ['string', 'null']
          description: Free-text "Remarks" section rendered below Technical & Service. Pass null to clear.
        beo_vendors:
          type: array
          description: Full vendor list (replaces existing). Pass [] to clear.
          items: { type: object, additionalProperties: true }
        beo_context:
          type: ['string', 'null']
          description: One-line context shown atop the BEO. null/empty = auto-generated sentence.
        onsite_contact_person_id:
          type: ['string', 'null']
          format: uuid
          description: >-
            Address-book person used as the on-site contact. Must belong to the
            caller's workspace, otherwise the request fails with
            `400 invalid_request`.

    Email:
      type: object
      description: An email linked to an event.
      properties:
        id: { type: string, format: uuid }
        subject: { type: ['string', 'null'] }
        from_email: { type: ['string', 'null'], format: email }
        from_name: { type: ['string', 'null'] }
        to_emails: { type: array, items: { type: string } }
        snippet: { type: ['string', 'null'] }
        is_sent: { type: boolean, description: true = outbound (sent), false = inbound (received). }
        received_at: { type: ['string', 'null'], format: date-time }
        sent_at: { type: ['string', 'null'], format: date-time }

    EventFile:
      type: object
      properties:
        id: { type: string, format: uuid }
        filename: { type: ['string', 'null'] }
        content_type: { type: ['string', 'null'] }
        size_bytes: { type: ['integer', 'null'] }
        url:
          type: ['string', 'null']
          format: uri
          description: Short-lived signed download URL (expires after ~1 hour).
        created_at: { type: ['string', 'null'], format: date-time }

    SearchResult:
      type: object
      properties:
        id: { type: string }
        type: { type: string, enum: [event, person, company] }
        title: { type: string }
        subtitle: { type: ['string', 'null'] }
        url: { type: string }
