Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ STRAVA_CLIENT_ID=
STRAVA_CLIENT_SECRET=
STRAVA_WEBHOOK_TOKEN=

# Add your own garmin info at https://developerportal.garmin.com
GARMIN_CONSUMER_KEY=
GARMIN_CONSUMER_SECRET=

# Mapbox config
MAPBOX_ACCESS_TOKEN=

Expand All @@ -36,6 +40,13 @@ SENDGRID_API_KEY=
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=

# Fitbit OAuth2 Setup
# https://dev.fitbit.com/apps
FITBIT_CLIENT_ID=
FITBIT_CLIENT_SECRET=
# Only required to verify a fitbit subscription
FITBIT_WEBHOOK_TOKEN=

# Algolia Search for Races
ALGOLIA_APPLICATION_ID=
ALGOLIA_API_KEY=
Expand Down
11 changes: 11 additions & 0 deletions config/config.exs
Original file line number Diff line number Diff line change
Expand Up @@ -68,11 +68,22 @@ config :squeeze, Squeeze.OAuth2.Google,
client_secret: System.get_env("GOOGLE_CLIENT_SECRET"),
redirect_uri: "http://localhost:4000/auth/google/callback"

config :squeeze, Squeeze.OAuth2.Fitbit,
client_id: System.get_env("FITBIT_CLIENT_ID"),
client_secret: System.get_env("FITBIT_CLIENT_SECRET"),
redirect_uri: "http://localhost:4000/integration/fitbit/callback",
webhook_challenge: System.get_env("FITBIT_WEBHOOK_TOKEN")

config :strava,
client_id: System.get_env("STRAVA_CLIENT_ID"),
client_secret: System.get_env("STRAVA_CLIENT_SECRET"),
webhook_challenge: System.get_env("STRAVA_WEBHOOK_TOKEN")

config :squeeze, Squeeze.Garmin,
consumer_key: System.get_env("GARMIN_CONSUMER_KEY"),
consumer_secret: System.get_env("GARMIN_CONSUMER_SECRET"),
redirect_uri: "http://localhost:4000/integration/garmin/callback"

