openapi: 3.1.0
info:
  title: PadelDraft Club Sync API
  version: 1.2.0
  description: Sincronización autorizada, idempotente y snapshot-safe de jugadores, pistas, disponibilidad y reservas, incluida la publicación manual desde el dashboard.
servers:
  - url: https://{PROJECT_REF}.supabase.co/functions/v1/club-sync
    variables:
      PROJECT_REF:
        default: PROJECT_REF
        description: Referencia del proyecto Supabase mostrada en el dashboard.
security:
  - integrationToken: []
components:
  securitySchemes:
    integrationToken:
      type: http
      scheme: bearer
      bearerFormat: PadelDraft integration token
    userJwt:
      type: http
      scheme: bearer
      bearerFormat: Supabase access token
    publishableKey:
      type: apiKey
      in: header
      name: apikey
  parameters:
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: true
      schema:
        type: string
        minLength: 8
        maxLength: 200
  schemas:
    Player:
      type: object
      additionalProperties: false
      required: [externalId, name, level, matches, wins]
      properties:
        externalId: { type: string, minLength: 1, maxLength: 100 }
        name: { type: string, minLength: 2, maxLength: 160 }
        email: { type: [string, "null"], format: email, maxLength: 320 }
        level: { type: number, minimum: 0, maximum: 7 }
        matches: { type: integer, minimum: 0 }
        wins: { type: integer, minimum: 0 }
        confidence: { type: integer, minimum: 0, maximum: 100, default: 80 }
    Court:
      type: object
      additionalProperties: false
      required: [externalId, name]
      properties:
        externalId: { type: string, minLength: 1, maxLength: 100 }
        name: { type: string, minLength: 2, maxLength: 120 }
        shortName: { type: string, minLength: 1, maxLength: 60 }
        indoor: { type: boolean, default: true }
        surface: { type: string, enum: [glass, wall, other], default: glass }
        active: { type: boolean, default: true }
        bookingEnabled: { type: boolean, default: true }
    Slot:
      type: object
      additionalProperties: false
      required: [externalId, courtExternalId, startsAt, endsAt, staleAt]
      properties:
        externalId: { type: string, minLength: 1, maxLength: 160 }
        courtExternalId: { type: string, minLength: 1, maxLength: 100 }
        startsAt: { type: string, format: date-time }
        endsAt: { type: string, format: date-time }
        label: { type: string, minLength: 1, maxLength: 160 }
        detail: { type: [string, "null"], maxLength: 500 }
        status: { type: string, enum: [available, held, booked, blocked, stale], default: available }
        source: { type: string, enum: [club, provider], default: provider }
        instantBookable: { type: boolean, default: false }
        priceMinor: { type: [integer, "null"], minimum: 0 }
        currency: { type: string, pattern: "^[A-Z]{3}$", default: EUR }
        staleAt:
          type: string
          format: date-time
          description: Caducidad obligatoria, como máximo 24 horas después de la sincronización.
    BookingDecision:
      type: object
      additionalProperties: false
      required: [decision]
      properties:
        decision: { type: string, enum: [confirmed, rejected, cancelled] }
        providerBookingId: { type: string, maxLength: 160 }
        courtExternalId: { type: string, maxLength: 160 }
        courtLabel: { type: string, maxLength: 160 }
        note: { type: string, maxLength: 500 }
    ManualAvailabilityRequest:
      type: object
      additionalProperties: false
      required:
        - target_club_id
        - target_court_id
        - requested_starts_at
        - requested_ends_at
        - requested_price_minor
        - requested_idempotency_key
      properties:
        target_club_id: { type: string, format: uuid }
        target_court_id: { type: string, format: uuid }
        requested_starts_at: { type: string, format: date-time }
        requested_ends_at:
          type: string
          format: date-time
          description: Al menos quince minutos posterior al inicio; el intervalo completo no puede superar ocho horas.
        requested_price_minor: { type: integer, minimum: 0, maximum: 1000000 }
        requested_idempotency_key: { type: string, minLength: 8, maxLength: 240 }
    ManualAvailabilityResult:
      type: object
      additionalProperties: false
      required: [id, courtId, localDate, startsAt, endsAt, label, detail, status, source, instantBookable, priceMinor, currency, replayed]
      properties:
        id: { type: string, format: uuid }
        courtId: { type: string, format: uuid }
        localDate: { type: string, format: date }
        startsAt: { type: string, format: date-time }
        endsAt: { type: string, format: date-time }
        label: { type: string, const: Disponible }
        detail: { type: string, const: Publicada manualmente }
        status: { type: string, const: available }
        source: { type: string, const: manual }
        instantBookable: { type: boolean, const: true }
        priceMinor: { type: integer, minimum: 0, maximum: 1000000 }
        currency: { type: string, const: EUR }
        replayed: { type: boolean }
    ManualAvailabilityWithdrawalRequest:
      type: object
      additionalProperties: false
      required: [target_club_id, target_slot_id]
      properties:
        target_club_id: { type: string, format: uuid }
        target_slot_id: { type: string, format: uuid }
    ManualAvailabilityWithdrawalResult:
      type: object
      additionalProperties: false
      required: [id, courtId, status, source, replayed]
      properties:
        id: { type: string, format: uuid }
        courtId: { type: string, format: uuid }
        status: { type: string, const: stale }
        source: { type: string, const: manual }
        replayed: { type: boolean }
