openapi: 3.0.3
info:
  title: Availabell API
  version: "1.0"
  description: |
    Presence-sharing API for small groups ("spaces"). This is a Rails
    rewrite of the original Supabase edge function — the wire contract below
    (routes, headers, action dispatch, response shape) is unchanged from
    that original implementation, so existing mobile clients work as-is.

    ## How dispatch works

    There is a single URL. The operation to perform is selected with the
    `X-Action` header (or `?action=` query parameter) rather than the HTTP
    path or method. A **POST** with no `X-Action` header pings availability
    (the most common request). Every successful response — regardless of
    which action was requested — returns the caller's full current
    `spaces` / `invitations` / `users` snapshot, not just a result for that
    one action.

    An optional trailing path segment carries whatever identifier the
    action needs: a space UUID for `create_invitation`/`leave_space`, or an
    invite code for `accept_invitation`/`join_space`/`revoke_invitation`.
  contact:
    name: Availabell
externalDocs:
  description: Raw OpenAPI document
  url: /openapi.yaml
servers:
  - url: /
    description: This deployment (path is relative to whatever host you're calling)

tags:
  - name: availabell
    description: The single mobile-facing endpoint, dispatched by X-Action.

components:
  parameters:
    XAction:
      name: X-Action
      in: header
      required: false
      description: |
        Selects the operation. Omit on POST to ping availability (the
        default action). Ignored on GET except for `cron`.
      schema:
        type: string
        enum:
          - create_space
          - create_invitation
          - accept_invitation
          - join_space
          - revoke_invitation
          - leave_space
          - update_nickname
          - delete_user_data
          - cron
    XDeviceId:
      name: X-Device-Id
      in: header
      required: false
      description: Identifies the calling device. Required by every action except `cron`.
      schema:
        type: string
        example: "a1b2c3d4-e5f6-7890-device-000000000001"
    XFunctionSecret:
      name: X-Function-Secret
      in: header
      required: false
      description: |
        Shared secret. Required on every **POST** request (checked against
        `MESSEGRAM_FUNCTION_SECRET`); a wrong or missing value returns 401.
        Not checked on GET requests — preserved from the original
        implementation for wire compatibility.
      schema:
        type: string
    TrailingSegment:
      name: trailing
      in: path
      required: true
      description: |
        Action-dependent identifier: a space UUID for `create_invitation`
        and `leave_space`, or an invite code for `accept_invitation`,
        `join_space`, and `revoke_invitation`. Omit the segment entirely
        for actions that don't need one (e.g. `create_space`,
        `update_nickname`, availability pings).
      schema:
        type: string

  schemas:
    Space:
      type: object
      properties:
        uuid: { type: string, format: uuid }
        title: { type: string }
        private: { type: boolean }
        owner: { type: string, description: "Owner's device_id" }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        createdAt: { type: integer, format: int64, description: "Epoch milliseconds" }
        updatedAt: { type: integer, format: int64, description: "Epoch milliseconds" }
    Invitation:
      type: object
      properties:
        uuid: { type: string, format: uuid }
        space_uuid: { type: string, format: uuid }
        spaceUuid: { type: string, format: uuid }
        invite_code: { type: string }
        inviteCode: { type: string }
        inviter: { type: string, description: "device_id of the inviter" }
        invitee: { type: string, nullable: true, description: "device_id of the invitee, null while open" }
        available: { type: boolean }
        settings: { type: object, description: "Last known device information reported by the invitee" }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        createdAt: { type: integer, format: int64 }
        updatedAt: { type: integer, format: int64 }
    User:
      type: object
      description: A connected device that is a member of one of the caller's spaces.
      properties:
        device_id: { type: string }
        hash: { type: string, description: "First 6 characters of device_id" }
        nickname: { type: string }
        connected: { type: boolean }
        available: { type: boolean }
        percentage: { type: number, description: "Battery percentage, if reported" }
        networkType: { type: string }
        isCharging: { type: boolean }
        city: { type: string }
        country: { type: string }
        version: { type: string }
        deviceId: { type: string }
        sdkInt: { type: integer }
        sdk: { type: string }
        brand: { type: string }
        device: { type: string }
        product: { type: string }
        information: { type: object, description: "Raw last-reported device payload" }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        createdAt: { type: integer, format: int64 }
        updatedAt: { type: integer, format: int64 }
    ReplyEnvelope:
      type: object
      description: Returned by every successful request, regardless of the action performed.
      properties:
        success: { type: boolean, example: true }
        message: { type: string, example: success }
        data:
          type: object
          properties:
            spaces:
              type: array
              items: { $ref: "#/components/schemas/Space" }
            invitations:
              type: array
              items: { $ref: "#/components/schemas/Invitation" }
            users:
              type: array
              items: { $ref: "#/components/schemas/User" }
    ErrorEnvelope:
      type: object
      properties:
        success: { type: boolean, example: false }
        message: { type: string }
        data:
          type: object
          properties:
            spaces: { type: array, items: {}, example: [] }
            invitations: { type: array, items: {}, example: [] }
            users: { type: array, items: {}, example: [] }

  responses:
    Reply:
      description: >-
        Always 200 on success, carrying the caller's full current snapshot
        (not just a result scoped to the action just performed).
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ReplyEnvelope" }
    Unauthorized:
      description: Missing or incorrect X-Function-Secret on a POST request.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorEnvelope" }
    ServerError:
      description: The action failed (validation error, not found, conflict, etc). See `message`.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorEnvelope" }

