Note: This was generated by ChatGPT ✨


Great — here’s a clean, production-ready Rails service object pattern for integrating your app with Microsoft Graph (for Outlook / Microsoft To Do / SharePoint).

This pattern gives you:

  • Clean separation of concerns
  • Automatic token refresh
  • Centralized error handling
  • Easy background job integration
  • Reusable for To Do + SharePoint

🏗 Recommended Structure

app/
  services/
    microsoft_graph/
      base_client.rb
      token_refresher.rb
      todo_tasks_service.rb
      sharepoint_service.rb

1️⃣ Base Client (Handles Auth + HTTP)

app/services/microsoft_graph/base_client.rb

# frozen_string_literal: true

module MicrosoftGraph
  class BaseClient
    GRAPH_BASE_URL = "https://graph.microsoft.com/v1.0"

    def initialize(user)
      @user = user
    end

    private

    def connection
      @connection ||= Faraday.new(url: GRAPH_BASE_URL) do |f|
        f.request :json
        f.response :json
        f.adapter Faraday.default_adapter
      end
    end

    def access_token
      return @user.microsoft_access_token if token_valid?

      MicrosoftGraph::TokenRefresher.new(@user).refresh!
      @user.reload.microsoft_access_token
    end

    def token_valid?
      @user.microsoft_token_expires_at.present? &&
        @user.microsoft_token_expires_at > 5.minutes.from_now
    end

    def get(path)
      request(:get, path)
    end

    def post(path, body:)
      request(:post, path, body)
    end

    def patch(path, body:)
      request(:patch, path, body)
    end

    def delete(path)
      request(:delete, path)
    end

    def request(method, path, body = nil)
      response = connection.send(method) do |req|
        req.url path
        req.headers["Authorization"] = "Bearer #{access_token}"
        req.headers["Content-Type"] = "application/json"
        req.body = body if body
      end

      handle_errors(response)

      response.body
    end

    def handle_errors(response)
      return if response.success?

      Rails.logger.error("Graph API Error: #{response.status} #{response.body}")
      raise "Microsoft Graph API Error: #{response.status}"
    end
  end
end

2️⃣ Token Refresher Service

app/services/microsoft_graph/token_refresher.rb

# frozen_string_literal: true

module MicrosoftGraph
  class TokenRefresher
    TOKEN_URL = "https://login.microsoftonline.com/#{ENV['MS_TENANT_ID']}/oauth2/v2.0/token"

    def initialize(user)
      @user = user
    end

    def refresh!
      response = Faraday.post(TOKEN_URL, {
        client_id: ENV["MS_CLIENT_ID"],
        client_secret: ENV["MS_CLIENT_SECRET"],
        grant_type: "refresh_token",
        refresh_token: @user.microsoft_refresh_token,
        scope: "offline_access Tasks.ReadWrite Sites.ReadWrite.All"
      })

      data = JSON.parse(response.body)

      @user.update!(
        microsoft_access_token: data["access_token"],
        microsoft_refresh_token: data["refresh_token"],
        microsoft_token_expires_at: Time.current + data["expires_in"].to_i.seconds
      )
    end
  end
end

3️⃣ Microsoft To Do Task Service

app/services/microsoft_graph/todo_tasks_service.rb

# frozen_string_literal: true

module MicrosoftGraph
  class TodoTasksService < BaseClient
    def create_task(list_id:, title:, due_date: nil)
      body = {
        title: title
      }

      if due_date
        body[:dueDateTime] = {
          dateTime: due_date.iso8601,
          timeZone: "UTC"
        }
      end

      post("/me/todo/lists/#{list_id}/tasks", body: body)
    end

    def update_task(list_id:, task_id:, attributes:)
      patch("/me/todo/lists/#{list_id}/tasks/#{task_id}", body: attributes)
    end

    def delete_task(list_id:, task_id:)
      delete("/me/todo/lists/#{list_id}/tasks/#{task_id}")
    end
  end
end

This syncs with tasks visible in Microsoft Outlook via Microsoft To Do.


4️⃣ SharePoint List Service

app/services/microsoft_graph/sharepoint_service.rb

# frozen_string_literal: true

module MicrosoftGraph
  class SharepointService < BaseClient
    def create_list_item(site_id:, list_id:, fields:)
      post("/sites/#{site_id}/lists/#{list_id}/items", body: { fields: fields })
    end

    def update_list_item(site_id:, list_id:, item_id:, fields:)
      patch("/sites/#{site_id}/lists/#{list_id}/items/#{item_id}/fields", body: fields)
    end
  end
end

Works with SharePoint lists.


5️⃣ How You Use It in Rails

Example from a model callback:

def sync_to_microsoft!
  service = MicrosoftGraph::TodoTasksService.new(user)

  response = service.create_task(
    list_id: user.microsoft_default_list_id,
    title: title,
    due_date: due_at
  )

  update!(microsoft_task_id: response["id"])
end

6️⃣ Recommended: Use Background Jobs

Never sync inside request cycle.

class SyncMicrosoftTaskJob < ApplicationJob
  queue_as :default

  def perform(action_item_id)
    action_item = ActionItem.find(action_item_id)
    action_item.sync_to_microsoft!
  end
end

🧠 Production-Grade Improvements

If you’re scaling:

✔ Add:

  • Retry logic on 429 rate limits
  • Webhook subscriptions for two-way sync
  • Conflict resolution (store last_modified_date_time)
  • Multi-tenant token isolation
  • Service-level error classes

🎯 Why This Pattern Works Well

  • BaseClient centralizes auth + HTTP
  • TokenRefresher isolates OAuth complexity
  • Feature services stay clean and readable
  • Easy to test with WebMock
  • Easy to extend (Calendar, Mail, Teams, etc.)

If you’d like, next I can show:

  • A full two-way sync architecture
  • Webhook subscription setup
  • Or a multi-tenant SaaS-ready structure

Just tell me your deployment model.