openapi: 3.0.3
info:
  title: GoRooba Journey Platform API
  version: 1.0.0
  description: |
    Logan MVP Journey Platform.

    Core Capabilities:
    - Standard Journeys
    - School Journeys
    - Elder Journeys
    - Community Journeys
    - Guardian Tracking
    - Concession Engine
    - Safety Workflows
    - SOS Escalation
    - Provider Accreditation
    - Journey Ledger
servers:
  - url: https://api.gorooba.com/v1
    description: Production
  - url: http://localhost:3000/v1
    description: Local Development
tags:
  - name: Journeys
  - name: Quotes
  - name: Dispatch
  - name: Guardians
  - name: Schools
  - name: Providers
  - name: Safety
  - name: Live
  - name: Rides
paths:
  /quotes:
    post:
      tags:
        - Quotes
      summary: Calculate journey quote
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/QuoteRequest"
      responses:
        "200":
          description: Journey quote
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/QuoteResponse"
        "400":
          description: Invalid request or pickup PIN
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
        "409":
          description: Invalid state transition
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
        "500":
          description: Internal service error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
      operationId: postQuotes
  /journeys:
    post:
      tags:
        - Journeys
      summary: Create journey
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateJourneyRequest"
      responses:
        "201":
          description: Journey created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Journey"
        "400":
          description: Invalid request or pickup PIN
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
        "409":
          description: Invalid state transition
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
        "500":
          description: Internal service error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
      operationId: postJourneys
  /journeys/{journeyId}:
    get:
      tags:
        - Journeys
      summary: Get journey
      parameters:
        - $ref: "#/components/parameters/JourneyId"
      responses:
        "200":
          description: Journey details
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Journey"
        "400":
          description: Invalid request or pickup PIN
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
        "404":
          description: Journey not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
        "500":
          description: Internal service error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
      operationId: getJourney
  /journeys/{journeyId}/status:
    get:
      tags:
        - Journeys
      summary: Get live journey status
      parameters:
        - $ref: "#/components/parameters/JourneyId"
      responses:
        "200":
          description: Current status
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/JourneyStatusResponse"
        "400":
          description: Invalid request or pickup PIN
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
        "404":
          description: Journey not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
        "500":
          description: Internal service error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
      operationId: getJourneyStatus
  /dispatch/match:
    post:
      tags:
        - Dispatch
      summary: Match nearest accredited provider
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ProviderMatchRequest"
      responses:
        "200":
          description: Provider matched
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ProviderMatchResponse"
        "400":
          description: Invalid request or pickup PIN
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
        "403":
          description: Provider lacks required accreditation
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
        "404":
          description: Journey not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
        "409":
          description: Invalid state transition
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
        "500":
          description: Internal service error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
      operationId: postDispatchMatch
  /journeys/{journeyId}/verify-pickup:
    post:
      tags:
        - Safety
      summary: Verify school pickup PIN
      parameters:
        - $ref: "#/components/parameters/JourneyId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/PickupVerificationRequest"
      responses:
        "200":
          description: Pickup verified
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PickupVerificationResponse"
        "400":
          description: Invalid request or pickup PIN
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
        "404":
          description: Journey not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
        "409":
          description: Invalid state transition
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
        "500":
          description: Internal service error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
      operationId: postVerifyPickup
  /journeys/{journeyId}/handoff:
    post:
      tags:
        - Safety
      summary: Verify student arrival handoff
      parameters:
        - $ref: "#/components/parameters/JourneyId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/HandoffVerificationRequest"
      responses:
        "200":
          description: Handoff verified
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/HandoffVerificationResponse"
        "400":
          description: Invalid request or pickup PIN
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
        "404":
          description: Journey not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
        "409":
          description: Invalid state transition
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
        "500":
          description: Internal service error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
      operationId: postHandoff
  /journeys/{journeyId}/sos:
    post:
      tags:
        - Safety
      summary: Trigger emergency escalation
      parameters:
        - $ref: "#/components/parameters/JourneyId"
      responses:
        "200":
          description: SOS triggered
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SOSResponse"
        "400":
          description: Invalid request or pickup PIN
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
        "404":
          description: Journey not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
        "409":
          description: Invalid state transition
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
        "500":
          description: Internal service error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
      operationId: postSos
  /guardians/{guardianId}/journeys:
    get:
      tags:
        - Guardians
      summary: Get journeys visible to guardian
      parameters:
        - name: guardianId
          in: path
          required: true
          schema:
            type: string
            minLength: 1
      responses:
        "200":
          description: Guardian journeys
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/Journey"
        "400":
          description: Invalid request or pickup PIN
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
        "500":
          description: Internal service error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
      operationId: getGuardianJourneys
  /providers/nearby:
    get:
      tags:
        - Providers
      summary: Get nearby providers
      parameters:
        - name: lat
          in: query
          required: true
          schema:
            type: number
        - name: lng
          in: query
          required: true
          schema:
            type: number
      responses:
        "200":
          description: Nearby providers
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/Provider"
        "400":
          description: Invalid request or pickup PIN
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
        "500":
          description: Internal service error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
      operationId: getNearbyProviders
  /schools:
    get:
      tags:
        - Schools
      summary: List schools
      responses:
        "200":
          description: School list
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/School"
        "400":
          description: Invalid request or pickup PIN
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
        "500":
          description: Internal service error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
      operationId: getSchools
  /live:
    post:
      tags:
        - Live
      summary: Open a live link so a rider and driver can see each other
      description: |
        Returns a short code the rider sends to their driver (e.g. Mum by SMS).
        Anyone holding the code can see both positions until it expires (3 hours).
      responses:
        "201":
          description: Live link opened
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LiveLink"
        "500":
          description: Internal service error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
      operationId: postLiveLink
  /live/{code}:
    get:
      tags:
        - Live
      summary: Latest rider and driver positions on a live link
      parameters:
        - $ref: "#/components/parameters/LiveCode"
      responses:
        "200":
          description: Live link
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LiveLink"
        "400":
          description: Invalid request
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
        "404":
          description: No such live link, or it has expired
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
        "500":
          description: Internal service error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
      operationId: getLiveLink
  /live/{code}/position:
    post:
      tags:
        - Live
      summary: Share the rider's or driver's current position
      parameters:
        - $ref: "#/components/parameters/LiveCode"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/LivePositionUpdate"
      responses:
        "200":
          description: Live link after the update
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LiveLink"
        "400":
          description: Invalid request
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
        "404":
          description: No such live link, or it has expired
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
        "500":
          description: Internal service error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
      operationId: postLivePosition
  /drivers/online:
    post:
      tags:
        - Rides
      summary: Driver heartbeat while online (every few seconds); returns any ride offer waiting for them
      description: |
        A driver counts as available while heartbeats keep arriving (within 20 seconds) and they hold no offer.
        Each heartbeat also ends the driver's previous ride, since the app only heartbeats between rides.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/DriverOnlineRequest"
      responses:
        "200":
          description: Driver status, with the current offer if there is one
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DriverStatus"
        "400":
          description: Invalid request
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
        "500":
          description: Internal service error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
      operationId: postDriverOnline
  /drivers/offline:
    post:
      tags:
        - Rides
      summary: Driver stops taking rides
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/DriverRef"
      responses:
        "200":
          description: Driver status
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DriverStatus"
        "400":
          description: Invalid request
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
        "500":
          description: Internal service error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
      operationId: postDriverOffline
  /rides:
    post:
      tags:
        - Rides
      summary: Request a ride; the nearest online driver is offered it
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateRideRequest"
      responses:
        "201":
          description: Ride requested
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Ride"
        "400":
          description: Invalid request
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
        "500":
          description: Internal service error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
      operationId: postRide
  /rides/{rideId}:
    get:
      tags:
        - Rides
      summary: Ride status (the rider polls this; it also moves dispatch along)
      parameters:
        - $ref: "#/components/parameters/RideId"
      responses:
        "200":
          description: Ride
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Ride"
        "400":
          description: Invalid request
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
        "404":
          description: No such ride
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
        "500":
          description: Internal service error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
      operationId: getRide
  /rides/{rideId}/accept:
    post:
      tags:
        - Rides
      summary: Driver accepts the ride offered to them; opens the live link both phones follow
      parameters:
        - $ref: "#/components/parameters/RideId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/DriverRef"
      responses:
        "200":
          description: Ride, now accepted, with its live code
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Ride"
        "400":
          description: Invalid request
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
        "404":
          description: No such ride
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
        "409":
          description: The offer expired, went to someone else, or the ride was cancelled
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
        "500":
          description: Internal service error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
      operationId: postRideAccept
  /rides/{rideId}/decline:
    post:
      tags:
        - Rides
      summary: Driver declines; the ride goes to the next nearest driver
      parameters:
        - $ref: "#/components/parameters/RideId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/DriverRef"
      responses:
        "200":
          description: Ride after the decline
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Ride"
        "400":
          description: Invalid request
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
        "404":
          description: No such ride
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
        "500":
          description: Internal service error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
      operationId: postRideDecline
  /rides/{rideId}/cancel:
    post:
      tags:
        - Rides
      summary: Rider cancels
      parameters:
        - $ref: "#/components/parameters/RideId"
      responses:
        "200":
          description: Ride, now cancelled
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Ride"
        "400":
          description: Invalid request
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
        "404":
          description: No such ride
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
        "500":
          description: Internal service error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
      operationId: postRideCancel