paths:
  /functions/v1/availabell:
    post:
      tags: [availabell]
      operationId: dispatchAvailabellAction
      summary: Ping availability, or perform any action that doesn't need a trailing identifier
      parameters:
        - $ref: "#/components/parameters/XAction"
        - $ref: "#/components/parameters/XDeviceId"
        - $ref: "#/components/parameters/XFunctionSecret"
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              description: >-
                Shape depends on X-Action. With no X-Action header, the
                entire body is stored verbatim as the device's presence
                information (arbitrary keys allowed; `available` drives
                online/offline state).
              additionalProperties: true
            examples:
              ping_availability (no X-Action):
                value:
                  available: true
                  model: "Pixel 9"
                  brand: "Google"
                  percentage: 87
                  networkType: "wifi"
                  sdkInt: 34
              create_space:
                value: { title: "Family", isPublic: false }
              update_nickname:
                value: { nickname: "Alex" }
      responses:
        "200": { $ref: "#/components/responses/Reply" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "500": { $ref: "#/components/responses/ServerError" }
    get:
      tags: [availabell]
      operationId: getAvailabellSnapshot
      summary: Fetch the caller's current spaces/invitations/users snapshot (or run the cron sweep)
      description: Does NOT require X-Function-Secret, matching the original implementation.
      parameters:
        - $ref: "#/components/parameters/XAction"
        - $ref: "#/components/parameters/XDeviceId"
      responses:
        "200": { $ref: "#/components/responses/Reply" }

  /functions/v1/availabell/{trailing}:
    post:
      tags: [availabell]
      operationId: dispatchAvailabellActionWithIdentifier
      summary: Perform an action that needs a trailing space UUID or invite code
      parameters:
        - $ref: "#/components/parameters/TrailingSegment"
        - $ref: "#/components/parameters/XAction"
        - $ref: "#/components/parameters/XDeviceId"
        - $ref: "#/components/parameters/XFunctionSecret"
      requestBody:
        required: false
        content:
          application/json:
            schema: { type: object, additionalProperties: true }
      responses:
        "200": { $ref: "#/components/responses/Reply" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "500": { $ref: "#/components/responses/ServerError" }
    get:
      tags: [availabell]
      operationId: getAvailabellSnapshotWithIdentifier
      summary: Same as GET /functions/v1/availabell; the trailing segment is ignored for GET
      parameters:
        - $ref: "#/components/parameters/TrailingSegment"
        - $ref: "#/components/parameters/XAction"
        - $ref: "#/components/parameters/XDeviceId"
      responses:
        "200": { $ref: "#/components/responses/Reply" }
