openapi: 3.1.0
info:
  title: RidingDesk Customer API
  version: 1.0.0
  description: |
    Customer-facing JSON API for RidingDesk campaigns. Lets your code (or an
    MCP client like Claude Desktop) read and write voters, donations,
    volunteers, canvassing interactions, lawn-sign requests, and campaign
    metadata for ONE campaign at a time.

    All requests authenticate with a Bearer token (`rd_live_*`) issued from
    the campaign's Settings → API Keys page. Each key is scoped — only the
    `resource:action` permissions you tick at creation time are honoured.

    Conventions:
      - All responses are JSON. Success: `{ data, pagination? }`. Error:
        `{ error, code }`.
      - List endpoints use cursor-based pagination. Pass the previous
        response's `pagination.nextCursor` back as `cursor` to fetch the
        next page. Default `limit` is 50, max 200.
      - Mutating endpoints (POST/PATCH/DELETE) accept an `Idempotency-Key`
        header. Replays within 24 hours return the original response —
        re-using the same key with a different body returns 422.
      - Per-tier rate limits apply: STARTER 60 req/min, PRO 300, CAMPAIGN_HQ
        1200. The `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and
        `Retry-After` headers are exposed on rate-limit responses.

    The MCP server `@ridingdesk/mcp-server` wraps these endpoints as 11
    tools — see https://ridingdesk.ca/docs/api/v1.
  contact:
    name: RidingDesk support
    url: https://ridingdesk.ca/support
servers:
  - url: https://ridingdesk.ca/api/v1
    description: Production
security:
  - bearerAuth: []

tags:
  - name: voters
    description: Voter records (create, read, update).
  - name: donations
    description: Donations (create, read) and CRA receipts (issue).
  - name: volunteers
    description: Volunteer roster (read-only in v1).
  - name: canvassing
    description: Door / phone / email interactions and walk lists.
  - name: lawn-signs
    description: Lawn-sign requests, installs, and pickups.
  - name: campaign
    description: Campaign metadata.

paths:
  /voters:
    get:
      tags: [voters]
      summary: List voters
      operationId: listVoters
      description: |
        Returns the campaign's voters, newest-first, paginated.
        Required scope: `voters:read`.
      parameters:
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limit'
        - in: query
          name: riding
          schema: { type: string }
          description: Filter to one riding name.
        - in: query
          name: support_level
          schema: { type: integer, minimum: 1, maximum: 5 }
          description: Filter to a single support level (1=strong supporter … 5=strong opponent).
      responses:
        '200':
          description: Voter list
          content:
            application/json:
              schema:
                type: object
                required: [data, pagination]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Voter' }
                  pagination: { $ref: '#/components/schemas/Pagination' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
    post:
      tags: [voters]
      summary: Create a voter
      operationId: createVoter
      description: |
        Creates a new voter record on the campaign. Required scope:
        `voters:write`.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreateVoter' }
      responses:
        '200':
          description: Created voter (envelope returns 200, not 201, by convention)
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: '#/components/schemas/Voter' }
        '400': { $ref: '#/components/responses/ValidationFailed' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/LimitExceeded' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422': { $ref: '#/components/responses/IdempotencyKeyReused' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /voters/{id}:
    parameters:
      - $ref: '#/components/parameters/PathId'
    get:
      tags: [voters]
      summary: Get a voter
      operationId: getVoter
      description: |
        Sensitive single-record read; logged to the campaign audit trail.
        Required scope: `voters:read`.
      responses:
        '200':
          description: Voter
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: '#/components/schemas/Voter' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
    patch:
      tags: [voters]
      summary: Update a voter
      operationId: updateVoter
      description: |
        Partial update — pass only the fields you want to change.
        Required scope: `voters:write`.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/UpdateVoter' }
      responses:
        '200':
          description: Updated voter
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: '#/components/schemas/Voter' }
        '400': { $ref: '#/components/responses/ValidationFailed' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/IdempotencyKeyReused' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /donations:
    get:
      tags: [donations]
      summary: List donations
      operationId: listDonations
      description: |
        Returns the campaign's donations, newest-first, paginated.
        Required scope: `donations:read`.
      parameters:
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limit'
        - in: query
          name: q
          schema: { type: string, maxLength: 200 }
          description: Free-text search on donor name, email, or address (case-insensitive).
        - in: query
          name: receipt_status
          schema:
            type: string
            enum: [PENDING, ISSUED, SPOILED]
          description: Filter to one receipt status.
        - in: query
          name: method
          schema: { $ref: '#/components/schemas/DonationMethod' }
          description: Filter to one donation method.
        - in: query
          name: date_from
          schema: { type: string, format: date }
          description: Inclusive lower bound on the donation `date` field (YYYY-MM-DD).
        - in: query
          name: date_to
          schema: { type: string, format: date }
          description: Inclusive upper bound on the donation `date` field (YYYY-MM-DD).
      responses:
        '200':
          description: Donation list
          content:
            application/json:
              schema:
                type: object
                required: [data, pagination]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Donation' }
                  pagination: { $ref: '#/components/schemas/Pagination' }
        '400': { $ref: '#/components/responses/ValidationFailed' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
    post:
      tags: [donations]
      summary: Record a donation
      operationId: createDonation
      description: |
        Records a new donation. The federal individual contribution cap
        (Canada Elections Act s. 367) is enforced: requests that would push
        a donor over the annual eligible total return 422 with code
        `validation_failed`.
        Required scope: `donations:write`.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreateDonation' }
      responses:
        '200':
          description: Created donation
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: '#/components/schemas/Donation' }
        '400': { $ref: '#/components/responses/ValidationFailed' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422':
          description: |
            Either `validation_failed` (contribution-limit gate) or
            `idempotency_key_reused`.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /donations/{id}:
    parameters:
      - $ref: '#/components/parameters/PathId'
    get:
      tags: [donations]
      summary: Get a donation
      operationId: getDonation
      description: |
        Sensitive single-record read; logged to the campaign audit trail.
        Required scope: `donations:read`.
      responses:
        '200':
          description: Donation
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: '#/components/schemas/Donation' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /donations/{id}/receipt:
    parameters:
      - $ref: '#/components/parameters/PathId'
    post:
      tags: [donations]
      summary: Issue a CRA receipt
      operationId: issueDonationReceipt
      description: |
        Issues an ITR 2000-compliant federal political contribution receipt.
        Six gates must pass (federal-only, monetary-only, agent on file,
        writ-window, EDA leader-authorization, donor-attestation). Failures
        return 422 with code `validation_failed` and an actionable message.
        Required scope: `donations:write`.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: '#/components/schemas/IssueReceipt' }
      responses:
        '200':
          description: Updated donation; `receiptStatus` is now `ISSUED`.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: '#/components/schemas/Donation' }
        '400': { $ref: '#/components/responses/ValidationFailed' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422':
          description: |
            CRA gate failure (with human-readable `error` message), or
            `idempotency_key_reused`.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /volunteers:
    get:
      tags: [volunteers]
      summary: List volunteers
      operationId: listVolunteers
      description: |
        Read-only roster of campaign volunteers, newest-first, paginated.
        Required scope: `volunteers:read`.
      parameters:
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limit'
      responses:
        '200':
          description: Volunteer list
          content:
            application/json:
              schema:
                type: object
                required: [data, pagination]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Volunteer' }
                  pagination: { $ref: '#/components/schemas/Pagination' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /canvassing/interactions:
    get:
      tags: [canvassing]
      summary: List canvass interactions
      operationId: listCanvassInteractions
      description: |
        Returns interactions with the campaign's voters, newest-first.
        Required scope: `canvassing:read`.
      parameters:
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limit'
        - in: query
          name: voter_id
          schema: { type: string }
          description: Restrict to interactions with a single voter.
      responses:
        '200':
          description: Interaction list
          content:
            application/json:
              schema:
                type: object
                required: [data, pagination]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/VoterInteraction' }
                  pagination: { $ref: '#/components/schemas/Pagination' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
    post:
      tags: [canvassing]
      summary: Record a canvass interaction
      operationId: recordCanvassInteraction
      description: |
        Logs a door-knock, phone call, email, or SMS conversation against a
        voter. Updates the voter's `lastContact` and (optionally) support
        level.
        Required scope: `canvassing:write`.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreateInteraction' }
      responses:
        '200':
          description: Created interaction
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: '#/components/schemas/VoterInteraction' }
        '400': { $ref: '#/components/responses/ValidationFailed' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/IdempotencyKeyReused' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /canvassing/walklist:
    get:
      tags: [canvassing]
      summary: Get a walk list
      operationId: getCanvassWalklist
      description: |
        Returns at most 200 voters, ordered for door-knocking. Filters
        narrow by riding, poll codes, support levels, and contact recency.
        Server-side pagination is intentionally a no-op — fetch the whole
        page each time.
        Required scope: `canvassing:read`.
      parameters:
        - $ref: '#/components/parameters/Limit'
        - in: query
          name: riding
          schema: { type: string }
          description: Restrict to one riding.
        - in: query
          name: poll
          schema: { type: string }
          description: Comma-separated poll codes.
        - in: query
          name: support_levels
          schema: { type: string }
          description: Comma-separated support levels (1-5) to include.
        - in: query
          name: exclude_contacted
          schema: { type: boolean }
          description: When `true`, skip voters with a non-null `lastContact`.
      responses:
        '200':
          description: Walk list
          content:
            application/json:
              schema:
                type: object
                required: [data, pagination]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Voter' }
                  pagination: { $ref: '#/components/schemas/Pagination' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /lawn-signs:
    get:
      tags: [lawn-signs]
      summary: List lawn-sign records
      operationId: listLawnSigns
      description: |
        Lists lawn-sign requests/installs/pickups, newest-first.
        Required scope: `lawn_signs:read`.
      parameters:
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limit'
        - in: query
          name: status
          schema:
            type: string
            enum: [REQUESTED, INSTALLED, COLLECTED]
      responses:
        '200':
          description: Lawn-sign list
          content:
            application/json:
              schema:
                type: object
                required: [data, pagination]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/LawnSign' }
                  pagination: { $ref: '#/components/schemas/Pagination' }
        '400': { $ref: '#/components/responses/ValidationFailed' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
    post:
      tags: [lawn-signs]
      summary: Create a lawn-sign request
      operationId: createLawnSign
      description: |
        Records a request for a sign at a specific address. Defaults
        status to `REQUESTED`.
        Required scope: `lawn_signs:write`.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreateLawnSign' }
      responses:
        '200':
          description: Created lawn-sign record
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: '#/components/schemas/LawnSign' }
        '400': { $ref: '#/components/responses/ValidationFailed' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422': { $ref: '#/components/responses/IdempotencyKeyReused' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /lawn-signs/{id}:
    parameters:
      - $ref: '#/components/parameters/PathId'
    get:
      tags: [lawn-signs]
      summary: Get a lawn-sign record
      operationId: getLawnSign
      responses:
        '200':
          description: Lawn-sign record
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: '#/components/schemas/LawnSign' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
    patch:
      tags: [lawn-signs]
      summary: Update a lawn-sign record
      operationId: updateLawnSign
      description: |
        Partial update. Setting `status` to `INSTALLED` or `COLLECTED` also
        sets the matching timestamp server-side. Required scope:
        `lawn_signs:write`.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/UpdateLawnSign' }
      responses:
        '200':
          description: Updated lawn-sign record
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: '#/components/schemas/LawnSign' }
        '400': { $ref: '#/components/responses/ValidationFailed' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/IdempotencyKeyReused' }
        '429': { $ref: '#/components/responses/RateLimited' }
    delete:
      tags: [lawn-signs]
      summary: Delete a lawn-sign record
      operationId: deleteLawnSign
      description: |
        Hard-delete the lawn-sign row. Required scope: `lawn_signs:write`.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      responses:
        '200':
          description: Deletion ack
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: object
                    required: [id, deleted]
                    properties:
                      id: { type: string }
                      deleted: { type: boolean, const: true }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/IdempotencyKeyReused' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /campaign:
    get:
      tags: [campaign]
      summary: Get the campaign metadata
      operationId: getCampaign
      description: |
        Returns metadata for the campaign that owns the API key. Required
        scope: `campaign:read`.
      responses:
        '200':
          description: Campaign metadata
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: '#/components/schemas/Campaign' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: 'rd_live_<32-char-base64url>'
      description: |
        Issued from RidingDesk → Settings → API Keys. Treat as a secret;
        the full key is shown once at creation time.

  parameters:
    PathId:
      in: path
      name: id
      required: true
      schema: { type: string }
      description: Resource ID.
    Cursor:
      in: query
      name: cursor
      schema: { type: string }
      description: |
        Opaque pagination cursor returned by the previous response's
        `pagination.nextCursor`. Treat as opaque — its shape may change.
    Limit:
      in: query
      name: limit
      schema:
        type: integer
        minimum: 1
        maximum: 200
        default: 50
      description: Max items per page (1–200, default 50).
    IdempotencyKey:
      in: header
      name: Idempotency-Key
      required: false
      schema: { type: string, maxLength: 200 }
      description: |
        Optional but recommended on POST/PATCH/DELETE. Replays within 24h
        return the original response. Re-using the same key with a different
        body returns 422 `idempotency_key_reused`.

  responses:
    Unauthorized:
      description: Missing or invalid Authorization header (expects `Bearer rd_live_…`).
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    Forbidden:
      description: |
        The key is valid but lacks the required scope, or the campaign's
        subscription tier does not permit API access.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    NotFound:
      description: Resource does not exist or does not belong to your campaign.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    ValidationFailed:
      description: Request body or query parameters failed validation.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    IdempotencyKeyReused:
      description: |
        The supplied `Idempotency-Key` was previously used with a DIFFERENT
        request body. Either replay with the original body or use a fresh key.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    LimitExceeded:
      description: |
        The write would push the campaign past its plan tier's per-tier
        capacity (today: voter count). The `error` message names the
        current/limit/tier; surface it directly. Upgrade to a higher tier
        to lift the cap.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    RateLimited:
      description: Per-tier per-key rate limit exhausted.
      headers:
        Retry-After:
          schema: { type: integer }
          description: Seconds until the bucket replenishes.
        X-RateLimit-Limit:
          schema: { type: integer }
          description: Per-minute cap for this campaign's tier.
        X-RateLimit-Remaining:
          schema: { type: integer }
          description: Requests remaining in the current window.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }

  schemas:
    Pagination:
      type: object
      required: [nextCursor, limit]
      properties:
        nextCursor:
          type: [string, 'null']
          description: |
            Pass back as `cursor` to fetch the next page. `null` when no
            further pages exist.
        limit:
          type: integer
          minimum: 1
          maximum: 200
          description: The page size that was applied.

    Error:
      type: object
      required: [error, code]
      properties:
        error:
          type: string
          description: Human-readable message; safe to display to end users.
        code:
          type: string
          enum:
            - unauthorized
            - forbidden
            - not_found
            - validation_failed
            - limit_exceeded
            - rate_limit_exceeded
            - idempotency_key_reused
            - internal
          description: |
            Stable machine-friendly code. Mirrors `V1ErrorCode` in
            `src/lib/v1-response.ts`.

    DonationMethod:
      type: string
      enum: [CREDIT_CARD, ETRANSFER, CHEQUE, CASH, OTHER, IN_KIND]
      description: |
        How the contribution was received. `IN_KIND` records non-monetary
        gifts; these are exempt from the federal monetary cap and cannot
        receive a CRA receipt under ITA 127(4.1).

    InteractionType:
      type: string
      enum: [DOOR, PHONE, EMAIL, SMS]

    InteractionDisposition:
      type: string
      enum:
        - STRONG_SUPPORT
        - LEANING_SUPPORT
        - UNDECIDED
        - LEANING_OPPOSE
        - STRONG_OPPOSE
        - NOT_HOME
        - MOVED
        - REFUSED
        - WRONG_NUMBER

    LawnSignStatus:
      type: string
      enum: [REQUESTED, INSTALLED, COLLECTED]

    ElectionLevel:
      type: string
      enum: [FEDERAL, PROVINCIAL, MUNICIPAL]

    SubscriptionTier:
      type: string
      enum: [COMMUNITY, STARTER, PRO, CAMPAIGN_HQ]

    Voter:
      type: object
      required:
        - id
        - campaignId
        - firstName
        - lastName
        - address
        - city
        - province
        - postalCode
        - riding
        - poll
        - createdAt
      properties:
        id: { type: string }
        campaignId: { type: string }
        firstName: { type: string }
        lastName: { type: string }
        email: { type: [string, 'null'], format: email }
        phone: { type: [string, 'null'] }
        address: { type: string }
        city: { type: string }
        province: { type: string }
        postalCode:
          type: string
          description: Canadian postal code (e.g. `K1A 0B1`).
        riding: { type: string }
        poll: { type: string }
        supportLevel:
          type: [integer, 'null']
          minimum: 1
          maximum: 5
        lastContact: { type: [string, 'null'], format: date-time }
        notes: { type: [string, 'null'] }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }

    CreateVoter:
      type: object
      required:
        - firstName
        - lastName
        - address
        - city
        - province
        - postalCode
        - riding
      properties:
        firstName: { type: string, minLength: 1, maxLength: 100 }
        lastName: { type: string, minLength: 1, maxLength: 100 }
        email: { type: [string, 'null'], format: email, maxLength: 255 }
        phone: { type: [string, 'null'], maxLength: 20 }
        address: { type: string, maxLength: 500 }
        city: { type: string, maxLength: 100 }
        province: { type: string, maxLength: 50 }
        postalCode:
          type: string
          pattern: '^[A-Za-z]\d[A-Za-z]\s?\d[A-Za-z]\d$'
        riding: { type: string, maxLength: 200 }
        poll: { type: string, maxLength: 20, default: '' }
        supportLevel:
          type: [integer, 'null']
          minimum: 1
          maximum: 5
        notes: { type: [string, 'null'], maxLength: 5000 }
        tags:
          type: array
          maxItems: 20
          items: { type: string, maxLength: 50 }

    UpdateVoter:
      description: Partial update — every field is optional.
      type: object
      properties:
        firstName: { type: string, minLength: 1, maxLength: 100 }
        lastName: { type: string, minLength: 1, maxLength: 100 }
        email: { type: [string, 'null'], format: email, maxLength: 255 }
        phone: { type: [string, 'null'], maxLength: 20 }
        address: { type: string, maxLength: 500 }
        city: { type: string, maxLength: 100 }
        province: { type: string, maxLength: 50 }
        postalCode:
          type: string
          pattern: '^[A-Za-z]\d[A-Za-z]\s?\d[A-Za-z]\d$'
        riding: { type: string, maxLength: 200 }
        poll: { type: string, maxLength: 20 }
        supportLevel:
          type: [integer, 'null']
          minimum: 1
          maximum: 5
        notes: { type: [string, 'null'], maxLength: 5000 }
        tags:
          type: array
          maxItems: 20
          items: { type: string, maxLength: 50 }

    Donation:
      type: object
      required:
        - id
        - campaignId
        - donorName
        - donorAddress
        - donorCity
        - donorProvince
        - donorPostalCode
        - amount
        - date
        - method
        - receiptStatus
        - eligibleAmount
        - createdAt
      properties:
        id: { type: string }
        campaignId: { type: string }
        donorName: { type: string }
        donorEmail: { type: [string, 'null'], format: email }
        donorPhone: { type: [string, 'null'] }
        donorAddress: { type: string }
        donorCity: { type: string }
        donorProvince: { type: string }
        donorPostalCode: { type: string }
        amount:
          type: string
          description: Decimal string (avoids float drift). Dollars, not cents.
        date: { type: string, format: date-time }
        method: { $ref: '#/components/schemas/DonationMethod' }
        advantageDescription: { type: [string, 'null'] }
        advantageAmount:
          type: [string, 'null']
          description: Decimal string. Fair-market value of any advantage.
        eligibleAmount:
          type: string
          description: |
            Decimal string. `amount - advantageAmount`, clamped at 0. This
            is what's used for the donor's tax credit.
        receiptStatus:
          type: string
          enum: [PENDING, ISSUED, SPOILED]
        receiptNumber: { type: [string, 'null'] }
        receiptIssuedDate: { type: [string, 'null'], format: date-time }
        receiptHtml:
          type: [string, 'null']
          description: Snapshotted receipt HTML (immutable once issued).
        replacesReceiptNumber: { type: [string, 'null'] }
        donorIsCanadianAttested: { type: boolean }
        notes: { type: [string, 'null'] }
        createdAt: { type: string, format: date-time }

    CreateDonation:
      type: object
      required:
        - donorName
        - donorAddress
        - donorCity
        - donorProvince
        - donorPostalCode
        - amount
        - date
        - method
      properties:
        donorName: { type: string, minLength: 2, maxLength: 200 }
        donorEmail: { type: [string, 'null'], format: email, maxLength: 255 }
        donorPhone: { type: [string, 'null'], maxLength: 20 }
        donorAddress: { type: string, minLength: 1, maxLength: 500 }
        donorCity: { type: string, minLength: 1, maxLength: 100 }
        donorProvince: { type: string, minLength: 1, maxLength: 50 }
        donorPostalCode:
          type: string
          pattern: '^[A-Za-z]\d[A-Za-z]\s?\d[A-Za-z]\d$'
        amount:
          type: number
          exclusiveMinimum: 0
          maximum: 100000
          description: |
            Dollars (NOT cents). Hard 100k ceiling against typos — the real
            cap is per-year and enforced by the contribution-limit gate.
        date:
          type: string
          pattern: '^\d{4}-\d{2}-\d{2}$'
          description: Date received in `YYYY-MM-DD` format.
        method: { $ref: '#/components/schemas/DonationMethod' }
        advantageDescription: { type: [string, 'null'], maxLength: 500 }
        advantageAmount:
          type: [number, 'null']
          minimum: 0
          description: Fair-market value of any donor advantage in dollars.
        donorIsCanadianAttested:
          type: boolean
          default: false
          description: |
            Required for a CRA receipt later (Canada Elections Act s. 363).
        notes: { type: [string, 'null'], maxLength: 2000 }

    IssueReceipt:
      description: |
        Body is optional. Pass `receiptNumber` to override the generated
        serial; pass `replacesReceiptNumber` when re-issuing after a SPOIL
        (must match the spoiled receipt's serial).
      type: object
      properties:
        receiptNumber: { type: string, maxLength: 50 }
        replacesReceiptNumber: { type: string, maxLength: 50 }

    Volunteer:
      type: object
      required: [id, campaignId, name, email, role, status, createdAt]
      properties:
        id: { type: string }
        campaignId: { type: string }
        userId: { type: [string, 'null'] }
        name: { type: string }
        email: { type: string, format: email }
        phone: { type: [string, 'null'] }
        role: { type: string }
        status:
          type: string
          enum: [PENDING, ACTIVE, INACTIVE]
        skills:
          type: array
          items: { type: string }
        hoursLogged: { type: number }
        createdAt: { type: string, format: date-time }

    VoterInteraction:
      type: object
      required: [id, voterId, userId, type, createdAt]
      properties:
        id: { type: string }
        voterId: { type: string }
        userId: { type: string }
        type: { $ref: '#/components/schemas/InteractionType' }
        disposition:
          oneOf:
            - { $ref: '#/components/schemas/InteractionDisposition' }
            - { type: 'null' }
        notes: { type: [string, 'null'] }
        latitude: { type: [number, 'null'] }
        longitude: { type: [number, 'null'] }
        createdAt: { type: string, format: date-time }

    CreateInteraction:
      type: object
      required: [voterId, type]
      properties:
        voterId: { type: string, minLength: 1, maxLength: 100 }
        type: { $ref: '#/components/schemas/InteractionType' }
        disposition:
          oneOf:
            - { $ref: '#/components/schemas/InteractionDisposition' }
            - { type: 'null' }
        supportLevel:
          type: [integer, 'null']
          minimum: 1
          maximum: 5
          description: |
            If revealed during the conversation, the voter's support level
            will be updated.
        notes: { type: [string, 'null'], maxLength: 5000 }

    LawnSign:
      type: object
      required:
        - id
        - campaignId
        - address
        - status
        - requestedAt
      properties:
        id: { type: string }
        campaignId: { type: string }
        voterId: { type: [string, 'null'] }
        address: { type: string }
        latitude: { type: [number, 'null'] }
        longitude: { type: [number, 'null'] }
        status: { $ref: '#/components/schemas/LawnSignStatus' }
        requestedAt: { type: string, format: date-time }
        installedAt: { type: [string, 'null'], format: date-time }
        collectedAt: { type: [string, 'null'], format: date-time }
        assignedVolunteerId: { type: [string, 'null'] }

    CreateLawnSign:
      type: object
      required: [address]
      properties:
        address: { type: string, minLength: 1, maxLength: 500 }
        voterId: { type: [string, 'null'], maxLength: 100 }
        latitude: { type: [number, 'null'], minimum: -90, maximum: 90 }
        longitude: { type: [number, 'null'], minimum: -180, maximum: 180 }
        status:
          $ref: '#/components/schemas/LawnSignStatus'
        assignedVolunteerId: { type: [string, 'null'], maxLength: 100 }

    UpdateLawnSign:
      description: Partial update — every field is optional.
      type: object
      properties:
        address: { type: string, minLength: 1, maxLength: 500 }
        voterId: { type: [string, 'null'], maxLength: 100 }
        latitude: { type: [number, 'null'], minimum: -90, maximum: 90 }
        longitude: { type: [number, 'null'], minimum: -180, maximum: 180 }
        status: { $ref: '#/components/schemas/LawnSignStatus' }
        assignedVolunteerId: { type: [string, 'null'], maxLength: 100 }

    Campaign:
      type: object
      required:
        - id
        - name
        - candidateName
        - party
        - riding
        - electionLevel
        - electionDate
        - subscriptionTier
        - createdAt
      properties:
        id: { type: string }
        name: { type: string }
        candidateName: { type: string }
        party: { type: string }
        riding: { type: string }
        ednNumber: { type: [string, 'null'] }
        electionLevel: { $ref: '#/components/schemas/ElectionLevel' }
        electionDate: { type: string, format: date-time }
        spendingLimit: { type: string, description: Decimal string. }
        contributionLimit: { type: string, description: Decimal string. }
        subscriptionTier: { $ref: '#/components/schemas/SubscriptionTier' }
        seatCount: { type: integer }
        isDemo:
          type: boolean
          description: |
            `true` for sandbox/demo campaigns. Demo data resets daily.
        officialAgentName: { type: [string, 'null'] }
        officialAgentAddress: { type: [string, 'null'] }
        nominationConfirmedDate: { type: [string, 'null'], format: date-time }
        leaderAuthorizationOnFile: { type: boolean }
        createdAt: { type: string, format: date-time }