paths:
  /rest/v1/rpc/create_club_court_availability:
    post:
      summary: Publica un hueco manual reservable desde el dashboard
      description: Requiere una sesión Google válida y una membership activa owner, admin u operations en el club. Rechaza pistas ajenas, inactivas o no reservables y cualquier solapamiento con un slot vivo.
      servers:
        - url: https://{PROJECT_REF}.supabase.co
          variables:
            PROJECT_REF:
              default: PROJECT_REF
              description: Referencia del proyecto Supabase mostrada en el dashboard.
      security:
        - userJwt: []
          publishableKey: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ManualAvailabilityRequest" }
      responses:
        "200":
          description: Disponibilidad publicada o reintento idempotente recuperado
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ManualAvailabilityResult" }
        "400": { description: Intervalo, precio o clave idempotente no válidos }
        "401": { description: Sesión ausente o caducada }
        "403": { description: El usuario no tiene el rol requerido en ese club }
        "409": { description: Solapamiento físico o reutilización incompatible de la clave idempotente }
  /rest/v1/rpc/withdraw_club_court_availability:
    post:
      summary: Retira un hueco manual que sigue libre
      description: La operación es idempotente y solo admite slots del mismo club con source manual y status available. Falla si existe una solicitud, hold o reserva activa.
      servers:
        - url: https://{PROJECT_REF}.supabase.co
          variables:
            PROJECT_REF:
              default: PROJECT_REF
              description: Referencia del proyecto Supabase mostrada en el dashboard.
      security:
        - userJwt: []
          publishableKey: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ManualAvailabilityWithdrawalRequest" }
      responses:
        "200":
          description: Disponibilidad retirada o retirada previa recuperada idempotentemente
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ManualAvailabilityWithdrawalResult" }
        "401": { description: Sesión ausente o caducada }
        "403": { description: El usuario no tiene el rol requerido o el slot no es manual }
        "404": { description: El slot no pertenece al club }
        "409": { description: El slot ya tiene una solicitud, hold o reserva }
  /players:
    post:
      summary: Sincroniza estadísticas de jugadores
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [players]
              properties:
                players:
                  type: array
                  minItems: 1
                  maxItems: 500
                  items: { $ref: "#/components/schemas/Player" }
      responses:
        "202": { description: Lote aceptado }
        "400": { description: Lote no válido }
        "401": { description: Credencial no válida }
        "403": { description: Scope insuficiente }
        "409": { description: Clave idempotente en curso o reutilizada con otro payload }
  /courts:
    post:
      summary: Sincroniza el catálogo de pistas
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [courts]
              properties:
                courts:
                  type: array
                  minItems: 1
                  maxItems: 500
                  items: { $ref: "#/components/schemas/Court" }
      responses:
        "202": { description: Lote aceptado }
        "400": { description: Lote no válido }
        "401": { description: Credencial no válida }
        "403": { description: Scope insuficiente }
        "409": { description: Clave idempotente en curso o reutilizada con otro payload }
  /slots:
    post:
      summary: Aplica un delta de disponibilidad sin caducar slots omitidos
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [slots]
              properties:
                slots:
                  type: array
                  minItems: 1
                  maxItems: 500
                  items: { $ref: "#/components/schemas/Slot" }
      responses:
        "202": { description: Lote aceptado }
        "400": { description: Lote no válido }
        "401": { description: Credencial no válida }
        "403": { description: Scope insuficiente }
        "409": { description: Clave idempotente en curso, payload distinto o conflicto físico }
    put:
      summary: Reemplaza una ventana completa de disponibilidad
      description: Los slots available omitidos dentro de la ventana pasan a stale. Los compromisos held, booked y blocked se conservan.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [windowStartsAt, windowEndsAt, slots]
              properties:
                windowStartsAt: { type: string, format: date-time }
                windowEndsAt:
                  type: string
                  format: date-time
                  description: Debe ser posterior al inicio y estar a un máximo de 31 días.
                slots:
                  type: array
                  minItems: 0
                  maxItems: 500
                  items: { $ref: "#/components/schemas/Slot" }
      responses:
        "202": { description: Lote aceptado }
        "400": { description: Snapshot o ventana no válidos }
        "409": { description: Clave idempotente en curso, payload distinto o conflicto físico }
  /bookings:
    get:
      summary: Lista reservas actualizadas
      parameters:
        - name: cursor
          in: query
          description: Cursor opaco compuesto por updated_at e id devuelto como nextCursor.
          schema: { type: string, minLength: 1, maxLength: 1000 }
        - name: since
          in: query
          deprecated: true
          description: Cursor temporal legado, solo para la primera petición. No combinar con cursor.
          schema: { type: string, format: date-time }
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 200, default: 100 }
      responses:
        "200": { description: Página de reservas }
        "401": { description: Credencial no válida }
        "403": { description: Scope insuficiente }
  /bookings/{bookingId}:
    post:
      summary: Confirma, rechaza o cancela una reserva
      parameters:
        - name: bookingId
          in: path
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/BookingDecision" }
      responses:
        "200": { description: Reserva actualizada }
        "400": { description: Decisión no válida }
        "404": { description: Reserva no encontrada }
