openapi: 3.1.0
info:
  title: Littledata CIP API (admin)
  version: "1.0.0"
  description: |
    The customer-success endpoints behind the Customer Intelligence Platform at
    https://cip.littledata.io — **Littledata admins only**. An agency `ldat_*` token or
    session gets 401 on every path here; this spec is for Littledata staff and for agents
    (a local Claude agent, a script) acting on their behalf.

    Authenticate with a per-user `ldat_*` bearer token minted from an admin session via
    `POST /auth/api-tokens` (documented in `openapi.yaml`). A `read`-scoped token covers every
    `GET` below; the `PUT` / `POST` / `DELETE` endpoints need a `write`-scoped token, which only
    an admin session can mint. A logged-in admin session (cookie or session Bearer) also works.

    Field-by-field meaning, the segment rules the dashboard applies client-side, and how to mint
    a write token live in the narrative contract, `CIP_DASHBOARD_CONTRACT.md`. This spec is the
    source of truth for paths, parameters and request bodies; where it disagrees with the
    handlers in `src/handlers/api/customers/`, the spec is wrong — please open a PR.

    Response envelope: most endpoints answer `{ "data": ... }`; the list may also answer a bare
    array. Read `data` when present.
servers:
  - url: https://alnmpsr7dc.execute-api.us-east-1.amazonaws.com/prod
    description: Production
