openapi: 3.1.0
info:
  title: Minoa Public API
  version: 1.0.0
  summary: Public read API for Minoa services and location data.
  description: |
    Read-only API designed for agent consumption.
    - Public GET endpoints use CORS `*`.
    - No cookies are used for read operations.
    - Rate limit is enforced per IP.
    - Default rate-limit values: `60` requests per `60000 ms` window (`60s`).
    - Configurable with `API_RATE_LIMIT_MAX_REQUESTS` and `API_RATE_LIMIT_WINDOW_MS`.
    - Responses include ETag + Cache-Control.
servers:
  - url: https://www.minoa.es
    description: Production
  - url: http://localhost:3000
    description: Local development
tags:
  - name: Services
  - name: Location
  - name: FAQs
  - name: Booking
paths:
  /api/v1/services:
    get:
      tags: [Services]
      operationId: listServices
      summary: List services
      description: >
        Returns normalized service records. Public endpoint.
        Rate limit defaults: limit=60, window=60000ms per IP (configurable).
      responses:
        '200':
          description: Service list
          headers:
            Cache-Control:
              schema: { type: string }
              example: public, max-age=300, stale-while-revalidate=86400
            ETag:
              schema: { type: string }
            X-RateLimit-Limit:
              schema: { type: integer }
            X-RateLimit-Remaining:
              schema: { type: integer }
            X-RateLimit-Reset:
              schema: { type: integer }
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/Service'
              example:
                - slug: manicura
                  name: Manicuras
                  category: unas
                  description_short: Manicuras semipermanentes y SoftGel de larga duracion con disenos personalizados.
                  duration_minutes: 60
                  price_from_eur: 29
                  booking_url: https://www.fresha.com/es/a/minoa-vilassar-de-mar-viiassar-de-mar-carrer-de-narcis-monturiol-83-jd9fyyl1
                  service_url: https://www.minoa.es/servicios/manicura/
                  updated_at: '2026-02-25T00:00:00.000Z'
        '304':
          description: Not Modified (ETag match)
        '429':
          $ref: '#/components/responses/TooManyRequests'
    head:
      tags: [Services]
      operationId: headServices
      summary: Check services ETag without body transfer
      description: >
        Returns headers only (no response body), useful for agents that only need
        cache validation via ETag.
        Rate limit defaults: limit=60, window=60000ms per IP (configurable).
      responses:
        '200':
          description: Headers returned, no body
          headers:
            Cache-Control:
              schema: { type: string }
              example: public, max-age=300, stale-while-revalidate=86400
            ETag:
              schema: { type: string }
            X-RateLimit-Limit:
              schema: { type: integer, example: 60 }
            X-RateLimit-Remaining:
              schema: { type: integer }
            X-RateLimit-Reset:
              schema: { type: integer }
        '304':
          description: Not Modified (ETag match), no body
        '429':
          $ref: '#/components/responses/TooManyRequests'
  /api/v1/services/{slug}:
    get:
      tags: [Services]
      operationId: getServiceDetails
      summary: Get service by slug
      parameters:
        - in: path
          name: slug
          required: true
          schema:
            type: string
            pattern: '^[a-z0-9]+(?:-[a-z0-9]+)*$'
      responses:
        '200':
          description: Service detail
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Service'
        '304':
          description: Not Modified (ETag match)
        '400':
          $ref: '#/components/responses/BadRequest'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
  /api/v1/location:
    get:
      tags: [Location]
      operationId: getLocation
      summary: Get center location and contact details
      responses:
        '200':
          description: Location
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Location'
              example:
                name: Minoa Estetica
                address: Carrer de Narcis Monturiol, 83, local 4, 08340 Vilassar de Mar, Barcelona, ES
                geo:
                  lat: 41.5067237
                  lng: 2.3891225
                phone: '+34605982299'
                whatsapp: '+34682653498'
                opening_hours:
                  - days: [monday, tuesday, wednesday, thursday, friday]
                    opens: '10:00'
                    closes: '19:00'
                area_served: [Vilassar de Mar, Premia de Mar, Cabrera de Mar, Mataro, Cabrils, Maresme]
        '304':
          description: Not Modified (ETag match)
        '429':
          $ref: '#/components/responses/TooManyRequests'
  /api/v1/faqs:
    get:
      tags: [FAQs]
      operationId: listFaqs
      summary: List FAQs
      parameters:
        - in: query
          name: service
          required: false
          schema:
            type: string
            pattern: '^[a-z0-9]+(?:-[a-z0-9]+)*$'
          description: Optional service slug filter.
      responses:
        '200':
          description: FAQ list
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/Faq'
        '304':
          description: Not Modified (ETag match)
        '400':
          $ref: '#/components/responses/BadRequest'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
  /api/v1/book:
    post:
      tags: [Booking]
      operationId: createBooking
      summary: Create pre-booking intent and redirect to Fresha
      description: |
        Booking write endpoint with strict validation and idempotency.
        - Returns `404` when `BOOKING_WRITE_ENABLE=false`.
        - Requires allowed `Origin` and CSRF token (`x-csrf-token` + `csrf_token` cookie).
        - Applies dedicated write rate-limit (default 10 req / 60s per IP).
        - Creates an internal pre-booking intent (TTL) and returns `redirect_required`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/BookingRequest'
      responses:
        '200':
          description: Booking result
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BookingResponse'
              example:
                status: redirect_required
                redirect_method: GET
                booking_id: pre_6a7c28f6-c4c4-40b2-ab91-7f3df2826f4f
                provider: fresha
                starts_at: '2026-03-01T12:00:00+01:00'
                service_slug: manicura
                customer_ref: 8f7a1e9f22f8cf87a6ce
                redirect_url: https://www.fresha.com/es/a/minoa-vilassar-de-mar-viiassar-de-mar-carrer-de-narcis-monturiol-83-jd9fyyl1?utm_source=agent&utm_medium=webmcp&utm_campaign=prebooking&prebooking_id=pre_6a7c28f6-c4c4-40b2-ab91-7f3df2826f4f
                expires_at: '2026-03-01T12:30:00.000Z'
        '404':
          description: Not found when write feature is disabled
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          $ref: '#/components/responses/Forbidden'
        '409':
          description: Idempotency conflict
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'
        '429':
          $ref: '#/components/responses/TooManyRequests'
  /api/v1/book-agent:
    post:
      tags: [Booking]
      operationId: createBookingAgent
      summary: Create pre-booking intent for server-to-server agents
      description: |
        Server-to-server booking endpoint using Bearer API key auth.
        - Returns `404` when `BOOKING_AGENT_ENABLE=false`.
        - Requires `Authorization: Bearer <AGENT_API_KEY>`.
        - No CSRF or cookies required.
        - Applies dedicated agent rate-limit and burst-limit.
        - Creates an internal pre-booking intent and returns `redirect_required`.
      parameters:
        - in: header
          name: Authorization
          required: true
          schema:
            type: string
          example: Bearer sk_agent_xxx
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/BookingRequest'
      responses:
        '200':
          description: Booking result
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BookingResponse'
        '401':
          description: Unauthorized API key
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: Not found when feature is disabled
        '409':
          description: Idempotency conflict
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'
        '429':
          $ref: '#/components/responses/TooManyRequests'
  /api/v1/csrf:
    get:
      tags: [Booking]
      operationId: getCsrfToken
      summary: Issue CSRF token cookie for booking write
      description: |
        Generates and sets `csrf_token` cookie (SameSite=Strict, Secure) and returns the token.
        - Returns `404` when booking write feature is disabled.
      responses:
        '200':
          description: CSRF token issued
          content:
            application/json:
              schema:
                type: object
                required: [token, expires_in_seconds]
                properties:
                  token:
                    type: string
                  expires_in_seconds:
                    type: integer
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: Not found when write feature is disabled
components:
  responses:
    BadRequest:
      description: Bad request
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/Problem'
    NotFound:
      description: Not found
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/Problem'
    Forbidden:
      description: Forbidden
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/Problem'
    TooManyRequests:
      description: Rate limit exceeded
      headers:
        Retry-After:
          schema: { type: integer }
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/Problem'
  schemas:
    Service:
      type: object
      required:
        - slug
        - name
        - category
        - description_short
        - duration_minutes
        - price_from_eur
        - booking_url
        - service_url
        - updated_at
      properties:
        slug:
          type: string
        name:
          type: string
        category:
          type: string
        description_short:
          type: string
        duration_minutes:
          type: [integer, 'null']
        price_from_eur:
          type: [number, 'null']
        booking_url:
          type: string
          format: uri
        service_url:
          type: string
          format: uri
        updated_at:
          type: string
          format: date-time
    Location:
      type: object
      required: [name, address, geo, phone, whatsapp, opening_hours, area_served]
      properties:
        name:
          type: string
        address:
          type: string
        geo:
          type:
            - object
            - 'null'
          properties:
            lat:
              type: number
            lng:
              type: number
          required: [lat, lng]
        phone:
          type: string
        whatsapp:
          type: string
        opening_hours:
          type:
            - array
            - 'null'
          items:
            type: object
            required: [days, opens, closes]
            properties:
              days:
                type: array
                items:
                  type: string
              opens:
                type: string
              closes:
                type: string
        area_served:
          type: array
          items:
            type: string
    Faq:
      type: object
      required: [id, service_slug, question, answer, updated_at]
      properties:
        id:
          type: string
        service_slug:
          type: [string, 'null']
        question:
          type: string
        answer:
          type: string
        updated_at:
          type: string
          format: date-time
    BookingRequest:
      type: object
      additionalProperties: false
      required: [service_slug, customer, requested, source]
      properties:
        service_slug:
          type: string
          pattern: '^[a-z0-9]+(?:-[a-z0-9]+)*$'
        customer:
          type: object
          additionalProperties: false
          required: [name, email, phone]
          properties:
            name:
              type: string
              minLength: 2
              maxLength: 80
            email:
              type: string
              format: email
            phone:
              type: string
              maxLength: 20
        requested:
          type: object
          additionalProperties: false
          required: [start_at, timezone]
          properties:
            start_at:
              type: string
              format: date-time
            timezone:
              type: string
        notes:
          type: string
          maxLength: 300
        source:
          type: string
          enum: [agent, web, other]
        idempotency_key:
          type: string
    BookingResponse:
      type: object
      required: [status, redirect_method, booking_id, provider, starts_at, service_slug, customer_ref, redirect_url, expires_at]
      properties:
        status:
          type: string
          enum: [redirect_required]
        redirect_method:
          type: string
          enum: [GET]
        booking_id:
          type: string
        provider:
          type: string
          enum: [fresha]
        starts_at:
          type: string
          format: date-time
        service_slug:
          type: string
        customer_ref:
          type: string
        redirect_url:
          type: string
          format: uri
        expires_at:
          type: string
          format: date-time
    Problem:
      type: object
      required: [type, title, status]
      properties:
        type:
          type: string
        title:
          type: string
        status:
          type: integer
        detail:
          type: string
        instance:
          type: string