components:
  parameters:
    JourneyId:
      name: journeyId
      in: path
      required: true
      schema:
        type: string
        minLength: 1
    LiveCode:
      name: code
      in: path
      required: true
      schema:
        $ref: "#/components/schemas/LiveCode"
    RideId:
      name: rideId
      in: path
      required: true
      schema:
        type: string
        pattern: "^RIDE_[23456789ABCDEFGHJKMNPQRSTUVWXYZ]{10}$"
  schemas:
    DriverId:
      type: string
      description: Random ID the driver's app makes once and keeps.
      pattern: "^[A-Za-z0-9_-]{8,64}$"
    DriverRef:
      type: object
      required:
        - driverId
      additionalProperties: false
      properties:
        driverId:
          $ref: "#/components/schemas/DriverId"
    DriverOnlineRequest:
      type: object
      required:
        - driverId
        - name
        - vehicle
        - lat
        - lng
      additionalProperties: false
      properties:
        driverId:
          $ref: "#/components/schemas/DriverId"
        name:
          type: string
          minLength: 1
          maxLength: 40
        vehicle:
          type: string
          minLength: 1
          maxLength: 60
        lat:
          type: number
          minimum: -90
          maximum: 90
        lng:
          type: number
          minimum: -180
          maximum: 180
        heading:
          type: number
          minimum: 0
          maximum: 360
    RideOffer:
      type: object
      required:
        - rideId
        - pickup
        - metersAway
        - expiresAt
        - expiresInMs
      additionalProperties: false
      properties:
        rideId:
          type: string
        expiresInMs:
          type: integer
          minimum: 0
          description: Time left on the offer as the server sees it, so phones with a wrong clock still count down right.
        pickup:
          $ref: "#/components/schemas/GeoPoint"
        destination:
          $ref: "#/components/schemas/GeoPoint"
        metersAway:
          type: integer
          minimum: 0
        expiresAt:
          type: string
          format: date-time
    DriverStatus:
      type: object
      required:
        - driverId
        - online
      additionalProperties: false
      properties:
        driverId:
          $ref: "#/components/schemas/DriverId"
        online:
          type: boolean
        offer:
          $ref: "#/components/schemas/RideOffer"
    CreateRideRequest:
      type: object
      required:
        - pickup
      additionalProperties: false
      properties:
        pickup:
          $ref: "#/components/schemas/GeoPoint"
        destination:
          $ref: "#/components/schemas/GeoPoint"
    RideStatus:
      type: string
      enum:
        - SEARCHING
        - OFFERED
        - ACCEPTED
        - NO_DRIVERS
        - CANCELLED
    RideDriver:
      type: object
      required:
        - name
        - vehicle
      additionalProperties: false
      properties:
        name:
          type: string
        vehicle:
          type: string
    Ride:
      type: object
      required:
        - rideId
        - status
        - pickup
        - createdAt
      additionalProperties: false
      properties:
        rideId:
          type: string
        status:
          $ref: "#/components/schemas/RideStatus"
        pickup:
          $ref: "#/components/schemas/GeoPoint"
        destination:
          $ref: "#/components/schemas/GeoPoint"
        createdAt:
          type: string
          format: date-time
        driver:
          $ref: "#/components/schemas/RideDriver"
        liveCode:
          $ref: "#/components/schemas/LiveCode"
    LiveCode:
      type: string
      description: Eight characters, no look-alikes (no 0/O, 1/I/L).
      pattern: "^[23456789ABCDEFGHJKMNPQRSTUVWXYZ]{8}$"
    LiveRole:
      type: string
      enum:
        - rider
        - driver
    LivePosition:
      type: object
      required:
        - lat
        - lng
        - updatedAt
      additionalProperties: false
      properties:
        lat:
          type: number
          minimum: -90
          maximum: 90
        lng:
          type: number
          minimum: -180
          maximum: 180
        heading:
          type: number
          minimum: 0
          maximum: 360
        updatedAt:
          type: string
          format: date-time
    LivePositionUpdate:
      type: object
      required:
        - role
        - lat
        - lng
      additionalProperties: false
      properties:
        role:
          $ref: "#/components/schemas/LiveRole"
        lat:
          type: number
          minimum: -90
          maximum: 90
        lng:
          type: number
          minimum: -180
          maximum: 180
        heading:
          type: number
          minimum: 0
          maximum: 360
    LiveLink:
      type: object
      required:
        - code
        - expiresAt
      additionalProperties: false
      properties:
        code:
          $ref: "#/components/schemas/LiveCode"
        expiresAt:
          type: string
          format: date-time
        rider:
          $ref: "#/components/schemas/LivePosition"
        driver:
          $ref: "#/components/schemas/LivePosition"
    GeoPoint:
      type: object
      required:
        - lat
        - lng
      properties:
        lat:
          type: number
          minimum: -90
          maximum: 90
        lng:
          type: number
          minimum: -180
          maximum: 180
        address:
          type: string
      additionalProperties: false
    JourneyType:
      type: string
      enum:
        - STANDARD
        - SCHOOL
        - ELDER
        - COMMUNITY
        - PREMIUM
        - XL
    JourneyStatus:
      type: string
      enum:
        - CREATED
        - ASSIGNED
        - DRIVER_ARRIVING
        - PICKUP_VERIFICATION
        - STUDENT_ONBOARD
        - IN_TRANSIT
        - ARRIVED
        - HANDOFF_VERIFIED
        - COMPLETED
        - CANCELLED
    ConcessionType:
      type: string
      enum:
        - NONE
        - STUDENT
        - PENSIONER
        - VETERAN
        - DISABILITY
    Accreditation:
      type: string
      enum:
        - STANDARD_DRIVER
        - SCHOOL_APPROVED
        - ELDER_APPROVED
        - COMMUNITY_APPROVED
        - WHEELCHAIR_APPROVED
        - MEDICAL_APPROVED
    Traveller:
      type: object
      properties:
        travellerId:
          type: string
          minLength: 1
        firstName:
          type: string
        lastName:
          type: string
        concessionType:
          $ref: "#/components/schemas/ConcessionType"
      required:
        - travellerId
        - firstName
        - lastName
        - concessionType
      additionalProperties: false
    Guardian:
      type: object
      properties:
        guardianId:
          type: string
          minLength: 1
        fullName:
          type: string
        phone:
          type: string
        relationship:
          type: string
    School:
      type: object
      properties:
        id:
          type: string
          minLength: 1
        name:
          type: string
        address:
          type: string
        lat:
          type: number
          minimum: -90
          maximum: 90
        lng:
          type: number
          minimum: -180
          maximum: 180
      required:
        - id
        - name
        - address
        - lat
        - lng
      additionalProperties: false
    Provider:
      type: object
      properties:
        providerId:
          type: string
          minLength: 1
        fullName:
          type: string
        rating:
          type: number
        vehicle:
          type: string
        capabilities:
          type: array
          items:
            $ref: "#/components/schemas/Accreditation"
        lat:
          type: number
          minimum: -90
          maximum: 90
        lng:
          type: number
          minimum: -180
          maximum: 180
      required:
        - providerId
        - fullName
        - rating
        - vehicle
        - capabilities
        - lat
        - lng
      additionalProperties: false
    Journey:
      type: object
      properties:
        id:
          type: string
          minLength: 1
        type:
          $ref: "#/components/schemas/JourneyType"
        status:
          $ref: "#/components/schemas/JourneyStatus"
        traveller:
          $ref: "#/components/schemas/Traveller"
        pickup:
          $ref: "#/components/schemas/GeoPoint"
        dropoff:
          $ref: "#/components/schemas/GeoPoint"
        scheduledAt:
          type: string
          format: date-time
        guardianId:
          type: string
          minLength: 1
        provider:
          $ref: "#/components/schemas/Provider"
        pickupPin:
          type: string
          pattern: ^[0-9]{4}$
      required:
        - id
        - type
        - status
        - traveller
        - pickup
        - dropoff
        - pickupPin
        - scheduledAt
      additionalProperties: false
    QuoteRequest:
      type: object
      required:
        - journeyType
        - distanceKm
      properties:
        journeyType:
          type: string
          enum:
            - STANDARD
            - SCHOOL
            - ELDER
            - COMMUNITY
            - PREMIUM
            - XL
            - STUDENT
        concessionType:
          $ref: "#/components/schemas/ConcessionType"
        distanceKm:
          type: number
          minimum: 0
      additionalProperties: false
    QuoteResponse:
      type: object
      properties:
        baseFare:
          type: number
        discount:
          type: number
        serviceFee:
          type: number
        total:
          type: number
      required:
        - baseFare
        - discount
        - serviceFee
        - total
      additionalProperties: false
    CreateJourneyRequest:
      type: object
      required:
        - journeyType
        - pickup
        - dropoff
      properties:
        journeyType:
          $ref: "#/components/schemas/JourneyType"
        travellerId:
          type: string
          minLength: 1
        pickup:
          $ref: "#/components/schemas/GeoPoint"
        dropoff:
          $ref: "#/components/schemas/GeoPoint"
        scheduledAt:
          type: string
          format: date-time
        guardianId:
          type: string
          minLength: 1
      additionalProperties: false
    ProviderMatchRequest:
      type: object
      properties:
        journeyType:
          $ref: "#/components/schemas/JourneyType"
        journeyId:
          type: string
          minLength: 1
      required:
        - journeyType
      additionalProperties: false
    ProviderMatchResponse:
      type: object
      properties:
        provider:
          $ref: "#/components/schemas/Provider"
        etaMinutes:
          type: integer
        distanceKm:
          type: number
      required:
        - provider
        - etaMinutes
        - distanceKm
      additionalProperties: false
    PickupVerificationRequest:
      type: object
      required:
        - pin
      properties:
        pin:
          type: string
          example: "8351"
          pattern: ^[0-9]{4}$
      additionalProperties: false
    PickupVerificationResponse:
      type: object
      properties:
        verified:
          type: boolean
        status:
          $ref: "#/components/schemas/JourneyStatus"
      required:
        - verified
        - status
      additionalProperties: false
    HandoffVerificationRequest:
      type: object
      properties:
        method: &a1
          type: string
          enum:
            - TEACHER_SCAN
            - GUARDIAN_PIN
            - SAFE_ARRIVAL_CONFIRM
      required:
        - method
      additionalProperties: false
    JourneyStatusResponse:
      type: object
      properties:
        journeyId:
          type: string
          minLength: 1
        status:
          $ref: "#/components/schemas/JourneyStatus"
        etaMinutes:
          type: integer
        provider:
          $ref: "#/components/schemas/Provider"
      required:
        - journeyId
        - status
        - etaMinutes
      additionalProperties: false
    SOSResponse:
      type: object
      properties:
        status:
          type: string
          enum:
            - ESCALATED
        guardianNotified:
          type: boolean
        adminNotified:
          type: boolean
        emergencyEventId:
          type: string
          minLength: 1
      required:
        - status
        - guardianNotified
        - adminNotified
        - emergencyEventId
      additionalProperties: false
    HandoffVerificationResponse:
      type: object
      required:
        - verified
        - status
        - method
      properties:
        verified:
          type: boolean
        status:
          $ref: "#/components/schemas/JourneyStatus"
        method: *a1
      additionalProperties: false
    ApiError:
      type: object
      required:
        - error
      additionalProperties: false
      properties:
        error:
          type: object
          required:
            - status
            - code
            - message
          additionalProperties: false
          properties:
            status:
              type: integer
              enum:
                - 400
                - 403
                - 404
                - 409
                - 500
            code:
              type: string
              enum:
                - INVALID_REQUEST
                - INVALID_PIN
                - UNACCREDITED_PROVIDER
                - JOURNEY_NOT_FOUND
                - ENDPOINT_NOT_FOUND
                - LIVE_LINK_NOT_FOUND
                - RIDE_NOT_FOUND
                - OFFER_NOT_AVAILABLE
                - INVALID_STATE_TRANSITION
                - INTERNAL_ERROR
            message:
              type: string