tags:
  - name: Customers
    description: Read the customer list and one customer's detail, contacts, people, company, history, notes and audit checks.
  - name: Agencies
    description: Which agencies work with which customers, inferred nightly from contact email domains (see the contract's Agencies section).
  - name: Updates
    description: Change what a CSM can change in the dashboard. Need a `write`-scoped token or an admin session.
security:
  - adminToken: []
  - sessionAuth: []
paths:
  /customers:
    get:
      tags: [Customers]
      summary: List every customer
      description: |
        The whole `customers` collection in one call — no pagination. Filters are optional and
        combine with AND. Pull it once, unfiltered, and segment locally: the dashboard's tabs
        (account-managed, installed-not-paying, upcoming renewals, recent churn) are client-side
        rules over this list, spelled out in `CIP_DASHBOARD_CONTRACT.md#agent-access-api-tokens`.

        Only `ownerType: "external"` rows are listed — Littledata / staging stores stay in Mongo for
        direct shop lookup but never appear here. `phase` is the synced badge
        (`paying` | `trialing` | `free` | `test` | `uninstalled`); Free-named plans and free payment
        phases both carry `free`.
      operationId: listCustomers
      parameters:
        - in: query
          name: search
          schema: { type: string }
          description: Case-insensitive substring match on `shopName`.
        - in: query
          name: planGroup
          schema:
            type: string
            enum: [plus, flex, scale, free, legacy, other]
          description: Plan family. `other` matches rows with no plan.
        - in: query
          name: phase
          schema:
            type: string
            enum: [paying, trialing, uninstalled, free, test]
        - in: query
          name: healthStage
          schema: { $ref: "#/components/schemas/HealthStage" }
          description: Health band. The middle band is still `Yellow` on the API; the dashboard displays it as Amber.
      responses:
        "200":
          description: "Customer rows, as `{ data: [...] }` or a bare array."
          content:
            application/json:
              schema:
                oneOf:
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/CustomerListRow" }
                  - type: array
                    items: { $ref: "#/components/schemas/CustomerListRow" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
  /customers/{shopName}:
    get:
      tags: [Customers]
      summary: One customer's detail
      description: |
        Everything on the list row plus the health score's reasons (`healthScoreChanges`), the
        manual flags (`hadRecentPositiveCall`), `accountOwner` / `accountOwnerSetAt`, subsidiary
        store names, the churn snapshot for an uninstalled store, and the audit-check summary.
      operationId: getCustomer
      parameters:
        - $ref: "#/components/parameters/shopName"
      responses:
        "200":
          description: Customer detail
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/CustomerDetail" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /customers/{shopName}/contacts:
    get:
      tags: [Customers]
      summary: Intercom contacts mirrored for the store
      description: |
        App users Intercom knows for this store, with `isChampion`, `linkedinUrl`, `jobTitle`,
        `persona`, `lastSeenAt` and any detected `roleChange`. Roll up a brand by calling this for
        the parent and each subsidiary; the dashboard dedupes twins by latest login and hides shared
        mailboxes (`support@`, `info@`…) and rows with neither name nor email.
      operationId: getCustomerContacts
      parameters:
        - $ref: "#/components/parameters/shopName"
      responses:
        "200":
          description: "Contacts, as `{ data: [...] }` or a bare array."
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Contact" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
  /customers/{shopName}/leads:
    get:
      tags: [Customers]
      summary: Everyone the leads database holds for the brand
      description: |
        People marketing-automations tracks against the brand, every persona including Finance,
        with or without an email, synced nightly. Drop anyone whose email also appears in
        `/contacts` — they are the same person, and the contact record is the richer one.

        The path is `/leads`, not `/people`: a `/people` API Gateway resource exists live in the
        CORS-preflight stack and cannot move (see the comment beside the route in `serverless.yml`).
      operationId: getCustomerLeads
      parameters:
        - $ref: "#/components/parameters/shopName"
      responses:
        "200":
          description: People rows
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/BrandPerson" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
  /customers/{shopName}/agencies:
    get:
      tags: [Customers]
      summary: Agencies working with the store
      description: |
        Inferred nightly from the email domains of the store's mirrored Intercom contacts, resolved
        by marketing-automations to its agency companies (see the contract's Agencies section). Each
        row carries the contacts that proved the link, when the agency was last seen in the store,
        and every other store the same agency is linked to. Per store — roll a brand's stores up and
        merge on `agencyId`.
      operationId: getCustomerAgencies
      parameters:
        - $ref: "#/components/parameters/shopName"
      responses:
        "200":
          description: Agency rows, most recently seen first
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/CustomerAgency" }
        "401": { $ref: "#/components/responses/Unauthorized" }
  /agencies:
    get:
      tags: [Agencies]
      summary: Every agency any customer works with
      description: One row per agency with the stores it is linked to, contact and store counts, and headquarters from the company record. Most stores first.
      operationId: listAgencies
      responses:
        "200":
          description: Agency summaries
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/AgencySummary" }
        "401": { $ref: "#/components/responses/Unauthorized" }
  /agencies/{agencyId}:
    get:
      tags: [Agencies]
      summary: One agency, its firmographics and every linked store
      operationId: getAgency
      parameters:
        - name: agencyId
          in: path
          required: true
          description: marketing-automations `Company._id`
          schema: { type: string }
      responses:
        "200":
          description: Agency detail
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/AgencyDetail" }
        "400":
          description: Not a company id
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
  /agencies/{agencyId}/contacts:
    get:
      tags: [Agencies]
      summary: The agency's people who are app users on our customers
      description: |
        Every mirrored Intercom contact whose email domain resolved to this agency, across every
        customer they have logged in to — the same rows and the same shape as
        `/customers/{shopName}/contacts`, cut by agency instead of by store. `shopName` on each row
        is the customer that contact belongs to, so one person working on three brands appears three
        times; merge them on `intercomContactId`. An unknown agency answers an empty list.
      operationId: getAgencyContacts
      parameters:
        - $ref: "#/components/parameters/agencyId"
      responses:
        "200":
          description: Contact rows
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Contact" }
        "400":
          description: Not a company id
        "401": { $ref: "#/components/responses/Unauthorized" }
  /agencies/{agencyId}/people:
    get:
      tags: [Agencies]
      summary: Everyone the leads database holds at the agency
      description: |
        The agency's second contact list, in the shape of `/customers/{shopName}/leads` minus
        `shopName` and `syncedAt`. Read live from marketing-automations rather than from a nightly
        sync: an agency page is opened rarely and whoever opens it wants the newest addresses.
        `intercomContactId` is set when the person is also an app user on one of our customers —
        list them once, under `/agencies/{agencyId}/contacts`. A marketing-automations outage
        answers 500 rather than an empty list.
      operationId: getAgencyPeople
      parameters:
        - $ref: "#/components/parameters/agencyId"
      responses:
        "200":
          description: People rows
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/BrandPerson" }
        "400":
          description: Not a company id
        "401": { $ref: "#/components/responses/Unauthorized" }
  /customers/{shopName}/company:
    get:
      tags: [Customers]
      summary: Firmographic profile of the brand
      description: |
        Third-party enrichment, not billing fact: industry, headquarters, headcount, storefront
        plan and traffic, LinkedIn page, and revenue estimates from three sources that measure
        different things (see the contract's Company profile section). About 8% of accounts have no
        record; a subsidiary with none falls back to its parent's and the response says so.
      operationId: getCustomerCompany
      parameters:
        - $ref: "#/components/parameters/shopName"
      responses:
        "200":
          description: "Company profile, or `{ data: null }` when there is no record."
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    nullable: true
                    additionalProperties: true
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
  /customers/{shopName}/health-history:
    get:
      tags: [Customers]
      summary: Health score over time
      description: One point per recorded change of the score or its reasons, plus the trial → paying and churn dates.
      operationId: getCustomerHealthHistory
      parameters:
        - $ref: "#/components/parameters/shopName"
      responses:
        "200":
          description: History
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/HealthHistory" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
  /customers/{shopName}/notes:
    get:
      tags: [Customers]
      summary: CS notes on the store's contacts
      description: Notes mirrored from Intercom for this store's contacts, newest first. Notes deleted through CIP are filtered out.
      operationId: getCustomerNotes
      parameters:
        - $ref: "#/components/parameters/shopName"
      responses:
        "200":
          description: "Notes, as `{ data: [...] }` or a bare array."
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Note" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      tags: [Updates]
      summary: Add a note
      description: |
        **Writes a note into Intercom**, on this store's most recently seen mirrored contact, with
        the caller's name and an optional 🟢 / 🔴 sentiment header. The whole CS team sees it in
        Intercom, so an agent should confirm with its operator first.

        Post to a store that owns contacts: a store mirroring none answers 404 even when its brand
        siblings have plenty. Sentiment does not move the health score — that is
        `PUT …/health-flags`.
      operationId: addCustomerNote
      parameters:
        - $ref: "#/components/parameters/shopName"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [body]
              properties:
                body: { type: string, description: Plain text. }
                sentiment:
                  type: string
                  enum: [positive, negative]
                  description: Optional call sentiment, written into the Intercom note and onto the annotation.
      responses:
        "200":
          description: The created note; `data.note.id` confirms Intercom stored it.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      note: { $ref: "#/components/schemas/Note" }
                    additionalProperties: true
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404":
          description: Customer not found, or `No mirrored contacts for this shop` — post to a sibling store that has contacts.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "502":
          description: Intercom did not return a note id; nothing was saved.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
  /customers/{shopName}/notes/{noteId}:
    delete:
      tags: [Updates]
      summary: Hide a note from CIP
      description: |
        Tombstones the cached note so it no longer appears in `GET …/notes`. Intercom has no
        delete-note API, so the note stays on the Intercom contact. Call it against the note's
        own store — a brand's notes are rolled up from every store under the parent.
      operationId: deleteCustomerNote
      parameters:
        - $ref: "#/components/parameters/shopName"
        - in: path
          name: noteId
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Hidden.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      ok: { type: boolean }
                      noteId: { type: string }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
  /customers/{shopName}/audit-checks:
    get:
      tags: [Customers]
      summary: Open Monitor AI errors and warnings
      operationId: getCustomerAuditChecks
      parameters:
        - $ref: "#/components/parameters/shopName"
      responses:
        "200":
          description: Audit checks
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      auditChecks:
                        type: array
                        items:
                          type: object
                          properties:
                            _id: { type: string }
                            check: { type: string }
                            displayName: { type: string }
                            status:
                              type: integer
                              description: >
                                AuditStatus wire enum: -1 UNKNOWN, 0 SUCCESS, 1 INFO, 2 WARNING, 3 ERROR.
                                Serializers pass the integer through unchanged.
                              enum: [-1, 0, 1, 2, 3]
                            destination: { type: string }
                            entityId: { type: string }
                            eventName: { type: string }
                            updatedAt: { type: string, format: date-time }
                          additionalProperties: true
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
  /customers/{shopName}/account-owner:
    put:
      tags: [Updates]
      summary: Set or clear the account owner
      description: |
        Ownership is per store: set it on the parent and on any subsidiary a different CSM looks
        after. The nightly sync gives every paying or trialing account with no owner the default
        (David), so clearing an active account's owner does not stick past the next run.
      operationId: setAccountOwner
      parameters:
        - $ref: "#/components/parameters/shopName"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [accountOwner]
              properties:
                accountOwner:
                  type: string
                  enum: [David, Cornelia, Cesar, Edward]
                  nullable: true
                  description: One of the CS team, or `null` to clear.
      responses:
        "200":
          description: The stored owner.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      shopName: { type: string }
                      accountOwner: { type: string, nullable: true }
                      accountOwnerSetAt: { type: string, format: date-time, nullable: true }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
  /customers/{shopName}/health-flags:
    put:
      tags: [Updates]
      summary: Record a recent positive call
      description: |
        The one manual input to the health score. Setting it true adds the "recent positive call"
        line and re-scores the customer; the change shows in `/health-history` with
        `source: "health-flags"`.
      operationId: setHealthFlags
      parameters:
        - $ref: "#/components/parameters/shopName"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                hadRecentPositiveCall: { type: boolean }
      responses:
        "200":
          description: The re-scored customer. Treat as a passthrough.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: object, additionalProperties: true }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
  /customers/{shopName}/contacts/{contactId}/champion:
    put:
      tags: [Updates]
      summary: Mark or unmark a contact as the champion
      description: |
        **Tags the contact in Intercom** ("Champion") and caches the flag on the mirrored contact.
        A champion who later leaves the company is what the health score's "Champion left" line
        reads, so confirm with the operator before changing it.
      operationId: setContactChampion
      parameters:
        - $ref: "#/components/parameters/shopName"
        - $ref: "#/components/parameters/contactId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [isChampion]
              properties:
                isChampion: { type: boolean }
      responses:
        "200":
          description: The contact's new state.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      intercomContactId: { type: string }
                      isChampion: { type: boolean }
                      tags: { type: array, items: { type: string } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
  /customers/{shopName}/contacts/{contactId}/linkedin:
    put:
      tags: [Updates]
      summary: Pin or clear a contact's LinkedIn profile URL
      operationId: setContactLinkedIn
      parameters:
        - $ref: "#/components/parameters/shopName"
        - $ref: "#/components/parameters/contactId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [linkedinUrl]
              properties:
                linkedinUrl:
                  type: string
                  nullable: true
                  description: A `linkedin.com/in/…` profile URL (http is upgraded to https), or `null` to clear.
      responses:
        "200":
          description: The stored URL and its source (`manual`).
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: object, additionalProperties: true }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
components:
  securitySchemes:
    adminToken:
      type: http
      scheme: bearer
      bearerFormat: ldat
      description: |
        `ldat_*` token minted from a Littledata **admin** session via `POST /auth/api-tokens`.
        `read` scope covers every GET here; `write` scope (admin-only to mint) is needed for the
        PUT / POST / DELETE endpoints. An agency user's token answers 401 on every path in this spec.
    sessionAuth:
      type: http
      scheme: bearer
      description: Logged-in admin session (cookie `session=` or session Bearer). What the dashboard itself uses.
  parameters:
    shopName:
      in: path
      name: shopName
      required: true
      schema: { type: string }
      description: The store's `shopName` from the list, usually `*.myshopify.com`. URL-encode it.
    agencyId:
      name: agencyId
      in: path
      required: true
      description: marketing-automations `Company._id`
      schema: { type: string }
    contactId:
      in: path
      name: contactId
      required: true
      schema: { type: string }
      description: The contact's `id` from `GET …/contacts` (the Intercom contact id).
  schemas:
    HealthStage:
      type: string
      enum: [Red, Yellow, Green]
      description: The middle band is `Yellow` on the wire; the dashboard shows it as Amber.
    Phase:
      type: string
      enum: [paying, trialing, free, test, uninstalled]
      description: Synced by syncCustomersFromShops. Free-named plans and free payment phases both use `free`.
    CustomerListRow:
      type: object
      required: [shopName]
      description: |
        Every field the dashboard's table and KPI tiles read. Optional fields are omitted, not
        nulled, when the sync has not stamped them. Full semantics in `CIP_DASHBOARD_CONTRACT.md`
        under Customer List.
      properties:
        _id: { type: string }
        shopName: { type: string }
        domain: { type: string, nullable: true }
        intercomCompanyId: { type: string, nullable: true }
        shopifyStoreId: { type: string, nullable: true }
        plan: { type: string, nullable: true }
        planPrice: { type: number, description: Plan base price, USD / month. Not the bill — read `mrr`. }
        payBy: { type: string, description: "`shopify` or `stripe`" }
        phase: { $ref: "#/components/schemas/Phase" }
        churnedAt: { type: string, format: date-time }
        trialEndDate: { type: string, format: date-time }
        renewalDate: { type: string, format: date-time, description: Next anniversary; annual stores only. }
        parentShopName: { type: string, description: Set on a subsidiary; roll brands up on it. }
        suspectedParentShopName: { type: string }
        orderCount: { type: integer, description: Current billing period's running total; resets mid-month. }
        ordersLastMonth: { type: integer, description: Orders in the last 30 days. Half of the account-size bar. }
        orderLimit: { type: integer }
        overageFee: { type: number }
        mrr: { type: number, description: This month's bill (base + graduated overage). The other half of the account-size bar. }
        suggestedPlan: { type: string, description: Cheapest live tier at the store's volume. }
        suggestedMrr: { type: number, description: "What that tier bills. `suggestedMrr - mrr` negative = a saving." }
        accountOwner: { type: string, enum: [David, Cornelia, Cesar, Edward] }
        healthScore: { type: number }
        healthStage: { $ref: "#/components/schemas/HealthStage" }
        healthScoreCalculatedAt: { type: string, format: date-time }
        intercomLastLoginAt: { type: string, format: date-time }
        destinations:
          type: array
          items: { type: object, additionalProperties: true }
      additionalProperties: true
    CustomerDetail:
      allOf:
        - $ref: "#/components/schemas/CustomerListRow"
        - type: object
          properties:
            healthScoreChanges:
              type: array
              items: { type: string }
              description: The reason lines behind the score, e.g. "Champion left company (−4)".
            hadRecentPositiveCall: { type: boolean }
            accountOwnerSetAt: { type: string, format: date-time, nullable: true }
            subsidiaryShopNames: { type: array, items: { type: string } }
            churned: { type: boolean }
            churnSnapshot:
              type: object
              nullable: true
              description: Health, MRR, plan and orders frozen at churn; uninstalled stores only.
              additionalProperties: true
            shopifyAuditChecks: { type: object, additionalProperties: { type: boolean } }
          additionalProperties: true
    Contact:
      type: object
      properties:
        id: { type: string, description: Intercom contact id — the `contactId` for the write endpoints. }
        name: { type: string }
        email: { type: string }
        role: { type: string, enum: [user, lead], description: "`lead` has never logged in." }
        isChampion: { type: boolean }
        jobTitle: { type: string, nullable: true }
        persona: { type: string, nullable: true }
        linkedinUrl: { type: string, nullable: true }
        linkedinUrlSource: { type: string, enum: [intercom, discovered, manual], nullable: true }
        lastSeenAt: { type: string, format: date-time, nullable: true, description: "Last Messenger session. Absent for most contacts — the app creates the Intercom record without one." }
        lastRepliedAt: { type: string, format: date-time, nullable: true, description: "Last reply from them in an Intercom conversation. Absent when they never replied." }
        createdAt: { type: string, format: date-time, description: "When the Intercom record was created — not a login." }
        roleChange:
          type: object
          nullable: true
          properties:
            kind: { type: string, enum: [left_company, changed_role] }
            detectedAt: { type: string, format: date-time }
            previousTitle: { type: string, nullable: true }
            observedTitle: { type: string, nullable: true }
            observedCompany: { type: string, nullable: true }
      additionalProperties: true
    BrandPerson:
      type: object
      properties:
        id: { type: string }
        shopName: { type: string }
        name: { type: string }
        email: { type: string, nullable: true }
        persona: { type: string, nullable: true }
        title: { type: string, nullable: true }
        linkedinUrl: { type: string, nullable: true }
        departed: { type: boolean }
        departedAt: { type: string, format: date-time, nullable: true }
        companyName: { type: string, nullable: true }
      additionalProperties: true
    AgencyContact:
      type: object
      properties:
        intercomContactId: { type: string }
        email: { type: string }
        name: { type: string, nullable: true }
        lastSeenAt: { type: string, format: date-time, nullable: true }
    AgencyMatchKey:
      type: string
      enum: [website, leadEmail]
      description: Whether the contact email domain is the agency's website or one its leads mail from.
    CustomerAgency:
      type: object
      description: One agency linked to one store, with the contacts that proved the link and the agency's other stores.
      properties:
        agencyId: { type: string, description: "marketing-automations Company._id" }
        name: { type: string, nullable: true }
        website: { type: string, nullable: true }
        matchedBy: { $ref: "#/components/schemas/AgencyMatchKey" }
        emailDomains: { type: array, items: { type: string } }
        lastSeenAt: { type: string, format: date-time, nullable: true, description: Latest login among the contacts }
        contacts: { type: array, items: { $ref: "#/components/schemas/AgencyContact" } }
        otherStores:
          type: array
          items: { $ref: "#/components/schemas/AgencyStoreSummary" }
        syncedAt: { type: string, format: date-time, nullable: true }
    AgencyStoreSummary:
      type: object
      properties:
        shopName: { type: string }
        lastSeenAt: { type: string, format: date-time, nullable: true }
        contactCount: { type: integer }
    AgencySummary:
      type: object
      properties:
        agencyId: { type: string }
        name: { type: string, nullable: true }
        website: { type: string, nullable: true }
        matchedBy: { $ref: "#/components/schemas/AgencyMatchKey" }
        headquarters: { type: string, nullable: true }
        logoUrl: { type: string, nullable: true }
        industry: { type: string, nullable: true }
        employees: { type: integer, nullable: true }
        employeeBand: { type: string, nullable: true }
        storeCount: { type: integer }
        contactCount: { type: integer }
        lastSeenAt: { type: string, format: date-time, nullable: true }
        stores: { type: array, items: { $ref: "#/components/schemas/AgencyStoreSummary" } }
    AgencyDetail:
      type: object
      properties:
        agencyId: { type: string }
        name: { type: string, nullable: true }
        website: { type: string, nullable: true }
        profile:
          nullable: true
          description: The company's firmographics in the Company-profile shape, minus matchedBy / matchedShopName.
          type: object
          additionalProperties: true
        stores:
          type: array
          items:
            type: object
            properties:
              shopName: { type: string }
              matchedBy: { $ref: "#/components/schemas/AgencyMatchKey" }
              emailDomains: { type: array, items: { type: string } }
              lastSeenAt: { type: string, format: date-time, nullable: true }
              contacts: { type: array, items: { $ref: "#/components/schemas/AgencyContact" } }
              syncedAt: { type: string, format: date-time, nullable: true }
    Note:
      type: object
      properties:
        id: { type: string }
        body: { type: string }
        created_at: { type: string, format: date-time }
        author:
          type: object
          properties:
            id: { type: string }
            name: { type: string }
            email: { type: string }
        contact:
          type: object
          properties:
            id: { type: string }
            name: { type: string }
            email: { type: string }
      additionalProperties: true
    HealthHistory:
      type: object
      properties:
        history:
          type: array
          items:
            type: object
            properties:
              recordedAt: { type: string, format: date-time }
              score: { type: number }
              stage: { $ref: "#/components/schemas/HealthStage" }
              changes: { type: array, items: { type: string } }
              phase: { $ref: "#/components/schemas/Phase" }
              source: { type: string, enum: [cron, health-flags] }
        trialEndDate: { type: string, format: date-time, nullable: true }
        payingSince: { type: string, format: date-time, nullable: true }
        churnedAt: { type: string, format: date-time, nullable: true }
        currentScore: { type: number, nullable: true }
        currentStage: { $ref: "#/components/schemas/HealthStage" }
      additionalProperties: true
    Error:
      type: object
      properties:
        message: { type: string }
  responses:
    BadRequest:
      description: Malformed request — the message names the field.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Unauthorized:
      description: |
        No valid credential, a non-admin credential, or a `read`-scoped token on a
        PUT / POST / DELETE (read tokens are never authenticated for those methods).
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Forbidden:
      description: An agency or single-store session reached an internal admin endpoint.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    NotFound:
      description: No customer (or contact, or note) with that identifier.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
