openapi: 3.1.0
info:
  title: POSnavigator Marketing API
  version: "1.0.0"
  description: |-
    Marketing APIs for agents: daily metrics warehouse (Meta, Google Ads, Search
    Console, GA4), campaign register with UTM attribution, and the creative
    (asset) register. Agents POST raw daily rows after their nightly pull,
    GET time series for reports, register campaigns, and read or update creative
    status and campaign links. The admin "Látogatottság → Marketing" tab and
    /admin/assets show the same data.

    Requires `X-Api-Key: pn_...` whose owner is an admin user. Reads need
    `marketing:read`, writes need `marketing:write`. If you get
    `403 INSUFFICIENT_PERMISSIONS`, an admin must add those scopes at
    /admin/api-keys (elevated keys include them by default).

    # The happy path

    1. GET /openapi/marketing.yaml
    2. POST /api/v1/marketing/metrics with yesterday's rows (safe to repeat)
    3. GET /api/v1/marketing/metrics?from=2026-10-01&to=2026-10-15&groupBy=campaign
    4. POST /api/v1/marketing/campaigns when a new campaign starts (hypothesis,
       utm, platformCampaignIds), GET /api/v1/marketing/campaigns/{id} to see how
       it performs, PATCH it with status=ended and result when it is over.
    5. GET /api/v1/marketing/assets?campaignId=...&status=live to see which
       creatives run in a campaign; PATCH /api/v1/marketing/assets/{id} to
       approve, launch or retire one, or to assign it to campaigns.

    # Creatives (assets)

    Creatives live in the admin asset library (/admin/assets). Each has a kind
    (image, video, copy), a status (draft → approved → live → retired) and the
    campaigns it belongs to. The link is two-way: an asset's campaignIds and a
    campaign's assetIds are kept in sync whichever side is written. `variants`
    holds the ad-format JPEG crops made in the admin (meta_feed_square 1080×1080,
    meta_feed_portrait 1080×1350, story 1080×1920, link_landscape 1200×628,
    google_square 1200×1200); `url` is the full-size original.

    # Campaigns and attribution

    A campaign record ties three things together: the platform campaign ids
    (joined to metric rows by campaignId), the utm_campaign value of its ad
    links, and the hypothesis being tested. Internal conversions are linked by
    UTM: the site stores the first-touch utm_* of a visitor (only after analytics
    cookie consent) and copies it onto new wizard leads, callback requests and
    orders. GET /api/v1/marketing/campaigns/{id} returns daily spend/clicks of the
    platform ids and the internal leads/callbacks/orders whose utm.campaign equals
    the campaign's utm.campaign, so spend per lead uses real internal leads.
    utm values are matched case-insensitively (stored lowercase); utm.campaign is
    unique across campaigns. Read past campaigns' hypothesis + result before
    proposing a new one. `assetIds` on create or PATCH only accepts valid
    ObjectIds; ids that do not exist in the asset library are ignored (no error).
    List assets first, then link by id from either the campaign or the asset side.

    # Row identity and idempotency

    A row is identified by (source, date, campaignId, landing, query). Omitted
    dimensions are stored as "". Re-posting the same identity overwrites its
    metrics and campaignName; it never duplicates. Post one row per natural
    grain, e.g. Meta: one row per campaign per day; GSC: one row per query
    per day (optionally per landing). Do not post a day total next to its
    per-campaign rows for the same source, or grouped sums double count.

    # Metrics

    `metrics` is a flat object of finite numbers, 1-50 keys, names
    `^[a-zA-Z][a-zA-Z0-9_]{0,63}$`. Use these names where they apply:
    spend (HUF), impressions, clicks, conversions, landingViews, sessions,
    users, leads, ctr, position, cpc. Unknown names are stored as-is.
    Ratios and averages (ctr, position, cpc, cpm, cpa, frequency, roas) are
    returned on raw rows but dropped from groupBy sums; derive them from the
    summed counts (ctr = clicks / impressions).

    # Response envelope

    Success: `{ "success": true, "data": ... }`.
    Failure: `{ "success": false, "error": { "code", "message", "details"? } }`.
    Branch on error.code, never on error.message.

    # Error codes

    | code | status | what to do |
    | --- | --- | --- |
    | INVALID_QUERY | 400 | fix the query string using details |
    | INVALID_BODY | 400 | fix the rows using details; nothing was written |
    | INVALID_ID | 400 | the campaign / asset id is not an ObjectId |
    | NOT_FOUND | 404 | no campaign / asset with that id; list them first |
    | CAMPAIGN_EXISTS | 409 | utm.campaign is taken; PATCH the existing campaign or pick another value |
    | UNKNOWN_CAMPAIGN | 400 | an asset's campaignIds names a missing campaign (see details); list campaigns first |
    | MISSING_API_KEY | 401 | add X-Api-Key; do not retry blindly |
    | INVALID_API_KEY | 401 | stop; ask a human for a new key |
    | INSUFFICIENT_PERMISSIONS | 403 | stop; this key lacks marketing:read / marketing:write |
    | FORBIDDEN | 403 | stop; the key owner is not admin |
    | RATE_LIMIT_EXCEEDED | 429 | wait Retry-After seconds |
    | INTERNAL_ERROR | 500 | retry with backoff; POST is idempotent |

    # Rate limiting

    Every response after successful authentication carries X-RateLimit-Limit,
    X-RateLimit-Remaining and X-RateLimit-Reset. A 429 also carries Retry-After.
    Default window is 100 requests per rolling hour. Batch rows: up to 5000 per POST.

    # Canonical host

    Use https://posnavigator.eu. Legacy .hu hosts redirect; authenticated writes
    should call .eu directly.
