openapi: 3.1.0
info:
  title: Press by MoliseWow API (PressSys)
  version: 0.1.0
  description: |
    API-first engine that turns a property's free dates into direct bookings from foreign markets.
    Owner of this contract: Claude (tech lead). Change requests go to handoff/<ai>.md.

    Errors: every operation may answer 400/401/404/429/500 with the `Error` schema `{ "error": { "code", "message" } }`.
    Auth (phase 0): demo routes are public (health, markets, destinations, radar, opportunities/search, POST campaigns,
    GET campaigns/{cmp_id}, demo/leads, events, feed); everything else needs a key listed in PRESSSYS_API_KEYS.
    Publish with mode=draft records the target but leaves the status unchanged.
servers:
  - url: http://localhost:8787/v1
    description: Local dev
security:
  - bearerAuth: []

tags:
  - name: radar
  - name: properties
  - name: opportunities
  - name: watch
    description: Gap Watch - autonomous weak-date monitoring (inventory, demand, market, action)
  - name: campaigns
  - name: distribution
  - name: tracking
  - name: demo

paths:
  /health:
    get:
      tags: [demo]
      security: []
      summary: Liveness check
      responses:
        '200': { description: OK, content: { application/json: { schema: { type: object, properties: { ok: { type: boolean }, version: { type: string }, data: { type: array, items: { type: string }, description: Origin of each loaded dataset }, llm: { type: string, description: Configured LLM provider or none } } } } } }

  /markets:
    get:
      tags: [radar]
      summary: List supported source markets
      responses:
        '200':
          description: Markets
          content: { application/json: { schema: { type: object, properties: { data: { type: array, items: { $ref: '#/components/schemas/Market' } } } } } }

  /destinations:
    get:
      tags: [radar]
      summary: List destinations with reference data (slug, name, region, tags)
      responses:
        '200': { description: OK, content: { application/json: { schema: { type: object, properties: { data: { type: array, items: { type: object, properties: { slug: { type: string }, name: { type: string }, region: { type: string }, tags: { type: array, items: { type: string } }, kind: { type: string, enum: [curated, province] }, featured: { type: boolean } } } } } } } } }

  /radar:
    get:
      tags: [radar]
      summary: Market signals for a destination and period (no property needed)
      parameters:
        - { name: destination, in: query, required: true, schema: { type: string, example: rimini } }
        - { name: from, in: query, required: true, schema: { type: string, format: date } }
        - { name: to, in: query, required: true, schema: { type: string, format: date } }
      responses:
        '200':
          description: Ranked markets with signals
          content: { application/json: { schema: { type: object, properties: { data: { type: array, items: { $ref: '#/components/schemas/Opportunity' } } } } } }

  /properties:
    post:
      tags: [properties]
      summary: Create a property
      requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/PropertyInput' } } } }
      responses:
        '201': { description: Created, content: { application/json: { schema: { $ref: '#/components/schemas/Property' } } } }
    get:
      tags: [properties]
      summary: List properties of the organization
      responses:
        '200': { description: OK, content: { application/json: { schema: { type: object, properties: { data: { type: array, items: { $ref: '#/components/schemas/Property' } } } } } } }

  /properties/{propertyId}:
    parameters: [ { $ref: '#/components/parameters/PropertyId' } ]
    get:
      tags: [properties]
      summary: Get a property
      responses: { '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/Property' } } } } }
    patch:
      tags: [properties]
      summary: Update a property
      requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/PropertyInput' } } } }
      responses: { '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/Property' } } } } }

  /properties/{propertyId}/availability:
    parameters: [ { $ref: '#/components/parameters/PropertyId' } ]
    put:
      tags: [properties]
      summary: Replace availability windows (from PMS, CSV import, manual)
      requestBody:
        required: true
        content: { application/json: { schema: { type: object, required: [windows], properties: { windows: { type: array, items: { $ref: '#/components/schemas/AvailabilityWindow' } } } } } }
      responses: { '204': { description: Saved } }
    get:
      tags: [properties]
      summary: Get availability windows
      responses: { '200': { description: OK, content: { application/json: { schema: { type: object, properties: { windows: { type: array, items: { $ref: '#/components/schemas/AvailabilityWindow' } } } } } } } }

  /properties/{propertyId}/availability/ical:
    parameters: [ { $ref: '#/components/parameters/PropertyId' } ]
    post:
      tags: [properties]
      summary: Register an iCal URL (booked dates) to derive free windows
      description: |
        Downloads the calendar (http/https/webcal, public hosts only, max 2 MB, 15 s timeout), reads VEVENT
        DTSTART/DTEND (DATE, DATE-TIME UTC/TZID/floating, folded lines; cancelled/transparent events skipped,
        RRULE not expanded) and replaces the availability windows for the next 365 days:
        free_ratio = 1 − overlapping events / rooms_total, merged over consecutive days (same format as PUT /availability).
        The URL is stored for later syncs.
      requestBody: { required: true, content: { application/json: { schema: { type: object, required: [url, rooms_total], properties: { url: { type: string, format: uri }, rooms_total: { type: integer, minimum: 1 } } } } } }
      responses:
        '202':
          description: Accepted, first sync done
          content: { application/json: { schema: { type: object, properties: { property_id: { type: string }, events: { type: integer }, windows: { type: integer }, synced_at: { type: string, format: date-time } } } } }
        '400': { description: "Invalid body or URL not allowed (non-http, private or loopback host)" }
        '404': { description: Property not found }
        '502': { description: Calendar download or format error }

  /properties/{propertyId}/watch:
    parameters: [ { $ref: '#/components/parameters/PropertyId' } ]
    get:
      tags: [watch]
      summary: Gap Watch settings (defaults with enabled=false when never set)
      responses:
        '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/WatchSettings' } } } }
        '404': { $ref: '#/components/responses/NotFound' }
    put:
      tags: [watch]
      summary: Replace Gap Watch settings (missing fields take the defaults)
      description: |
        With enabled=true the server scheduler runs the watch cycle every WATCH_INTERVAL_MIN minutes (default 360, 0 = off).
        See POST /properties/{propertyId}/watch/run for what a cycle does.
      requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/WatchSettings' } } } }
      responses:
        '200': { description: Saved, content: { application/json: { schema: { $ref: '#/components/schemas/WatchSettings' } } } }
        '400': { $ref: '#/components/responses/BadRequest' }
        '404': { $ref: '#/components/responses/NotFound' }

  /properties/{propertyId}/watch/run:
    parameters: [ { $ref: '#/components/parameters/PropertyId' } ]
    post:
      tags: [watch]
      summary: Run the Gap Watch cycle now
      description: |
        1. Re-syncs the iCal feed if one is registered (on failure the stored windows are kept and `ical.error` is set).
        2. Finds the gaps (see GET /properties/{propertyId}/gaps).
        3. For each gap takes the top 3 markets and keeps those with score >= min_score.
        4. Skips a (market, gap) pair already proposed in the last 7 days (fingerprint), stores the others and,
           when notify_webhook is true, fires `opportunity.created` with the extra `gap` field.
        5. With auto_generate=true, creates a draft campaign for each new opportunity (same logic as POST /campaigns,
           channels from settings or the opportunity's recommended_channels; max WATCH_MAX_CAMPAIGNS per run, default 5)
           and fires `campaign.ready`.
      requestBody:
        required: false
        content: { application/json: { schema: { type: object, properties: { today: { type: string, format: date, description: "Simulation only: run as if today were this date" } } } } }
      responses:
        '200':
          description: Cycle result
          content:
            application/json:
              schema:
                type: object
                required: [property_id, gaps, opportunities, campaigns]
                properties:
                  property_id: { type: string }
                  ran_at: { type: string, format: date-time }
                  ical:
                    type: [object, 'null']
                    description: null when no iCal is registered
                    properties: { synced: { type: boolean }, synced_at: { type: string, format: date-time }, events: { type: integer }, error: { type: string } }
                  gaps: { type: array, items: { $ref: '#/components/schemas/Gap' } }
                  opportunities: { type: array, items: { $ref: '#/components/schemas/Opportunity' }, description: New opportunities only (each with `gap`) }
                  campaigns: { type: array, items: { $ref: '#/components/schemas/Campaign' } }
                  skipped_duplicates: { type: integer, description: "(market, gap) pairs already proposed in the last 7 days" }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { description: A cycle for this property is already running, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }

  /properties/{propertyId}/gaps:
    parameters: [ { $ref: '#/components/parameters/PropertyId' } ]
    get:
      tags: [watch]
      summary: Weak dates from the stored availability (no iCal sync, no side effects)
      description: |
        Contiguous nights in [today, today + horizon_days) with free_ratio >= min_free_ratio and at least min_nights,
        using the property's watch settings (or the defaults). Adjacent windows are merged; nights without data count as
        not free; runs longer than 14 nights are split into near-equal chunks.
      parameters:
        - { name: today, in: query, schema: { type: string, format: date }, description: "Simulation only (default: today in Europe/Rome)" }
      responses:
        '200':
          description: Gaps
          content: { application/json: { schema: { type: object, properties: { property_id: { type: string }, settings: { $ref: '#/components/schemas/WatchSettings' }, data: { type: array, items: { $ref: '#/components/schemas/Gap' } } } } } }
        '404': { $ref: '#/components/responses/NotFound' }

  /opportunities/search:
    post:
      tags: [opportunities]
      summary: Find an audience for these dates - top foreign-market opportunities
      description: Pass property_id OR an inline property (demo / stateless integrations).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [date_from, date_to]
              properties:
                property_id: { type: string }
                property: { $ref: '#/components/schemas/PropertyInput' }
                date_from: { type: string, format: date }
                date_to: { type: string, format: date }
                free_ratio: { type: number, minimum: 0, maximum: 1, description: Override when no availability is stored }
                price_per_night: { type: number, description: "Optional: average price per night (EUR) for the mission verdict" }
                ads_budget_max: { type: number, description: "Optional: maximum ads budget (EUR) for the mission verdict" }
                today: { type: string, format: date, description: "Simulation only: score as if today were this date (default: server date)" }
                limit: { type: integer, default: 3, maximum: 10 }
      responses:
        '200': { description: Ranked opportunities, content: { application/json: { schema: { type: object, properties: { property_id: { type: string }, inventory: { $ref: '#/components/schemas/Inventory' }, summary: { type: string }, mission: { $ref: '#/components/schemas/Mission' }, data: { type: array, items: { $ref: '#/components/schemas/Opportunity' } } } } } } }

  /campaigns:
    post:
      tags: [campaigns]
      summary: Generate a campaign for an opportunity
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [opportunity_id]
              properties:
                opportunity_id: { type: string }
                channels: { type: array, items: { $ref: '#/components/schemas/Channel' }, default: [landing, article, social, reel, email], description: "ads and whatsapp are never generated by default: request them explicitly" }
      responses:
        '201': { description: Created (status=draft), content: { application/json: { schema: { $ref: '#/components/schemas/Campaign' } } } }
    get:
      tags: [campaigns]
      summary: List campaigns
      parameters:
        - { name: property_id, in: query, schema: { type: string } }
        - { name: status, in: query, schema: { $ref: '#/components/schemas/CampaignStatus' } }
      responses: { '200': { description: OK, content: { application/json: { schema: { type: object, properties: { data: { type: array, items: { $ref: '#/components/schemas/Campaign' } } } } } } } }

  /campaigns/{campaignId}:
    parameters: [ { $ref: '#/components/parameters/CampaignId' } ]
    get:
      tags: [campaigns]
      summary: Get a campaign (json, html or markdown)
      parameters:
        - { name: format, in: query, schema: { type: string, enum: [json, html, markdown], default: json } }
      responses:
        '200':
          description: Campaign JSON, or the landing (fallback article) as sanitized HTML / Markdown when format is html / markdown
          content:
            application/json: { schema: { $ref: '#/components/schemas/Campaign' } }
            text/html: { schema: { type: string } }
            text/markdown: { schema: { type: string } }
        '404': { $ref: '#/components/responses/NotFound' }

  /campaigns/{campaignId}/approve:
    parameters: [ { $ref: '#/components/parameters/CampaignId' } ]
    post:
      tags: [campaigns]
      summary: Human approval; fires webhook campaign.approved
      responses: { '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/Campaign' } } } } }

  /campaigns/{campaignId}/publish:
    parameters: [ { $ref: '#/components/parameters/CampaignId' } ]
    post:
      tags: [distribution]
      summary: Publish to a target; connectors confirm with published_url
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [target]
              properties:
                target: { type: string, enum: [webhook, feed, widget, wordpress, custom], description: "custom = proprietary CMS that pulls via REST and confirms with published_url" }
                mode: { type: string, enum: [draft, scheduled, live], default: draft }
                publish_at: { type: string, format: date-time }
                published_url: { type: string, format: uri, description: Set by connector after publishing }
      responses: { '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/Campaign' } } } } }

  /feed/{propertyId}.json:
    parameters:
      - { $ref: '#/components/parameters/PropertyId' }
      - { name: lang, in: query, schema: { type: string, example: de } }
    get:
      tags: [distribution]
      summary: JSON Feed 1.1 of published content (public key allowed)
      responses: { '200': { description: JSON Feed, content: { application/feed+json: { schema: { type: object } } } } }

  /webhooks:
    post:
      tags: [distribution]
      summary: Register a webhook (signed HMAC-SHA256)
      description: |
        Each delivery is a POST with JSON body `{ "id": "dlv_...", "event": "...", "created_at": "...", "data": <full object> }` and headers:
        - `X-PressSys-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256(secret, t + "." + raw body)>`
        - `X-PressSys-Event`: event name
        - `X-PressSys-Delivery`: unique delivery id (same across retries, use it for idempotency)

        Receivers should recompute the HMAC on the raw body, compare in constant time and reject timestamps older than 5 minutes.
        Any 2xx is success; otherwise PressSys retries 3 times after 1 s, 5 s and 25 s (10 s timeout per attempt).
        `opportunity.created` sent by Gap Watch carries `data.gap` (`Gap`: date_from, date_to, room_nights_to_sell, ...).
        Only http/https URLs resolving to public IPs are accepted.
      requestBody:
        required: true
        content: { application/json: { schema: { type: object, required: [url, events], properties: { url: { type: string, format: uri }, events: { type: array, minItems: 1, items: { $ref: '#/components/schemas/WebhookEvent' } } } } } }
      responses:
        '201': { description: Created — secret returned once, content: { application/json: { schema: { $ref: '#/components/schemas/Webhook' } } } }
        '400': { description: "Invalid events or URL not allowed (non-http, private or loopback host)" }
    get:
      tags: [distribution]
      summary: List webhooks (without secret)
      responses:
        '200':
          description: OK
          content: { application/json: { schema: { type: object, properties: { data: { type: array, items: { $ref: '#/components/schemas/Webhook' } } } } } }

  /webhooks/{webhookId}:
    delete:
      tags: [distribution]
      summary: Delete a webhook
      parameters: [ { name: webhookId, in: path, required: true, schema: { type: string } } ]
      responses: { '204': { description: Deleted }, '404': { description: Not found } }

  /events:
    post:
      tags: [tracking]
      summary: Conversion events (browser with pk_ key, or server-to-server)
      requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/TrackingEvent' } } } }
      responses: { '202': { description: Accepted } }

  /reports/{propertyId}:
    parameters: [ { $ref: '#/components/parameters/PropertyId' } ]
    get:
      tags: [tracking]
      summary: Performance report for a property
      responses: { '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/Report' } } } } }

  /seo/check:
    post:
      tags: [demo]
      security: []
      summary: Free SEO check-up of an accommodation website (by URL or by property name)
      description: |
        Finds the site (URL, domain guessed from the name, or Brave Search if BRAVE_API_KEY is set), runs on-page checks
        and a local Lighthouse audit (same engine as PageSpeed Insights). Cached 24h per domain. Can take up to ~60 s.
      requestBody:
        required: true
        content: { application/json: { schema: { type: object, required: [query], properties: { query: { type: string, description: "Property name + city, or website address" } } } } }
      responses:
        '200':
          description: SEO report
          content:
            application/json:
              schema:
                type: object
                properties:
                  input: { type: string }
                  url: { type: string }
                  found_via: { type: string, enum: [url, guess, search] }
                  title: { type: string }
                  score: { type: integer, minimum: 0, maximum: 100 }
                  grade: { type: string, enum: [Ottimo, Buono, Da migliorare, Critico] }
                  categories: { type: array, items: { type: object, properties: { key: { type: string }, label: { type: string }, score: { type: integer }, max: { type: integer } } } }
                  checks: { type: array, items: { type: object, properties: { key: { type: string }, label: { type: string }, status: { type: string, enum: [ok, warn, fail] }, detail: { type: string }, fix: { type: string }, impact: { type: string, enum: [alta, media, bassa] }, category: { type: string } } } }
                  lighthouse: { type: [object, 'null'], properties: { performance: { type: integer }, seo: { type: integer }, accessibility: { type: integer }, best_practices: { type: integer }, lcp_ms: { type: integer }, cls: { type: number } } }
                  languages: { type: object, properties: { site: { type: array, items: { type: string } }, missing: { type: array, items: { type: object, properties: { code: { type: string }, language: { type: string }, name: { type: string } } } } } }
                  press_tip: { type: string }
                  analyzed_at: { type: string, format: date-time }
                  cached: { type: boolean }
        '400': { $ref: '#/components/responses/BadRequest' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { description: Site unreachable, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /demo/leads:
    post:
      tags: [demo]
      security: []
      summary: Lead capture from TTG demo (GDPR consent required)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, email, consent]
              properties:
                name: { type: string }
                email: { type: string, format: email }
                phone: { type: string }
                company: { type: string }
                segment: { type: string, enum: [hotel, agency, software, dmo, tour_operator, other] }
                website: { type: string }
                notes: { type: string }
                consent: { type: boolean, const: true }
                source: { type: string, default: ttg2026 }
      responses: { '201': { description: Saved } }

components:
  securitySchemes:
    bearerAuth: { type: http, scheme: bearer, description: "psk_... (secret) or pk_... (public: feed, widget, events)" }

  responses:
    BadRequest: { description: Invalid input, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
    Unauthorized: { description: Missing or invalid API key, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
    NotFound: { description: Not found, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
    TooManyRequests: { description: Rate limit exceeded, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }

  parameters:
    PropertyId: { name: propertyId, in: path, required: true, schema: { type: string } }
    CampaignId: { name: campaignId, in: path, required: true, schema: { type: string } }

  schemas:
    Mission:
      type: object
      description: "Missione date libere: commercial go/no-go with explicit assumptions"
      properties:
        verdict: { type: string, enum: [consigliata, da valutare, sconsigliata] }
        headline: { type: string }
        reasons: { type: array, items: { type: string } }
        potential_revenue_eur: { type: [number, 'null'] }
        suggested_budget_eur: { type: [object, 'null'], properties: { min: { type: number }, max: { type: number } } }
        break_even_bookings: { type: [integer, 'null'] }
        assumptions: { type: array, items: { type: string } }
    Inventory:
      type: object
      description: What the property has to sell in the period (null fields when rooms_total is unknown)
      properties:
        nights: { type: integer }
        free_ratio: { type: number }
        rooms_total: { type: [integer, 'null'] }
        rooms_free: { type: [integer, 'null'] }
        room_nights_to_sell: { type: [integer, 'null'] }
    Error:
      type: object
      required: [error]
      properties:
        error: { type: object, required: [code, message], properties: { code: { type: integer }, message: { type: string } } }
    Confidence: { type: string, enum: [high, medium, low, estimate] }
    Channel:
      type: string
      enum: [landing, article, social, reel, email, ads, whatsapp]
      description: "ads = paid-ads brief to upload to Meta/Google (never launched by PressSys); whatsapp = Meta-approvable template for past guests. Both opt-in, not in the default set."
    CampaignStatus: { type: string, enum: [draft, approved, scheduled, published, archived] }
    WebhookEvent: { type: string, enum: [opportunity.created, campaign.ready, campaign.approved, campaign.published, booking.attributed] }
    Webhook:
      type: object
      required: [id, url, events]
      properties:
        id: { type: string }
        url: { type: string, format: uri }
        events: { type: array, items: { $ref: '#/components/schemas/WebhookEvent' } }
        secret: { type: string, example: whsec_0f3a..., description: "whsec_ + 64 hex chars; returned only by POST /webhooks" }
        created_at: { type: string, format: date-time }

    Market:
      type: object
      required: [code, name, language, locale]
      properties:
        code: { type: string, example: DE, description: ISO 3166-1 alpha-2 }
        name: { type: string, example: Germania }
        language: { type: string, example: de }
        locale: { type: string, example: de-DE }
        booking_window_days: { type: object, properties: { min: { type: integer }, max: { type: integer } } }
        interests: { type: array, items: { type: string }, description: "Controlled vocabulary, see docs/VOCABULARY.md" }

    PropertyInput:
      type: object
      required: [name, destination]
      properties:
        name: { type: string }
        type: { type: string, enum: [hotel, bnb, agriturismo, apartment, camping, villa, other] }
        destination: { type: string, description: "Destination slug, e.g. rimini" }
        region: { type: string }
        website: { type: string, format: uri }
        booking_url: { type: string, format: uri }
        stars: { type: integer }
        rooms_total: { type: integer }
        amenities: { type: array, items: { type: string }, description: "Controlled vocabulary, see docs/VOCABULARY.md" }
        experiences: { type: array, items: { type: string } }
        highlights: { type: array, items: { type: string }, description: Verified selling points written by the property }
        languages_spoken: { type: array, items: { type: string } }
        photos: { type: array, items: { type: string, format: uri } }
        price_from: { type: number, description: "EUR per night, only if provided by property" }

    Property:
      allOf:
        - $ref: '#/components/schemas/PropertyInput'
        - type: object
          properties:
            id: { type: string }
            created_at: { type: string, format: date-time }

    AvailabilityWindow:
      type: object
      required: [date_from, date_to, free_ratio]
      properties:
        date_from: { type: string, format: date }
        date_to: { type: string, format: date }
        free_ratio: { type: number, minimum: 0, maximum: 1 }

    Factor:
      type: object
      required: [key, value]
      properties:
        key: { type: string, enum: [vacancy, timing, history, calendar, interest, access, fit] }
        value: { type: number, minimum: 0, maximum: 1 }
        weight: { type: number }
        explanation: { type: string }
        source: { type: string }
        as_of: { type: string, format: date }
        confidence: { $ref: '#/components/schemas/Confidence' }

    Opportunity:
      type: object
      required: [id, market, date_from, date_to, score, confidence, factors]
      properties:
        id: { type: string }
        property_id: { type: string }
        market: { $ref: '#/components/schemas/Market' }
        date_from: { type: string, format: date }
        date_to: { type: string, format: date }
        score: { type: integer, minimum: 0, maximum: 100 }
        confidence: { type: string, enum: [high, medium, low], description: "Aggregate reliability of the opportunity (single data points may also be 'estimate', see factors[].confidence)" }
        headline: { type: string, example: "Polonia: ponte di maggio (majówka) + volo diretto" }
        reason: { type: string, description: Travel motivation }
        matching_assets: { type: array, items: { type: string }, description: Property amenities/experiences supporting the offer }
        recommended_channels: { type: array, items: { $ref: '#/components/schemas/Channel' } }
        launch_window: { type: object, properties: { start: { type: string, format: date }, end: { type: string, format: date } }, description: When to publish/promote }
        room_nights_to_sell: { type: [integer, 'null'], description: "rooms_total × free_ratio × nights; null when rooms_total is unknown" }
        holidays: { type: array, items: { type: object, properties: { name: { type: string }, region: { type: string }, date_from: { type: string, format: date }, date_to: { type: string, format: date } } } }
        factors: { type: array, items: { $ref: '#/components/schemas/Factor' } }
        missing_data: { type: array, items: { type: string } }
        label: { type: string, enum: [Ottima occasione, Buona occasione, Da valutare], description: Plain-language verdict for non-technical users }
        reason_kind: { type: string, enum: [holiday, season], description: "holiday = real holiday/bridge in the period; season = only season and affinity" }
        timing: { type: object, properties: { phase: { type: string, enum: [early, open, late] }, label: { type: string } } }
        destination_matches: { type: array, items: { type: string }, description: "Destination features matching the market (not property services)" }
        summary: { type: string, description: "One-line plain-language summary (Italian)" }
        next_steps: { type: array, items: { type: string }, description: "Concrete actions for the property, in Italian" }
        gap: { $ref: '#/components/schemas/Gap', description: Present when the opportunity was found by Gap Watch (also in the opportunity.created webhook payload) }

    WatchSettings:
      type: object
      properties:
        enabled: { type: boolean, default: false, description: Run on the server schedule }
        horizon_days: { type: integer, minimum: 1, maximum: 365, default: 120 }
        min_free_ratio: { type: number, minimum: 0, maximum: 1, default: 0.35 }
        min_nights: { type: integer, minimum: 1, maximum: 14, default: 2 }
        min_score: { type: integer, minimum: 0, maximum: 100, default: 55 }
        channels: { type: array, items: { $ref: '#/components/schemas/Channel' }, default: [], description: "Channels for auto-generated campaigns; empty = the opportunity's recommended_channels" }
        auto_generate: { type: boolean, default: false, description: Create a draft campaign for each new opportunity }
        notify_webhook: { type: boolean, default: true, description: Fire opportunity.created / campaign.ready }
        last_run_at: { type: string, format: date-time, readOnly: true }

    Gap:
      type: object
      required: [date_from, date_to, nights, free_ratio, room_nights_to_sell]
      properties:
        date_from: { type: string, format: date, description: First free night }
        date_to: { type: string, format: date, description: "Last free night (inclusive, like AvailabilityWindow)" }
        nights: { type: integer, minimum: 1, maximum: 14 }
        free_ratio: { type: number, minimum: 0, maximum: 1, description: Average over the nights }
        room_nights_to_sell: { type: [integer, 'null'], description: "sum(free_ratio) × rooms_total; null when rooms_total is unknown" }

    CampaignContent:
      type: object
      description: Output of the Studio. Every channel optional depending on request.
      properties:
        language: { type: string }
        landing:
          type: object
          properties:
            title: { type: string }
            slug: { type: string }
            meta_title: { type: string, maxLength: 60 }
            meta_description: { type: string, maxLength: 160 }
            hero_subtitle: { type: string }
            blocks: { type: array, items: { $ref: '#/components/schemas/ContentBlock' } }
            cta: { type: object, properties: { label: { type: string }, url: { type: string } } }
            html: { type: string }
            markdown: { type: string }
            schema_org: { type: object, description: JSON-LD }
        article:
          type: object
          properties:
            title: { type: string }
            slug: { type: string }
            meta_title: { type: string }
            meta_description: { type: string }
            excerpt: { type: string }
            html: { type: string }
            markdown: { type: string }
        social:
          type: array
          items:
            type: object
            properties:
              network: { type: string, enum: [instagram, facebook, tiktok, linkedin] }
              caption: { type: string }
              hashtags: { type: array, items: { type: string }, maxItems: 5 }
              image_hint: { type: string }
        reel:
          type: object
          properties:
            hook: { type: string }
            scenes: { type: array, items: { type: object, properties: { seconds: { type: integer }, visual: { type: string }, text_overlay: { type: string } } } }
            music_mood: { type: string }
        email:
          type: object
          properties: { subject: { type: string }, preheader: { type: string }, html: { type: string }, text: { type: string } }
        ads:
          type: object
          description: Paid-ads brief ready to upload to Meta Ads Manager / Google Ads. PressSys never launches or pays for campaigns.
          properties:
            objective: { type: string, enum: [traffic, conversions] }
            targeting:
              type: object
              description: Computed by the server from the opportunity
              properties:
                countries: { type: array, items: { type: string }, description: ISO 3166-1 alpha-2 }
                regions: { type: array, items: { type: string }, description: "ISO 3166-2 (e.g. DE-BY) when the holiday is regional; empty = whole country" }
                languages: { type: array, items: { type: string } }
                interests: { type: array, items: { type: string }, description: From opportunity.matching_assets (controlled vocabulary) }
            schedule:
              type: object
              description: From launch_window; end never after the first night of the stay
              properties:
                start: { type: string, format: date }
                end: { type: string, format: date }
            budget_hint:
              type: object
              description: Prudent estimate, not a forecast; no result is promised
              properties:
                currency: { type: string, example: EUR }
                daily_min: { type: number }
                daily_max: { type: number }
                total_min: { type: number }
                total_max: { type: number }
                days: { type: integer }
                basis: { type: string, enum: [room_nights, flat] }
                room_nights_to_sell: { type: [integer, 'null'] }
                is_estimate: { type: boolean, const: true }
                note: { type: string, description: "Italian, for the property owner" }
            meta:
              type: array
              maxItems: 2
              items:
                type: object
                properties:
                  primary_text: { type: string, maxLength: 125 }
                  headline: { type: string, maxLength: 40 }
                  description: { type: string, maxLength: 30 }
            google_search:
              type: array
              maxItems: 1
              description: Responsive Search Ad
              items:
                type: object
                properties:
                  headlines: { type: array, maxItems: 5, items: { type: string, maxLength: 30 } }
                  descriptions: { type: array, maxItems: 2, items: { type: string, maxLength: 90 } }
            keywords: { type: array, items: { type: string }, description: In the market language }
            negative_keywords: { type: array, items: { type: string } }
            final_url: { type: string, description: "Direct booking URL with UTM (utm_medium=cpc) set by the server; empty when no direct URL exists" }
        whatsapp:
          type: object
          description: Marketing template for past guests of the market, in their language. Requires opt-in and Meta approval before sending.
          properties:
            template_name: { type: string, pattern: '^[a-z0-9_]+$' }
            body: { type: string, maxLength: 550, description: "At most one variable {{1}} (guest first name)" }
            cta_label: { type: string, maxLength: 25 }
            cta_url: { type: string, description: "Direct booking URL with UTM (utm_medium=whatsapp) set by the server; empty when no direct URL exists" }
            opt_in_required: { type: boolean, const: true }
            compliance_notes: { type: array, items: { type: string }, description: "Italian: consent, opt-out, Meta template approval" }
        quality:
          type: object
          properties:
            facts_used: { type: array, items: { type: string } }
            warnings: { type: array, items: { type: string } }

    ContentBlock:
      type: object
      required: [type]
      properties:
        type: { type: string, enum: [heading, paragraph, list, offer, faq, image, cta] }
        text: { type: string }
        items: { type: array, items: { type: string } }
        image_url: { type: string }

    Campaign:
      type: object
      required: [id, opportunity_id, status, content]
      properties:
        id: { type: string }
        opportunity_id: { type: string }
        property_id: { type: string }
        status: { $ref: '#/components/schemas/CampaignStatus' }
        market_code: { type: string }
        language: { type: string }
        utm: { type: object, properties: { source: { type: string }, medium: { type: string }, campaign: { type: string } } }
        content: { $ref: '#/components/schemas/CampaignContent' }
        published: { type: array, items: { type: object, properties: { target: { type: string }, url: { type: string }, at: { type: string, format: date-time } } } }
        generator: { type: object, properties: { provider: { type: string }, model: { type: string }, fallback: { type: boolean } } }
        created_at: { type: string, format: date-time }

    TrackingEvent:
      type: object
      required: [type, property_id]
      properties:
        type: { type: string, enum: [visit, lead, booking] }
        property_id: { type: string }
        campaign_id: { type: string }
        utm_campaign: { type: string }
        market_code: { type: string }
        value_eur: { type: number }
        nights: { type: integer }
        occurred_at: { type: string, format: date-time }

    Report:
      type: object
      properties:
        property_id: { type: string }
        period: { type: object, properties: { from: { type: string, format: date }, to: { type: string, format: date } } }
        campaigns: { type: integer }
        visits: { type: integer }
        leads: { type: integer }
        bookings: { type: integer }
        revenue_eur: { type: number }
        ota_commission_saved_eur: { type: number, description: revenue × ota_rate }
        ota_rate: { type: number, description: "Assumed OTA commission (query ota_rate or env OTA_RATE, default 0.17)" }
        by_market: { type: array, items: { type: object } }