config :squeeze, Squeeze.Scheduler,
jobs: [
# Run every hour
Expand Down
11 changes: 11 additions & 0 deletions config/runtime.exs
Original file line number Diff line number Diff line change
Expand Up @@ -96,4 +96,15 @@ if config_env() == :prod do
api_key: System.get_env("STRIPE_SECRET_KEY"),
publishable_key: System.get_env("STRIPE_PUBLISHABLE_KEY"),
webhook_secret: System.get_env("STRIPE_WEBHOOK_SECRET")

config :squeeze, Squeeze.OAuth2.Fitbit,
client_id: System.get_env("FITBIT_CLIENT_ID"),
client_secret: System.get_env("FITBIT_CLIENT_SECRET"),
redirect_uri: "https://www.openpace.co/integration/fitbit/callback",
webhook_challenge: System.get_env("FITBIT_WEBHOOK_TOKEN")

config :squeeze, Squeeze.Garmin,
consumer_key: System.get_env("GARMIN_CONSUMER_KEY"),
consumer_secret: System.get_env("GARMIN_CONSUMER_SECRET"),
redirect_uri: "https://www.openpace.co/integration/garmin/callback"
end
5 changes: 5 additions & 0 deletions config/test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ config :squeeze, :strava_streams, Squeeze.Strava.MockStreams

config :squeeze, :payment_processor, Squeeze.MockPaymentProcessor

config :squeeze, :garmin_auth, Squeeze.Garmin.AuthMock

config :squeeze, :fitbit_auth, Squeeze.Fitbit.AuthMock
config :squeeze, :fitbit_client, Squeeze.Fitbit.ClientMock

config :squeeze, Squeeze.OAuth2.Fitbit,
client_id: "1",
client_secret: "123456789",
Expand Down
51 changes: 51 additions & 0 deletions lib/squeeze/fitbit/activity_loader.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
defmodule Squeeze.Fitbit.ActivityLoader do
@moduledoc false

alias Squeeze.Accounts.Credential
alias Squeeze.Activities
alias Squeeze.ActivityMatcher

def update_or_create_activity(%Credential{} = credential, fitbit_activity) do
user = credential.user
activity = map_activity(fitbit_activity)

case ActivityMatcher.get_closest_activity(user, activity) do
nil ->
Activities.create_activity(user, activity)

existing_activity ->
activity = %{activity | name: existing_activity.name}
Activities.update_activity(existing_activity, activity)
end
end

defp map_activity(%{"activityName" => name, "logId" => id} = activity) do
%{
name: name,
type: name,
distance: distance(activity),
duration: duration(activity),
start_at: start_at(activity),
external_id: "#{id}"
}
end

defp start_at(%{"startTime" => start_time}) do
case Timex.parse(start_time, "{ISO:Extended:Z}") do
{:ok, t} -> t
_ -> nil
end
end

def distance(%{"distance" => distance, "distanceUnit" => unit}) do
case unit do
"Kilometer" -> distance * 1000.0
_ -> distance
end
end

def distance(_), do: 0

def duration(%{"duration" => duration}), do: trunc(duration / 1000)
def duration(_), do: 0
end
74 changes: 74 additions & 0 deletions lib/squeeze/fitbit/auth.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
defmodule Squeeze.Fitbit.Auth do
@moduledoc """
Handles OAuth2 authentication flow for Fitbit integration.
Implements the Squeeze.Fitbit.AuthBehaviour for standardized authentication methods.
"""

@behaviour Squeeze.Fitbit.AuthBehaviour

use OAuth2.Strategy

@defaults [
strategy: __MODULE__,
site: "https://api.fitbit.com",
authorize_url: "https://www.fitbit.com/oauth2/authorize",
token_url: "https://api.fitbit.com/oauth2/token"
]

@impl true
def new do
config = Application.get_env(:squeeze, Squeeze.OAuth2.Fitbit)

@defaults
|> Keyword.merge(config)
|> OAuth2.Client.new()
end

@impl true
def authorize_url!(params \\ []) do
OAuth2.Client.authorize_url!(new(), params)
end

@impl true
def get_token(params \\ [], headers \\ []) do
OAuth2.Client.get_token(new(), params, headers)
end

@impl true
def get_token!(params \\ [], headers \\ []) do
OAuth2.Client.get_token!(new(), params, headers)
end

@impl true
def get_credential!(%{token: token}) do
token
|> Map.take([:access_token, :refresh_token])
|> Map.merge(%{provider: "fitbit", uid: token.other_params["user_id"]})
end

# OAuth2.Strategy callbacks
def authorize_url(client, params) do
client
|> put_param(:response_type, "code")
|> put_param(:client_id, client.client_id)
|> put_param(:redirect_uri, client.redirect_uri)
|> merge_params(params)
end

def get_token(client, params, headers) do
{code, params} = Keyword.pop(params, :code, client.params["code"])
{grant_type, params} = Keyword.pop(params, :grant_type)

client
|> put_param(:code, code)
|> put_param(:grant_type, grant_type)
|> put_param(:redirect_uri, client.redirect_uri)
|> merge_params(params)
|> put_header(
"Authorization",
"Basic " <> Base.encode64(client.client_id <> ":" <> client.client_secret)
)
|> put_header("Accept", "application/json")
|> put_headers(headers)
end
end
57 changes: 57 additions & 0 deletions lib/squeeze/fitbit/auth_behavior.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
defmodule Squeeze.Fitbit.AuthBehaviour do
@moduledoc """
Behaviour module defining callbacks for Fitbit OAuth authentication.
"""

@type client :: OAuth2.Client.t()
@type token :: OAuth2.AccessToken.t()
@type headers :: [{binary, binary}]
@type credential :: %{
access_token: binary,
refresh_token: binary,
provider: binary,
uid: binary
}
@type scope :: String.t()
@type code :: String.t()
@type grant_type :: String.t()
@type token_opts :: [
code: code,
grant_type: grant_type,
refresh_token: binary
]
@type auth_opts :: [
scope: scope,
expires_in: integer
]

@doc """
Generates the authorization URL for the OAuth2 flow.
"""
@callback authorize_url!() :: String.t()
@callback authorize_url!(auth_opts()) :: String.t()

@doc """
Gets an OAuth2 token, raising an error if the request fails.
"""
@callback get_token!() :: client()
@callback get_token!(token_opts()) :: client()
@callback get_token!(token_opts(), headers()) :: client()

@doc """
Gets an OAuth2 token, returns {:ok, client} or {:error, reason}.
"""
@callback get_token() :: {:ok, client()} | {:error, any()}
@callback get_token(token_opts()) :: {:ok, client()} | {:error, any()}
@callback get_token(token_opts(), headers()) :: {:ok, client()} | {:error, any()}

@doc """
Creates credential map from OAuth2 client response.
"""
@callback get_credential!(client()) :: credential()

@doc """
Creates a new OAuth2 client with default configuration.
"""
@callback new() :: client()
end
96 changes: 96 additions & 0 deletions lib/squeeze/fitbit/client.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
defmodule Squeeze.Fitbit.Client do
@moduledoc """
Tesla-based HTTP client for interacting with the Fitbit API.
"""

@behaviour Squeeze.Fitbit.ClientBehaviour

use Tesla

alias Squeeze.Accounts
alias Squeeze.Accounts.Credential
alias Squeeze.Fitbit.Middleware

plug(Tesla.Middleware.BaseUrl, "https://api.fitbit.com")
plug Tesla.Middleware.JSON

@impl true
def new(%Credential{} = credential) do
new(credential.access_token,
refresh_token: credential.refresh_token,
token_refreshed:
&Accounts.update_credential(
credential,
Map.from_struct(&1.token)
)
)
end

@impl true
def new(access_token, opts \\ []) when is_binary(access_token) do
Tesla.client([
{Tesla.Middleware.Headers, [{"Authorization", "Bearer #{access_token}"}]},
{Middleware.RefreshToken, opts}
])
end

@impl true
def get_logged_in_user(client) do
url = "/1/user/-/profile.json"

case get(client, url) do
{:ok, %Tesla.Env{status: 200, body: body}} ->
{:ok, body["user"]}

{:ok, %Tesla.Env{status: 401}} ->
{:error, :unauthorized}

{:error, reason} ->
{:error, reason}
end
end

@impl true
def get_daily_activity_summary(client, date) do
url = "/1/user/-/activities/date/#{date}.json"

client
|> get(url)
end

@impl true
def get_activity_tcx(client, log_id) do
url = "/1/user/-/activities/#{log_id}.tcx"

client
|> get(url)
end

@impl true
def get_activities(client, opts \\ []) do
today = Timex.format!(Timex.today(), "{YYYY}-{0M}-{0D}")
opts = Keyword.merge(opts, limit: 20, beforeDate: today, offset: 0, sort: "desc")
url = "/1/user/-/activities/list.json"

client
|> get(url, query: opts)
end

@impl true
def create_subscription(client, user_id) do
url = "/1/user/-/activities/apiSubscriptions/#{user_id}.json"

case post(client, url) do
{:ok, %Tesla.Env{status: 201}} -> {:ok, :created}
{:ok, %Tesla.Env{status: 409}} -> {:ok, :exists}
{:ok, %Tesla.Env{status: 401}} -> {:error, :unauthorized}
{:error, reason} -> {:error, reason}
_ -> {:error, :unknown}
end
end

@impl true
def set_authorization_header(%Tesla.Env{} = env, access_token) do
%Tesla.Env{env | headers: [{"Authorization", "Bearer #{access_token}"}]}
end
end
Loading
Loading