openapi: 3.2.0
info:
  title: Reservify Booking API
  version: 1.0.0-beta.1
  summary: Disponibilidad y reservas públicas de un restaurante.
  description: >-
    Primera vertical funcional de Reservify Booking API v1. Cada credencial
    está limitada a un restaurante y un entorno.
# El bloque servers se reescribe al servirlo según BASE_URL del entorno.
servers:
  - url: "https://reservifyrest.wemvy.com/api/v1"
    description: Producción
tags:
  - name: Booking profile
  - name: Availability
  - name: Reservations
  - name: Waitlist
security:
  - bearerAuth: []
paths:
  /venues/{venue_id}/booking-profile:
    get:
      tags: [Booking profile]
      operationId: getBookingProfile
      summary: Obtiene el perfil, política y configuración publicada.
      parameters:
        - $ref: '#/components/parameters/VenueId'
      responses:
        '200':
          description: Perfil publicable del restaurante.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BookingProfileResponse'
        '401': { $ref: '#/components/responses/AuthenticationRequired' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/VenueNotFound' }
        '500': { $ref: '#/components/responses/InternalError' }
  /venues/{venue_id}/availability:
    get:
      tags: [Availability]
      operationId: getAvailability
      summary: Busca franjas disponibles para una fecha y grupo.
      parameters:
        - $ref: '#/components/parameters/VenueId'
        - name: date
          in: query
          required: true
          schema: { type: string, format: date }
        - name: party_size
          in: query
          required: true
          schema: { type: integer, minimum: 1, maximum: 255 }
        - name: shift_id
          in: query
          required: false
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: Resultado de disponibilidad. Una lista vacía es válida.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AvailabilityResponse'
        '401': { $ref: '#/components/responses/AuthenticationRequired' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/VenueNotFound' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '500': { $ref: '#/components/responses/InternalError' }
  /reservations:
    post:
      tags: [Reservations]
      operationId: createReservation
      summary: Crea una reserva revalidando la franja firmada.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreateReservationRequest' }
      responses:
        '201':
          description: Reserva creada o repetición idempotente.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ReservationResponse' }
        '401': { $ref: '#/components/responses/AuthenticationRequired' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/VenueNotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '500': { $ref: '#/components/responses/InternalError' }
  /reservations/{reservation_id}:
    parameters:
      - $ref: '#/components/parameters/ReservationId'
    get:
      tags: [Reservations]
      operationId: getReservation
      summary: Consulta una reserva del restaurante de la credencial.
      responses:
        '200':
          description: Estado vigente de la reserva.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ReservationDetailResponse' }
        '401': { $ref: '#/components/responses/AuthenticationRequired' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/ResourceNotFound' }
        '500': { $ref: '#/components/responses/InternalError' }
    patch:
      tags: [Reservations]
      operationId: modifyReservation
      summary: Reprograma una reserva revalidando otra franja firmada.
      description: >-
        Conserva comensales, duración y datos del cliente. La versión debe
        coincidir con la devuelta por la última consulta.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ModifyReservationRequest' }
      responses:
        '200':
          description: Reserva reprogramada o repetición idempotente.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ReservationDetailResponse' }
        '401': { $ref: '#/components/responses/AuthenticationRequired' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/ResourceNotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '500': { $ref: '#/components/responses/InternalError' }
    delete:
      tags: [Reservations]
      operationId: cancelReservation
      summary: Cancela una reserva dentro de su ventana permitida.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      responses:
        '200':
          description: Reserva cancelada o repetición idempotente.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ReservationDetailResponse' }
        '401': { $ref: '#/components/responses/AuthenticationRequired' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/ResourceNotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '500': { $ref: '#/components/responses/InternalError' }
  /waitlist-entries:
    post:
      tags: [Waitlist]
      operationId: createWaitlistEntry
      summary: Añade un grupo a la lista de espera del restaurante.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreateWaitlistEntryRequest' }
      responses:
        '201':
          description: Entrada creada o repetición idempotente.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WaitlistEntryResponse' }
        '401': { $ref: '#/components/responses/AuthenticationRequired' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/VenueNotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '500': { $ref: '#/components/responses/InternalError' }
  /waitlist-entries/{entry_id}:
    parameters:
      - $ref: '#/components/parameters/WaitlistEntryId'
    get:
      tags: [Waitlist]
      operationId: getWaitlistEntry
      summary: Consulta una entrada de lista de espera.
      responses:
        '200':
          description: Estado vigente de la entrada.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WaitlistEntryResponse' }
        '401': { $ref: '#/components/responses/AuthenticationRequired' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/ResourceNotFound' }
        '500': { $ref: '#/components/responses/InternalError' }
    delete:
      tags: [Waitlist]
      operationId: cancelWaitlistEntry
      summary: Retira un grupo activo de la lista de espera.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      responses:
        '200':
          description: Entrada cancelada o repetición idempotente.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WaitlistEntryResponse' }
        '401': { $ref: '#/components/responses/AuthenticationRequired' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/ResourceNotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '500': { $ref: '#/components/responses/InternalError' }
webhooks:
  bookingEvents:
    post:
      summary: Notifica un cambio de reserva o lista de espera.
      description: >-
        Reservify firma el cuerpo JSON exacto con HMAC-SHA256. El endpoint
        debe responder con cualquier estado 2xx. Las entregas fallidas se
        reintentan con espera creciente hasta ocho veces.
      parameters:
        - name: Reservify-Event
          in: header
          required: true
          schema: { $ref: '#/components/schemas/WebhookEventType' }
        - name: Reservify-Event-Id
          in: header
          required: true
          schema: { type: string, format: uuid }
        - name: Reservify-Signature
          in: header
          required: true
          schema:
            type: string
            example: t=1785765600,v1=31f2e7d0a1...
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/WebhookEnvelope' }
      responses:
        '200': { description: Evento aceptado. }
        '204': { description: Evento aceptado sin cuerpo. }
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: rv_sk_test_* or rv_sk_live_*
  parameters:
    VenueId:
      name: venue_id
      in: path
      required: true
      schema: { type: string, format: uuid }
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: true
      schema:
        type: string
        minLength: 8
        maxLength: 128
        pattern: '^[A-Za-z0-9._:-]+$'
    ReservationId:
      name: reservation_id
      in: path
      required: true
      schema: { type: string, format: uuid }
    WaitlistEntryId:
      name: entry_id
      in: path
      required: true
      schema: { type: string, format: uuid }
  schemas:
    ResponseMeta:
      type: object
      required: [request_id]
      properties:
        request_id: { type: string, format: uuid }
    Address:
      type: object
      required: [line, postal_code, city]
      properties:
        line: { type: string }
        postal_code: { type: string }
        city: { type: string }
    Contact:
      type: object
      required: [phone, email]
      properties:
        phone: { type: string }
        email: { type: string }
    Venue:
      type: object
      required: [id, name, description, logo_url, timezone, address, contact, cancellation_policy]
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        description: { type: string }
        logo_url: { type: string }
        timezone: { type: string, example: Europe/Madrid }
        address: { $ref: '#/components/schemas/Address' }
        contact: { $ref: '#/components/schemas/Contact' }
        cancellation_policy: { type: string }
    BookingPolicy:
      type: object
      additionalProperties: false
      required:
        - minimum_date
        - maximum_date
        - maximum_party_size
        - minimum_lead_hours
        - modification_minimum_hours
        - deposit_enabled
        - deposit_amount
        - deposit_currency
      properties:
        minimum_date: { type: string, format: date }
        maximum_date: { type: string, format: date }
        maximum_party_size: { type: integer }
        minimum_lead_hours: { type: integer }
        modification_minimum_hours: { type: integer }
        deposit_enabled: { type: boolean }
        deposit_amount: { type: integer, description: Importe en céntimos. }
        deposit_currency: { type: string, example: eur }
    Channel:
      type: object
      required: [id, version, schema_version, published_at, config]
      properties:
        id: { type: string, format: uuid }
        version: { type: integer, minimum: 1 }
        schema_version: { type: integer, minimum: 1 }
        published_at: { type: string, format: date-time }
        config: { $ref: '#/components/schemas/BookingChannelConfig' }
    CustomFieldDefinition:
      type: object
      additionalProperties: false
      required: [enabled, key, type, label, help, required, options]
      properties:
        enabled: { const: true }
        key:
          type: string
          pattern: '^[a-z][a-z0-9_]{1,39}$'
        type:
          type: string
          enum: [text, textarea, select, checkbox, number]
        label: { type: string, maxLength: 100 }
        help: { type: string, maxLength: 240 }
        required: { type: boolean }
        options:
          type: array
          maxItems: 20
          items: { type: string, maxLength: 80 }
    BookingChannelConfig:
      type: object
      required: [schema_version, appearance, content, journey, fields]
      properties:
        schema_version: { type: integer, const: 2 }
        appearance: { type: object, additionalProperties: true }
        content: { type: object, additionalProperties: true }
        journey: { type: object, additionalProperties: true }
        fields:
          type: object
          additionalProperties: true
          required: [custom]
          properties:
            custom:
              type: array
              maxItems: 5
              items: { $ref: '#/components/schemas/CustomFieldDefinition' }
    BookingProfile:
      type: object
      required: [venue, booking_policy, channel]
      properties:
        venue: { $ref: '#/components/schemas/Venue' }
        booking_policy: { $ref: '#/components/schemas/BookingPolicy' }
        channel: { $ref: '#/components/schemas/Channel' }
    BookingProfileResponse:
      type: object
      required: [data, meta]
      properties:
        data: { $ref: '#/components/schemas/BookingProfile' }
        meta: { $ref: '#/components/schemas/ResponseMeta' }
    Shift:
      type: object
      required: [id, name]
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
    Slot:
      type: object
      required: [id, date, time, start_local, duration_minutes, shift]
      properties:
        id:
          type: string
          description: Token opaco, firmado y de corta duración.
        date: { type: string, format: date }
        time: { type: string, pattern: '^([01][0-9]|2[0-3]):[0-5][0-9]$' }
        start_local: { type: string }
        duration_minutes: { type: integer, minimum: 1 }
        shift: { $ref: '#/components/schemas/Shift' }
    Availability:
      type: object
      required: [venue_id, date, party_size, state, message, shifts, slots]
      properties:
        venue_id: { type: string, format: uuid }
        date: { type: string, format: date }
        party_size: { type: integer }
        state:
          type: string
          enum: [ready, invalid, large_party, closed, unavailable, invalid_time]
        message: { type: string }
        shifts:
          type: array
          items: { $ref: '#/components/schemas/Shift' }
        slots:
          type: array
          items: { $ref: '#/components/schemas/Slot' }
    AvailabilityResponse:
      type: object
      required: [data, meta]
      properties:
        data: { $ref: '#/components/schemas/Availability' }
        meta: { $ref: '#/components/schemas/ResponseMeta' }
    Guest:
      type: object
      additionalProperties: false
      required: [first_name, email]
      properties:
        first_name: { type: string, maxLength: 100 }
        last_name: { type: string, maxLength: 150 }
        email: { type: string, format: email, maxLength: 150 }
        phone: { type: string, maxLength: 30 }
    ReservationConsents:
      type: object
      additionalProperties: false
      required: [privacy]
      properties:
        privacy: { const: true }
        deposit: { type: boolean, default: false }
    CreateReservationRequest:
      type: object
      additionalProperties: false
      required: [venue_id, slot_id, guest, consents]
      properties:
        venue_id: { type: string, format: uuid }
        slot_id: { type: string, minLength: 32, maxLength: 4096 }
        guest: { $ref: '#/components/schemas/Guest' }
        children: { type: integer, minimum: 0 }
        occasion: { type: string, maxLength: 50 }
        notes: { type: string, maxLength: 2000 }
        custom_fields:
          $ref: '#/components/schemas/CustomFieldValues'
        consents: { $ref: '#/components/schemas/ReservationConsents' }
    ModifyReservationRequest:
      type: object
      additionalProperties: false
      required: [version, slot_id]
      properties:
        version: { type: integer, minimum: 1 }
        slot_id:
          type: string
          minLength: 32
          maxLength: 4096
          description: Nueva franja obtenida de disponibilidad.
        custom_fields:
          $ref: '#/components/schemas/CustomFieldValues'
    CustomFieldValues:
      type: object
      description: >-
        Valores cuyas claves y tipos deben corresponder con fields.custom del
        booking profile publicado.
      additionalProperties:
        type: [string, number, boolean, 'null']
    Payment:
      type: object
      required: [required, status, amount, currency, expires_at, checkout_url]
      properties:
        required: { type: boolean }
        status: { type: string, enum: [not_required, pending] }
        amount: { type: integer, minimum: 0 }
        currency: { type: string, example: eur }
        expires_at: { type: [string, 'null'], format: date-time }
        checkout_url: { type: [string, 'null'], format: uri }
    Reservation:
      type: object
      required: [id, venue_id, status, date, time, party_size, children, custom_fields, manage_url, payment]
      properties:
        id: { type: string, format: uuid }
        venue_id: { type: string, format: uuid }
        status: { type: string, enum: [pending, confirmed] }
        date: { type: string, format: date }
        time: { type: string }
        party_size: { type: integer, minimum: 1 }
        children: { type: integer, minimum: 0 }
        custom_fields: { $ref: '#/components/schemas/CustomFieldValues' }
        manage_url: { type: [string, 'null'], format: uri }
        payment: { $ref: '#/components/schemas/Payment' }
    ReservationResponse:
      type: object
      required: [data, meta]
      properties:
        data: { $ref: '#/components/schemas/Reservation' }
        meta:
          allOf:
            - $ref: '#/components/schemas/ResponseMeta'
            - type: object
              required: [idempotent_replay]
              properties:
                idempotent_replay: { type: boolean }
    ReservationDetail:
      type: object
      required:
        - id
        - venue_id
        - version
        - status
        - date
        - time
        - start_local
        - start_utc
        - end_utc
        - duration_minutes
        - party_size
        - children
        - custom_fields
        - shift
        - guest
        - can_modify
        - can_cancel
      properties:
        id: { type: string, format: uuid }
        venue_id: { type: string, format: uuid }
        version: { type: integer, minimum: 1 }
        status:
          type: string
          enum: [pending, confirmed, seated, completed, no_show, cancelled, rejected]
        date: { type: string, format: date }
        time: { type: string }
        start_local: { type: string }
        start_utc: { type: string, format: date-time }
        end_utc: { type: string, format: date-time }
        duration_minutes: { type: integer, minimum: 1 }
        party_size: { type: integer, minimum: 1 }
        children: { type: integer, minimum: 0 }
        custom_fields: { $ref: '#/components/schemas/CustomFieldValues' }
        shift: { $ref: '#/components/schemas/Shift' }
        guest:
          anyOf:
            - $ref: '#/components/schemas/Guest'
            - type: 'null'
        notes: { type: [string, 'null'] }
        occasion: { type: [string, 'null'] }
        can_modify: { type: boolean }
        can_cancel: { type: boolean }
        change_until_local: { type: [string, 'null'] }
    ReservationDetailResponse:
      type: object
      required: [data, meta]
      properties:
        data: { $ref: '#/components/schemas/ReservationDetail' }
        meta: { $ref: '#/components/schemas/ResponseMeta' }
    CreateWaitlistEntryRequest:
      type: object
      additionalProperties: false
      required: [venue_id, date, shift_id, party_size, guest, consents]
      properties:
        venue_id: { type: string, format: uuid }
        date: { type: string, format: date }
        shift_id: { type: string, format: uuid }
        party_size: { type: integer, minimum: 1, maximum: 255 }
        guest: { $ref: '#/components/schemas/Guest' }
        notes: { type: string, maxLength: 2000 }
        consents: { $ref: '#/components/schemas/ReservationConsents' }
    WaitlistEntry:
      type: object
      required:
        - id
        - venue_id
        - version
        - status
        - date
        - party_size
        - shift
        - guest
        - can_cancel
        - created_at
        - updated_at
      properties:
        id: { type: string, format: uuid }
        venue_id: { type: string, format: uuid }
        version: { type: integer, minimum: 1 }
        status:
          type: string
          enum: [waiting, notified, converted, cancelled]
        date: { type: string, format: date }
        party_size: { type: integer, minimum: 1 }
        shift: { $ref: '#/components/schemas/Shift' }
        guest:
          anyOf:
            - $ref: '#/components/schemas/Guest'
            - type: 'null'
        notes: { type: [string, 'null'] }
        reservation_id: { type: [string, 'null'], format: uuid }
        can_cancel: { type: boolean }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    WaitlistEntryResponse:
      type: object
      required: [data, meta]
      properties:
        data: { $ref: '#/components/schemas/WaitlistEntry' }
        meta: { $ref: '#/components/schemas/ResponseMeta' }
    WebhookEventType:
      type: string
      enum:
        - reservation.created
        - reservation.updated
        - reservation.cancelled
        - waitlist.created
        - waitlist.updated
        - webhook.test
    WebhookEnvelope:
      type: object
      additionalProperties: false
      required: [id, type, created_at, data]
      properties:
        id: { type: string, format: uuid }
        type: { $ref: '#/components/schemas/WebhookEventType' }
        created_at: { type: string, format: date-time }
        data:
          type: object
          additionalProperties: true
    Problem:
      type: object
      required: [type, title, status, detail, code, request_id]
      properties:
        type: { type: string, format: uri }
        title: { type: string }
        status: { type: integer }
        detail: { type: string }
        code: { type: string }
        request_id: { type: string, format: uuid }
  responses:
    AuthenticationRequired:
      description: Credencial ausente o no válida.
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
    Forbidden:
      description: Permiso u origen no autorizado.
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
    VenueNotFound:
      description: Restaurante no disponible para esta credencial.
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
    ResourceNotFound:
      description: Reserva o entrada no encontrada dentro del restaurante.
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
    ValidationError:
      description: Parámetros no válidos.
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
    Conflict:
      description: Franja ocupada o conflicto de idempotencia.
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
    InternalError:
      description: Error interno no recuperable en la petición.
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