servers:
  - url: https://posnavigator.eu
tags:
  - name: Discovery
  - name: Metrics
  - name: Campaigns
  - name: Assets
security:
  - ApiKeyAuth: []
paths:
  /openapi/marketing.yaml:
    get:
      operationId: getMarketingOpenApi
      tags: [Discovery]
      security: []
      summary: Public OpenAPI 3.1 description
      description: |-
        This document. Public: no API key, no rate limit. Fetch it first and treat
        it as the complete manual. Every schema, limit, example, error code and
        header an agent needs is here. Linked from GET /openapi/catalog.yaml.
      responses:
        '200':
          description: OpenAPI YAML
          content:
            application/yaml:
              schema:
                type: string
              example: 'openapi: 3.1.0'
  /api/v1/marketing/metrics:
    get:
      operationId: listMarketingMetrics
      tags: [Metrics]
      summary: Read daily marketing metrics, raw or grouped
      description: |-
        Without groupBy: raw rows sorted by date then source, each with its full
        metrics object. With groupBy=date|campaign|landing|query: one row per
        (source, key) with additive metrics summed over the range; ratios are
        dropped. Source is always part of the key because clicks from different
        platforms are not comparable. Range max 400 days. Requires marketing:read.
      parameters:
        - name: from
          in: query
          required: true
          schema: { type: string, format: date }
          description: First day, inclusive (YYYY-MM-DD, Europe/Budapest)
        - name: to
          in: query
          required: true
          schema: { type: string, format: date }
          description: Last day, inclusive
        - name: source
          in: query
          schema: { type: string, enum: [meta, google_ads, gsc, ga4] }
        - name: campaignId
          in: query
          schema: { type: string, maxLength: 500 }
        - name: groupBy
          in: query
          schema: { type: string, enum: [date, campaign, landing, query] }
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 10000, default: 1000 }
        - name: offset
          in: query
          schema: { type: integer, minimum: 0, default: 0 }
      responses:
        '200':
          description: Rows and pagination
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MetricsListResponse'
              example:
                success: true
                data:
                  rows:
                    - source: meta
                      key: '120210000000000'
                      campaignName: SumUp landing
                      metrics: { spend: 18795, clicks: 798, impressions: 16520, landingViews: 308 }
                  pagination: { total: 1, limit: 1000, offset: 0, hasMore: false }
        '400': { $ref: '#/components/responses/Error' }
        '401': { $ref: '#/components/responses/ErrorNoRateLimit' }
        '403': { $ref: '#/components/responses/Error' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Error' }
    post:
      operationId: upsertMarketingMetrics
      tags: [Metrics]
      summary: Bulk upsert daily marketing rows (idempotent)
      description: |-
        Validates the whole payload first; if any row is invalid nothing is
        written and INVALID_BODY lists the problems. Valid payloads upsert each
        row by (source, date, campaignId, landing, query). Safe to resend a whole
        day or backfill old days. Max 5000 rows per request. Requires marketing:write.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [rows]
              properties:
                rows:
                  type: array
                  minItems: 1
                  maxItems: 5000
                  items:
                    $ref: '#/components/schemas/MetricRow'
            example:
              rows:
                - source: google_ads
                  date: '2026-10-15'
                  campaignId: '23001234567'
                  campaignName: Search - POS terminál
                  landing: /hu/sumup
                  metrics: { spend: 5623.37, clicks: 9, impressions: 188, conversions: 0 }
                - source: gsc
                  date: '2026-10-15'
                  query: legolcsóbb pos terminál
                  metrics: { clicks: 0, impressions: 14, position: 18.2 }
      responses:
        '200':
          description: Write counts
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
          content:
            application/json:
              schema:
                type: object
                additionalProperties: false
                required: [success, data]
                properties:
                  success: { type: boolean, const: true }
                  data:
                    type: object
                    additionalProperties: false
                    properties:
                      upserted: { type: integer, description: New rows }
                      matched: { type: integer, description: Existing rows hit }
                      modified: { type: integer, description: Existing rows overwritten (updatedAt refreshes on every re-post) }
              example:
                success: true
                data: { upserted: 2, matched: 0, modified: 0 }
        '400': { $ref: '#/components/responses/Error' }
        '401': { $ref: '#/components/responses/ErrorNoRateLimit' }
        '403': { $ref: '#/components/responses/Error' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Error' }
  /api/v1/marketing/campaigns:
    get:
      operationId: listMarketingCampaigns
      tags: [Campaigns]
      summary: List campaigns
      description: |-
        Campaign register, newest start date first, optionally filtered by status.
        Each row has the hypothesis, utm, platformCampaignIds and (when closed) the
        result. Fetch one by id for its performance. Requires marketing:read.
      parameters:
        - name: status
          in: query
          schema: { type: string, enum: [draft, active, paused, ended] }
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
        - name: offset
          in: query
          schema: { type: integer, minimum: 0, default: 0 }
      responses:
        '200':
          description: Campaigns and pagination
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
          content:
            application/json:
              schema:
                type: object
                additionalProperties: false
                required: [success, data]
                properties:
                  success: { type: boolean, const: true }
                  data:
                    type: object
                    additionalProperties: false
                    required: [rows, pagination]
                    properties:
                      rows:
                        type: array
                        maxItems: 200
                        items: { $ref: '#/components/schemas/Campaign' }
                      pagination:
                        $ref: '#/components/schemas/OffsetPagination'
              example:
                success: true
                data:
                  rows:
                    - _id: 6710f2c49b1e8a0012345678
                      name: SumUp landing - kalkulátor videó
                      channel: meta
                      platformCampaignIds: ['120210000000000']
                      utm: { source: facebook, medium: paid_social, campaign: sumup_kalkulator_2026_10 }
                      landing: /hu/sumup
                      status: active
                      startDate: '2026-10-16'
                      endDate: null
                      budget: { amount: 5000, currency: HUF, period: daily }
                      hypothesis: A kalkulátort mutató videó olcsóbb wizard leadet hoz, mint a statikus kép.
                      targetKpi: { metric: cpl, target: 4000 }
                      result: null
                      assetIds: ['6710a1b29b1e8a0098765432']
                      createdBy: 66b0a1c2d3e4f50607080901
                      createdAt: '2026-10-16T08:00:00.000Z'
                      updatedAt: '2026-10-16T08:00:00.000Z'
                  pagination: { total: 1, limit: 50, offset: 0, hasMore: false }
        '400': { $ref: '#/components/responses/Error' }
        '401': { $ref: '#/components/responses/ErrorNoRateLimit' }
        '403': { $ref: '#/components/responses/Error' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Error' }
    post:
      operationId: createMarketingCampaign
      tags: [Campaigns]
      summary: Register a campaign
      description: |-
        Creates a campaign. utm.campaign must be unique (409 CAMPAIGN_EXISTS).
        Status defaults to draft. Setting status=ended without endDate closes it
        today (Europe/Budapest). Requires marketing:write.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CampaignInput'
            example:
              name: SumUp landing - kalkulátor videó
              channel: meta
              platformCampaignIds: ['120210000000000']
              utm: { source: facebook, medium: paid_social, campaign: sumup_kalkulator_2026_10 }
              landing: /hu/sumup
              status: active
              startDate: '2026-10-16'
              budget: { amount: 5000, currency: HUF, period: daily }
              hypothesis: A kalkulátort mutató videó olcsóbb wizard leadet hoz, mint a statikus kép.
              targetKpi: { metric: cpl, target: 4000 }
      responses:
        '201':
          description: Created campaign
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CampaignResponse'
        '400': { $ref: '#/components/responses/Error' }
        '401': { $ref: '#/components/responses/ErrorNoRateLimit' }
        '403': { $ref: '#/components/responses/Error' }
        '409': { $ref: '#/components/responses/Error' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Error' }
  /api/v1/marketing/campaigns/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string, pattern: '^[a-f0-9]{24}$' }
    get:
      operationId: getMarketingCampaign
      tags: [Campaigns]
      summary: Campaign with performance
      description: |-
        The campaign plus `performance` over startDate → min(endDate, today),
        capped to the last 400 days; null before the start date. Spend, clicks,
        impressions and platform conversions sum the meta/google_ads metric rows
        whose campaignId is in platformCampaignIds. leads (new wizard leads),
        callbacks (callback requests, test ones excluded) and orders count records
        whose first-touch utm.campaign equals the campaign's, by Europe/Budapest
        day. Requires marketing:read.
      responses:
        '200':
          description: Campaign and performance
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
          content:
            application/json:
              schema:
                type: object
                additionalProperties: false
                required: [success, data]
                properties:
                  success: { type: boolean, const: true }
                  data:
                    allOf:
                      - $ref: '#/components/schemas/Campaign'
                      - type: object
                        required: [performance]
                        properties:
                          performance:
                            oneOf:
                              - $ref: '#/components/schemas/CampaignPerformance'
                              - type: 'null'
              example:
                success: true
                data:
                  _id: 6710f2c49b1e8a0012345678
                  name: SumUp landing - kalkulátor videó
                  channel: meta
                  platformCampaignIds: ['120210000000000']
                  utm: { source: facebook, medium: paid_social, campaign: sumup_kalkulator_2026_10 }
                  landing: /hu/sumup
                  status: active
                  startDate: '2026-10-16'
                  endDate: null
                  budget: { amount: 5000, currency: HUF, period: daily }
                  hypothesis: A kalkulátort mutató videó olcsóbb wizard leadet hoz, mint a statikus kép.
                  targetKpi: { metric: cpl, target: 4000 }
                  result: null
                  assetIds: ['6710a1b29b1e8a0098765432']
                  createdBy: 66b0a1c2d3e4f50607080901
                  createdAt: '2026-10-16T08:00:00.000Z'
                  updatedAt: '2026-10-16T08:00:00.000Z'
                  performance:
                    from: '2026-10-16'
                    to: '2026-10-16'
                    totals:
                      spend: 18795
                      spendBySource: { meta: 18795 }
                      clicks: 798
                      impressions: 16520
                      ctr: 0.0483
                      platformConversions: 12
                      leads: 3
                      callbacks: 1
                      orders: 0
                      cpl: 6265
                      costPerOrder: null
                    series:
                      - date: '2026-10-16'
                        spend: 18795
                        clicks: 798
                        impressions: 16520
                        platformConversions: 12
                        leads: 3
                        callbacks: 1
                        orders: 0
        '400': { $ref: '#/components/responses/Error' }
        '401': { $ref: '#/components/responses/ErrorNoRateLimit' }
        '403': { $ref: '#/components/responses/Error' }
        '404': { $ref: '#/components/responses/Error' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Error' }
    patch:
      operationId: updateMarketingCampaign
      tags: [Campaigns]
      summary: Update or close a campaign
      description: |-
        Partial update: only the sent keys change; send null to clear an optional
        field. A sent utm replaces the whole utm object. Close a campaign with
        { "status": "ended", "result": "what happened, what we learned" }.
        Requires marketing:write.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CampaignPatch'
            example:
              status: ended
              result: CPL 3200 Ft (cél 4000), a videós kreatív 2x CTR-t hozott. Folytatni Google keresésen is.
      responses:
        '200':
          description: Updated campaign
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CampaignResponse'
        '400': { $ref: '#/components/responses/Error' }
        '401': { $ref: '#/components/responses/ErrorNoRateLimit' }
        '403': { $ref: '#/components/responses/Error' }
        '404': { $ref: '#/components/responses/Error' }
        '409': { $ref: '#/components/responses/Error' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Error' }
  /api/v1/marketing/assets:
    get:
      operationId: listMarketingAssets
      tags: [Assets]
      summary: List creatives
      description: |-
        Asset library entries, newest first. Filter by campaignId to see the
        creatives of a campaign, by status (e.g. live) to see what runs. Assets
        saved before the register count as kind=image, status=draft.
        Requires marketing:read.
      parameters:
        - name: campaignId
          in: query
          schema: { type: string, pattern: '^[a-f0-9]{24}$' }
        - name: status
          in: query
          schema: { type: string, enum: [draft, approved, live, retired] }
        - name: kind
          in: query
          schema: { type: string, enum: [image, video, copy] }
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
        - name: offset
          in: query
          schema: { type: integer, minimum: 0, default: 0 }
      responses:
        '200':
          description: Assets and pagination
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
          content:
            application/json:
              schema:
                type: object
                additionalProperties: false
                required: [success, data]
                properties:
                  success: { type: boolean, const: true }
                  data:
                    type: object
                    additionalProperties: false
                    required: [rows, pagination]
                    properties:
                      rows:
                        type: array
                        maxItems: 200
                        items: { $ref: '#/components/schemas/Asset' }
                      pagination:
                        $ref: '#/components/schemas/OffsetPagination'
              example:
                success: true
                data:
                  rows:
                    - _id: 6710a1b29b1e8a0098765432
                      name: sumup-kalkulator-hero.jpg
                      alt: SumUp kalkulátor videó borítókép
                      tags: [sumup, video]
                      kind: image
                      status: live
                      campaignIds: ['6710f2c49b1e8a0012345678']
                      url: https://storage.googleapis.com/example/assets/sumup-kalkulator-hero.jpg
                      thumbnail: https://storage.googleapis.com/example/assets/thumb_sumup-kalkulator-hero.jpg
                      metadata: { width: 1920, height: 1080, size: 245000, mimeType: image/jpeg }
                      variants:
                        - format: meta_feed_square
                          width: 1080
                          height: 1080
                          url: https://storage.googleapis.com/example/assets/variants/sumup_meta_feed_square.jpg
                      createdAt: '2026-10-15T10:00:00.000Z'
                      updatedAt: '2026-10-16T09:30:00.000Z'
                  pagination: { total: 1, limit: 50, offset: 0, hasMore: false }
        '400': { $ref: '#/components/responses/Error' }
        '401': { $ref: '#/components/responses/ErrorNoRateLimit' }
        '403': { $ref: '#/components/responses/Error' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Error' }
  /api/v1/marketing/assets/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string, pattern: '^[a-f0-9]{24}$' }
    patch:
      operationId: updateMarketingAsset
      tags: [Assets]
      summary: Set a creative's status, kind or campaigns
      description: |-
        Partial update: only the sent keys change. campaignIds replaces the whole
        list; every id must be an existing campaign (else 400 UNKNOWN_CAMPAIGN,
        nothing written). Added / removed campaigns get the asset in / out of
        their assetIds. Images, names and variants are managed in the admin.
        Requires marketing:write.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/AssetPatch'
            example:
              status: live
              campaignIds: ['6710f2c49b1e8a0012345678']
      responses:
        '200':
          description: Updated asset
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
          content:
            application/json:
              schema:
                type: object
                additionalProperties: false
                required: [success, data]
                properties:
                  success: { type: boolean, const: true }
                  data: { $ref: '#/components/schemas/Asset' }
        '400': { $ref: '#/components/responses/Error' }
        '401': { $ref: '#/components/responses/ErrorNoRateLimit' }
        '403': { $ref: '#/components/responses/Error' }
        '404': { $ref: '#/components/responses/Error' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Error' }
components:
  responses:
    Error:
      description: Error
      headers:
        X-RateLimit-Limit:
          $ref: '#/components/headers/X-RateLimit-Limit'
        X-RateLimit-Remaining:
          $ref: '#/components/headers/X-RateLimit-Remaining'
        X-RateLimit-Reset:
          $ref: '#/components/headers/X-RateLimit-Reset'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
    ErrorNoRateLimit:
      description: Error (emitted before rate-limit accounting)
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
    RateLimited:
      description: Rate limit exceeded
      headers:
        X-RateLimit-Limit:
          $ref: '#/components/headers/X-RateLimit-Limit'
        X-RateLimit-Remaining:
          $ref: '#/components/headers/X-RateLimit-Remaining'
        X-RateLimit-Reset:
          $ref: '#/components/headers/X-RateLimit-Reset'
        Retry-After:
          $ref: '#/components/headers/Retry-After'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
  securitySchemes:
    ApiKeyAuth:
      type: apiKey
      in: header
      name: X-Api-Key
      description: |-
        Send `X-Api-Key: pn_...` on every data operation. Create a key at
        /user/api-keys; it is shown once. Inactive or expired keys return 401.
  headers:
    X-RateLimit-Limit:
      description: Maximum requests allowed in the current window
      schema:
        type: integer
    X-RateLimit-Remaining:
      description: Requests remaining in the current window
      schema:
        type: integer
    X-RateLimit-Reset:
      description: Unix epoch seconds when the window resets
      schema:
        type: integer
    Retry-After:
      description: Seconds to wait before retrying a 429
      schema:
        type: integer
  schemas:
    ErrorEnvelope:
      type: object
      additionalProperties: false
      required: [success, error]
      properties:
        success:
          type: boolean
          const: false
        error:
          type: object
          additionalProperties: false
          required: [code, message]
          properties:
            code:
              type: string
              maxLength: 80
              description: Stable machine code. Branch on this, never on message.
            message:
              type: string
              maxLength: 2000
            details:
              type: array
              maxItems: 50
              items:
                type: string
                maxLength: 500
            retryAfter:
              type: integer
              description: Present on RATE_LIMIT_EXCEEDED
            field_errors:
              type: object
              additionalProperties:
                type: array
                maxItems: 20
                items:
                  type: string
                  maxLength: 500
    MetricRow:
      type: object
      additionalProperties: false
      required: [source, date, metrics]
      properties:
        source: { type: string, enum: [meta, google_ads, gsc, ga4] }
        date: { type: string, format: date, description: 'Report day, YYYY-MM-DD, Europe/Budapest' }
        campaignId: { type: string, maxLength: 500, description: Platform campaign id (Meta / Google) }
        campaignName: { type: string, maxLength: 500 }
        landing: { type: string, maxLength: 500, description: 'Landing path, e.g. /hu/sumup' }
        query: { type: string, maxLength: 500, description: Search term (GSC query or Ads search term) }
        metrics:
          type: object
          minProperties: 1
          maxProperties: 50
          propertyNames: { pattern: '^[a-zA-Z][a-zA-Z0-9_]{0,63}$' }
          additionalProperties: { type: number }
    MetricsListResponse:
      type: object
      additionalProperties: false
      required: [success, data]
      properties:
        success: { type: boolean, const: true }
        data:
          type: object
          additionalProperties: false
          required: [rows, pagination]
          properties:
            rows:
              type: array
              maxItems: 10000
              description: Raw MetricRow objects, or grouped { source, key, campaignName?, metrics }
              items: { type: object, additionalProperties: true }
            pagination:
              $ref: '#/components/schemas/OffsetPagination'
    OffsetPagination:
      type: object
      additionalProperties: false
      required: [total, limit, offset, hasMore]
      properties:
        total:
          type: integer
        limit:
          type: integer
        offset:
          type: integer
        hasMore:
          type: boolean
    CampaignUtm:
      type: object
      additionalProperties: false
      required: [source, campaign]
      properties:
        source: { type: string, minLength: 1, maxLength: 200, description: utm_source (stored lowercase) }
        medium: { type: string, maxLength: 200, default: '' }
        campaign: { type: string, minLength: 1, maxLength: 200, description: utm_campaign (stored lowercase, unique) }
        content: { type: [string, 'null'], maxLength: 200 }
    CampaignInput:
      type: object
      additionalProperties: false
      required: [name, channel, utm, startDate, hypothesis]
      properties:
        name: { type: string, minLength: 1, maxLength: 200 }
        channel: { type: string, enum: [meta, google_ads, email, organic, other] }
        platformCampaignIds:
          type: array
          maxItems: 50
          default: []
          description: Meta / Google campaign ids; metric rows join on campaignId
          items: { type: string, minLength: 1, maxLength: 500 }
        utm: { $ref: '#/components/schemas/CampaignUtm' }
        landing: { type: [string, 'null'], maxLength: 500, pattern: '^/', description: 'Target landing path, e.g. /hu/sumup' }
        status: { type: string, enum: [draft, active, paused, ended], default: draft }
        startDate: { type: string, format: date }
        endDate: { type: [string, 'null'], format: date, description: 'Must be >= startDate' }
        budget:
          type: [object, 'null']
          additionalProperties: false
          required: [amount, period]
          properties:
            amount: { type: number, minimum: 0 }
            currency: { type: string, const: HUF, default: HUF }
            period: { type: string, enum: [daily, total] }
        hypothesis: { type: string, minLength: 1, maxLength: 5000, description: What is tested and why }
        targetKpi:
          type: [object, 'null']
          additionalProperties: false
          required: [metric, target]
          properties:
            metric: { type: string, minLength: 1, maxLength: 100, description: 'e.g. cpl, ctr, orders' }
            target: { type: number }
        result: { type: [string, 'null'], maxLength: 10000, description: Filled when closing; what happened and what we learned }
        assetIds:
          type: array
          maxItems: 100
          default: []
          description: |-
            Creative ids from the admin asset library; mirrored onto each asset's
            campaignIds. Only ObjectId format is validated; unknown ids are
            ignored (list GET /api/v1/marketing/assets first).
          items: { type: string, pattern: '^[a-f0-9]{24}$' }
    CampaignPatch:
      type: object
      additionalProperties: false
      minProperties: 1
      description: Any subset of CampaignInput fields.
      properties:
        name: { type: string, minLength: 1, maxLength: 200 }
        channel: { type: string, enum: [meta, google_ads, email, organic, other] }
        platformCampaignIds: { type: array, maxItems: 50, items: { type: string, minLength: 1, maxLength: 500 } }
        utm: { $ref: '#/components/schemas/CampaignUtm' }
        landing: { type: [string, 'null'], maxLength: 500, pattern: '^/' }
        status: { type: string, enum: [draft, active, paused, ended] }
        startDate: { type: string, format: date }
        endDate: { type: [string, 'null'], format: date }
        budget: { $ref: '#/components/schemas/CampaignInput/properties/budget' }
        hypothesis: { type: string, minLength: 1, maxLength: 5000 }
        targetKpi: { $ref: '#/components/schemas/CampaignInput/properties/targetKpi' }
        result: { type: [string, 'null'], maxLength: 10000 }
        assetIds: { type: array, maxItems: 100, items: { type: string, pattern: '^[a-f0-9]{24}$' } }
    Campaign:
      type: object
      description: CampaignInput fields with defaults applied (unset optional fields are null) plus server fields.
      required: [_id, name, channel, platformCampaignIds, utm, status, startDate, hypothesis, assetIds, createdBy, createdAt, updatedAt]
      properties:
        _id: { type: string }
        name: { $ref: '#/components/schemas/CampaignInput/properties/name' }
        channel: { $ref: '#/components/schemas/CampaignInput/properties/channel' }
        platformCampaignIds: { $ref: '#/components/schemas/CampaignInput/properties/platformCampaignIds' }
        utm: { $ref: '#/components/schemas/CampaignUtm' }
        landing: { $ref: '#/components/schemas/CampaignInput/properties/landing' }
        status: { $ref: '#/components/schemas/CampaignInput/properties/status' }
        startDate: { $ref: '#/components/schemas/CampaignInput/properties/startDate' }
        endDate: { $ref: '#/components/schemas/CampaignInput/properties/endDate' }
        budget: { $ref: '#/components/schemas/CampaignInput/properties/budget' }
        hypothesis: { $ref: '#/components/schemas/CampaignInput/properties/hypothesis' }
        targetKpi: { $ref: '#/components/schemas/CampaignInput/properties/targetKpi' }
        result: { $ref: '#/components/schemas/CampaignInput/properties/result' }
        assetIds: { $ref: '#/components/schemas/CampaignInput/properties/assetIds' }
        createdBy: { type: string }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
    AssetPatch:
      type: object
      additionalProperties: false
      minProperties: 1
      properties:
        status: { type: string, enum: [draft, approved, live, retired] }
        kind: { type: string, enum: [image, video, copy] }
        campaignIds:
          type: array
          maxItems: 50
          description: Replaces the asset's campaigns; duplicates are dropped
          items: { type: string, pattern: '^[a-f0-9]{24}$' }
    AssetVariant:
      type: object
      additionalProperties: false
      required: [format, width, height, url]
      properties:
        format: { type: string, enum: [meta_feed_square, meta_feed_portrait, story, link_landscape, google_square] }
        width: { type: integer }
        height: { type: integer }
        url: { type: string, format: uri, description: JPEG in Firebase Storage }
    Asset:
      type: object
      additionalProperties: false
      required: [_id, name, alt, tags, kind, status, campaignIds, url, thumbnail, metadata, variants, createdAt, updatedAt]
      properties:
        _id: { type: string }
        name: { type: string }
        alt: { type: string }
        tags: { type: array, items: { type: string } }
        kind: { type: string, enum: [image, video, copy] }
        status: { type: string, enum: [draft, approved, live, retired] }
        campaignIds: { type: array, items: { type: string } }
        url: { type: [string, 'null'], description: Full-size original }
        thumbnail: { type: [string, 'null'] }
        metadata:
          type: object
          additionalProperties: false
          properties:
            width: { type: integer }
            height: { type: integer }
            size: { type: integer, description: bytes }
            mimeType: { type: string }
        variants:
          type: array
          description: Ad-format crops, in the order of the format list
          items: { $ref: '#/components/schemas/AssetVariant' }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
    CampaignResponse:
      type: object
      additionalProperties: false
      required: [success, data]
      properties:
        success: { type: boolean, const: true }
        data: { $ref: '#/components/schemas/Campaign' }
    CampaignPerformance:
      type: object
      additionalProperties: false
      required: [from, to, totals, series]
      properties:
        from: { type: string, format: date }
        to: { type: string, format: date }
        totals:
          type: object
          additionalProperties: false
          properties:
            spend: { type: number, description: HUF }
            spendBySource: { type: object, additionalProperties: { type: number } }
            clicks: { type: number }
            impressions: { type: number }
            ctr: { type: [number, 'null'] }
            platformConversions: { type: number }
            leads: { type: integer, description: New wizard leads with this utm.campaign }
            callbacks: { type: integer, description: Callback requests with this utm.campaign }
            orders: { type: integer, description: Orders with this utm.campaign }
            cpl: { type: [number, 'null'], description: spend / leads }
            costPerOrder: { type: [number, 'null'], description: spend / orders }
        series:
          type: array
          maxItems: 400
          items:
            type: object
            additionalProperties: false
            properties:
              date: { type: string, format: date }
              spend: { type: number }
              clicks: { type: number }
              impressions: { type: number }
              platformConversions: { type: number }
              leads: { type: integer }
              callbacks: { type: integer }
              orders: { type: integer }
