feat: Google Play Store Reviews

This commit is contained in:
Pranav
2026-05-19 15:32:49 -07:00
parent 1913ccadfa
commit 13a9f2591b
33 changed files with 603 additions and 39 deletions
@@ -0,0 +1,34 @@
class Api::V1::Accounts::GooglePlay::AuthorizationsController < Api::V1::Accounts::OauthAuthorizationController
include GooglePlayOauthConcern
def create
redirect_url = google_client.auth_code.authorize_url(
redirect_uri: google_play_callback_url,
scope: scope,
response_type: 'code',
prompt: 'consent', # forces Google to return a refresh token
access_type: 'offline',
state: google_play_oauth_state,
client_id: GlobalConfigService.load('GOOGLE_OAUTH_CLIENT_ID', nil)
)
if redirect_url
render json: { success: true, url: redirect_url }
else
render json: { success: false }, status: :unprocessable_entity
end
end
private
def google_play_oauth_state
google_play_verifier.generate(
{
account_id: Current.account.id,
app_id: params[:app_id],
inbox_name: params[:inbox_name]
},
expires_in: 15.minutes
)
end
end
@@ -97,7 +97,7 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
end
def allowed_channel_types
%w[web_widget api email line telegram whatsapp sms]
%w[web_widget api email line telegram whatsapp sms google_play]
end
def update_inbox_working_hours
@@ -179,7 +179,8 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
'line' => Channel::Line,
'telegram' => Channel::Telegram,
'whatsapp' => Channel::Whatsapp,
'sms' => Channel::Sms
'sms' => Channel::Sms,
'google_play' => Channel::GooglePlay
}[permitted_params[:channel][:type]]
end
@@ -0,0 +1,26 @@
module GooglePlayOauthConcern
extend ActiveSupport::Concern
include GoogleConcern
GOOGLE_PLAY_SCOPE = 'https://www.googleapis.com/auth/androidpublisher'.freeze
private
# Overrides the Gmail scope from GoogleConcern with the Play Developer API scope
def scope
GOOGLE_PLAY_SCOPE
end
# Carries the account and channel details through the OAuth round trip
def google_play_verifier
Rails.application.message_verifier('google_play_oauth')
end
def google_play_callback_url
"#{base_url}/google_play/callback"
end
def base_url
ENV.fetch('FRONTEND_URL', 'http://localhost:3000')
end
end
@@ -0,0 +1,57 @@
class GooglePlay::CallbacksController < ApplicationController
include GooglePlayOauthConcern
def show
return redirect_with_error(params[:error]) if params[:error].present?
token = google_client.auth_code.get_token(params[:code], redirect_uri: google_play_callback_url)
inbox = create_channel_with_inbox(token)
redirect_to app_google_play_inbox_agents_url(account_id: account.id, inbox_id: inbox.id)
rescue StandardError => e
ChatwootExceptionTracker.new(e).capture_exception
redirect_with_error(e.message)
end
private
# Sends the user back to the inbox setup form with the error surfaced as a query param.
# Falls back to '/' only when we can't determine the account (e.g. tampered/missing state).
def redirect_with_error(error_message)
acc = safe_account
return redirect_to '/' unless acc
redirect_to app_new_google_play_inbox_url(account_id: acc.id, error: error_message.to_s.truncate(300))
end
def safe_account
Account.find_by(id: state_payload[:account_id])
rescue StandardError
nil
end
def state_payload
@state_payload ||= google_play_verifier.verify(params[:state]).with_indifferent_access
end
def account
@account ||= Account.find(state_payload[:account_id])
end
def create_channel_with_inbox(token)
ActiveRecord::Base.transaction do
channel = Channel::GooglePlay.create!(
account: account,
app_id: state_payload[:app_id],
provider_config: {
access_token: token.token,
refresh_token: token.refresh_token,
expires_on: (Time.current.utc + 1.hour).to_s
}
)
# Return the newly created inbox directly — `channel.inbox` is unreliable here because the polymorphic
# has_one cache is not always populated by `account.inboxes.create!`.
account.inboxes.create!(account: account, channel: channel, name: state_payload[:inbox_name])
end
end
end
+2 -1
View File
@@ -111,7 +111,8 @@ module Api::V1::InboxesHelper
'line' => Current.account.line_channels,
'telegram' => Current.account.telegram_channels,
'whatsapp' => Current.account.whatsapp_channels,
'sms' => Current.account.sms_channels
'sms' => Current.account.sms_channels,
'google_play' => Current.account.google_play_channels
}[permitted_params[:channel][:type]]
end
@@ -0,0 +1,14 @@
/* global axios */
import ApiClient from '../ApiClient';
class GooglePlayClient extends ApiClient {
constructor() {
super('google_play', { accountScoped: true });
}
generateAuthorization(payload) {
return axios.post(`${this.url}/authorization`, payload);
}
}
export default new GooglePlayClient();
@@ -15,6 +15,7 @@ export function useChannelIcon(inbox) {
'Channel::Whatsapp': 'i-woot-whatsapp',
'Channel::Instagram': 'i-woot-instagram',
'Channel::Tiktok': 'i-woot-tiktok',
'Channel::GooglePlay': 'i-ri-google-play-line',
};
const providerIconMap = {
@@ -21,6 +21,7 @@ const {
isAnEmailChannel,
isAnInstagramChannel,
isATiktokChannel,
isAGooglePlayChannel,
} = useInbox();
const {
@@ -62,7 +63,8 @@ const isSent = computed(() => {
isASmsInbox.value ||
isATelegramChannel.value ||
isAnInstagramChannel.value ||
isATiktokChannel.value
isATiktokChannel.value ||
isAGooglePlayChannel.value
) {
return sourceId.value && status.value === MESSAGE_STATUS.SENT;
}
@@ -67,6 +67,7 @@ const isActive = computed(() => {
'instagram',
'tiktok',
'voice',
'google_play',
].includes(key);
});
@@ -249,6 +249,9 @@ export default {
if (this.isATiktokChannel) {
return MESSAGE_MAX_LENGTH.TIKTOK;
}
if (this.isAGooglePlayChannel) {
return MESSAGE_MAX_LENGTH.GOOGLE_PLAY;
}
if (this.isATwilioWhatsAppChannel) {
return MESSAGE_MAX_LENGTH.TWILIO_WHATSAPP;
}
@@ -138,6 +138,10 @@ export const useInbox = (inboxId = null) => {
return channelType.value === INBOX_TYPES.TIKTOK;
});
const isAGooglePlayChannel = computed(() => {
return channelType.value === INBOX_TYPES.GOOGLE_PLAY;
});
const voiceCallEnabled = computed(() => isVoiceCallEnabled(inbox.value));
const voiceCallProvider = computed(() => getVoiceCallProvider(inbox.value));
@@ -160,6 +164,7 @@ export const useInbox = (inboxId = null) => {
isAnEmailChannel,
isAnInstagramChannel,
isATiktokChannel,
isAGooglePlayChannel,
voiceCallEnabled,
voiceCallProvider,
};
@@ -114,6 +114,12 @@ export const FORMATTING = {
nodes: [],
menu: [],
},
'Channel::GooglePlay': {
// Google Play developer replies are plain text only — no formatting is supported.
marks: [],
nodes: [],
menu: [],
},
// Special contexts (not actual channels)
'Context::PrivateNote': {
marks: ['strong', 'em', 'code', 'link', 'strike'],
+6
View File
@@ -11,6 +11,7 @@ export const INBOX_TYPES = {
SMS: 'Channel::Sms',
INSTAGRAM: 'Channel::Instagram',
TIKTOK: 'Channel::Tiktok',
GOOGLE_PLAY: 'Channel::GooglePlay',
};
// Add providers here as they gain voice capability (e.g., WhatsApp Cloud, Twilio WhatsApp)
@@ -50,6 +51,7 @@ const INBOX_ICON_MAP_FILL = {
[INBOX_TYPES.LINE]: 'i-ri-line-fill',
[INBOX_TYPES.INSTAGRAM]: 'i-ri-instagram-fill',
[INBOX_TYPES.TIKTOK]: 'i-ri-tiktok-fill',
[INBOX_TYPES.GOOGLE_PLAY]: 'i-ri-google-play-fill',
};
const DEFAULT_ICON_FILL = 'i-ri-chat-1-fill';
@@ -65,6 +67,7 @@ const INBOX_ICON_MAP_LINE = {
[INBOX_TYPES.LINE]: 'i-woot-line',
[INBOX_TYPES.INSTAGRAM]: 'i-woot-instagram',
[INBOX_TYPES.TIKTOK]: 'i-woot-tiktok',
[INBOX_TYPES.GOOGLE_PLAY]: 'i-ri-google-play-line',
};
const DEFAULT_ICON_LINE = 'i-ri-chat-1-line';
@@ -114,6 +117,9 @@ export const getReadableInboxByType = (type, phoneNumber) => {
case INBOX_TYPES.LINE:
return 'line';
case INBOX_TYPES.GOOGLE_PLAY:
return 'google_play';
default:
return 'chat';
}
@@ -447,6 +447,24 @@
"ERROR_MESSAGE": "We were not able to save the telegram channel"
}
},
"GOOGLE_PLAY": {
"TITLE": "Google Play Reviews",
"DESC": "Manage and reply to your Google Play Store app reviews from Chatwoot.",
"INBOX_NAME": {
"LABEL": "Inbox Name",
"PLACEHOLDER": "Please enter an inbox name",
"ERROR": "This field is required"
},
"APP_ID": {
"LABEL": "App package name",
"PLACEHOLDER": "Please enter your app package name (eg: com.example.app)",
"ERROR": "Please enter a valid app package name"
},
"CONNECT_BUTTON": "Connect with Google",
"API": {
"ERROR_MESSAGE": "We were not able to connect your Google Play Reviews channel"
}
},
"AUTH": {
"TITLE": "Choose a channel",
"DESC": "Chatwoot supports live-chat widgets, Facebook Messenger, WhatsApp, Emails, etc., as channels. If you want to build a custom channel, you can create it using the API channel. To get started, choose one of the channels below.",
@@ -496,6 +514,10 @@
"VOICE": {
"TITLE": "Voice",
"DESCRIPTION": "Integrate with Twilio Voice"
},
"GOOGLE_PLAY": {
"TITLE": "Google Play Reviews",
"DESCRIPTION": "Manage your Google Play Store app reviews"
}
}
},
@@ -1165,6 +1187,7 @@
"API": "API Channel",
"INSTAGRAM": "Instagram",
"TIKTOK": "TikTok",
"GOOGLE_PLAY": "Google Play Reviews",
"VOICE": "Voice"
}
}
@@ -12,6 +12,7 @@ import Telegram from './channels/Telegram.vue';
import Instagram from './channels/Instagram.vue';
import Tiktok from './channels/Tiktok.vue';
import Voice from './channels/Voice.vue';
import GooglePlay from './channels/GooglePlay.vue';
const channelViewList = {
facebook: Facebook,
@@ -26,6 +27,7 @@ const channelViewList = {
instagram: Instagram,
tiktok: Tiktok,
voice: Voice,
google_play: GooglePlay,
};
export default defineComponent({
@@ -77,6 +77,12 @@ const channelList = computed(() => {
description: t('INBOX_MGMT.ADD.AUTH.CHANNEL.INSTAGRAM.DESCRIPTION'),
icon: 'i-woot-instagram',
},
{
key: 'google_play',
title: t('INBOX_MGMT.ADD.AUTH.CHANNEL.GOOGLE_PLAY.TITLE'),
description: t('INBOX_MGMT.ADD.AUTH.CHANNEL.GOOGLE_PLAY.DESCRIPTION'),
icon: 'i-ri-google-play-line',
},
];
if (hasTiktokConfigured.value) {
@@ -0,0 +1,109 @@
<script setup>
import { onMounted, reactive, ref } from 'vue';
import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useVuelidate } from '@vuelidate/core';
import { required } from '@vuelidate/validators';
import { useAlert } from 'dashboard/composables';
import googlePlayClient from 'dashboard/api/channel/googlePlayClient';
import PageHeader from '../../SettingsSubPageHeader.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
const route = useRoute();
const { t } = useI18n();
// Surface any error the OAuth callback redirected back with
onMounted(() => {
if (route.query.error) {
useAlert(String(route.query.error));
}
});
const state = reactive({
inboxName: '',
appId: '',
});
const isConnecting = ref(false);
const rules = {
inboxName: { required },
appId: { required },
};
const v$ = useVuelidate(rules, state);
const connectWithGoogle = async () => {
v$.value.$touch();
if (v$.value.$invalid) return;
try {
isConnecting.value = true;
const {
data: { url },
} = await googlePlayClient.generateAuthorization({
app_id: state.appId.trim(),
inbox_name: state.inboxName.trim(),
});
window.location.href = url;
} catch (error) {
isConnecting.value = false;
useAlert(t('INBOX_MGMT.ADD.GOOGLE_PLAY.API.ERROR_MESSAGE'));
}
};
</script>
<template>
<div class="h-full w-full p-6 col-span-6">
<PageHeader
:header-title="$t('INBOX_MGMT.ADD.GOOGLE_PLAY.TITLE')"
:header-content="$t('INBOX_MGMT.ADD.GOOGLE_PLAY.DESC')"
/>
<form
class="flex flex-wrap flex-col mx-0"
@submit.prevent="connectWithGoogle"
>
<div class="flex-shrink-0 flex-grow-0">
<label :class="{ error: v$.inboxName.$error }">
{{ $t('INBOX_MGMT.ADD.GOOGLE_PLAY.INBOX_NAME.LABEL') }}
<input
v-model="state.inboxName"
type="text"
:placeholder="
$t('INBOX_MGMT.ADD.GOOGLE_PLAY.INBOX_NAME.PLACEHOLDER')
"
@blur="v$.inboxName.$touch"
/>
<span v-if="v$.inboxName.$error" class="message">
{{ $t('INBOX_MGMT.ADD.GOOGLE_PLAY.INBOX_NAME.ERROR') }}
</span>
</label>
</div>
<div class="flex-shrink-0 flex-grow-0">
<label :class="{ error: v$.appId.$error }">
{{ $t('INBOX_MGMT.ADD.GOOGLE_PLAY.APP_ID.LABEL') }}
<input
v-model="state.appId"
type="text"
:placeholder="$t('INBOX_MGMT.ADD.GOOGLE_PLAY.APP_ID.PLACEHOLDER')"
@blur="v$.appId.$touch"
/>
<span v-if="v$.appId.$error" class="message">
{{ $t('INBOX_MGMT.ADD.GOOGLE_PLAY.APP_ID.ERROR') }}
</span>
</label>
</div>
<div class="w-full mt-4">
<NextButton
:is-loading="isConnecting"
type="submit"
solid
blue
:label="$t('INBOX_MGMT.ADD.GOOGLE_PLAY.CONNECT_BUTTON')"
/>
</div>
</form>
</div>
</template>
@@ -34,6 +34,7 @@ const i18nMap = {
'Channel::Api': 'API',
'Channel::Instagram': 'INSTAGRAM',
'Channel::Tiktok': 'TIKTOK',
'Channel::GooglePlay': 'GOOGLE_PLAY',
};
const twilioChannelName = () => {
@@ -22,4 +22,6 @@ export const MESSAGE_MAX_LENGTH = {
TELEGRAM: 4096,
LINE: 2000,
EMAIL: 25000,
// https://developers.google.com/android-publisher/reviews — developer replies are capped at 350 characters
GOOGLE_PLAY: 350,
};
@@ -134,6 +134,9 @@ export default {
isATiktokChannel() {
return this.channelType === INBOX_TYPES.TIKTOK;
},
isAGooglePlayChannel() {
return this.channelType === INBOX_TYPES.GOOGLE_PLAY;
},
},
methods: {
inboxHasFeature(feature) {
@@ -0,0 +1,11 @@
class Inboxes::FetchGooglePlayReviewInboxesJob < ApplicationJob
queue_as :scheduled_jobs
def perform
Inbox.where(channel_type: 'Channel::GooglePlay').find_each(batch_size: 100) do |inbox|
next if inbox.account.suspended?
::Inboxes::FetchGooglePlayReviewsJob.perform_later(inbox.channel)
end
end
end
@@ -0,0 +1,13 @@
class Inboxes::FetchGooglePlayReviewsJob < ApplicationJob
queue_as :scheduled_jobs
def perform(channel)
channel.fetch_reviews.each do |review|
::GooglePlay::ReviewBuilder.new(review: review, channel: channel).perform
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: channel.account).capture_exception
end
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: channel.account).capture_exception
end
end
+1
View File
@@ -11,6 +11,7 @@ class SendReplyJob < ApplicationJob
'Channel::Instagram' => ::Instagram::SendOnInstagramService,
'Channel::Tiktok' => ::Tiktok::SendOnTiktokService,
'Channel::Email' => ::Email::SendOnEmailService,
'Channel::GooglePlay' => ::GooglePlay::SendOnGooglePlayService,
'Channel::WebWidget' => ::Messages::SendEmailNotificationService,
'Channel::Api' => ::Messages::SendEmailNotificationService
}.freeze
+1
View File
@@ -77,6 +77,7 @@ class Account < ApplicationRecord
has_many :data_imports, dependent: :destroy_async
has_many :email_channels, dependent: :destroy_async, class_name: '::Channel::Email'
has_many :facebook_pages, dependent: :destroy_async, class_name: '::Channel::FacebookPage'
has_many :google_play_channels, dependent: :destroy_async, class_name: '::Channel::GooglePlay'
has_many :instagram_channels, dependent: :destroy_async, class_name: '::Channel::Instagram'
has_many :tiktok_channels, dependent: :destroy_async, class_name: '::Channel::Tiktok'
has_many :hooks, dependent: :destroy_async, class_name: 'Integrations::Hook'
+70
View File
@@ -0,0 +1,70 @@
# == Schema Information
#
# Table name: channel_google_play
#
# id :bigint not null, primary key
# app_id :string not null
# provider_config :jsonb not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
#
# Indexes
#
# index_channel_google_play_on_account_id_and_app_id (account_id,app_id) UNIQUE
#
class Channel::GooglePlay < ApplicationRecord
include Channelable
self.table_name = 'channel_google_play'
EDITABLE_ATTRS = [:app_id, { provider_config: {} }].freeze
API_BASE_URL = 'https://androidpublisher.googleapis.com/androidpublisher/v3'.freeze
# Google Play caps a developer reply at 350 characters
MAX_REPLY_LENGTH = 350
validates :app_id, presence: true, uniqueness: { scope: :account_id }
def name
'Google Play'
end
# Google Play only retains reviews from the last 7 days, hence the channel is polled frequently
def fetch_reviews
response = HTTParty.get(
"#{API_BASE_URL}/applications/#{app_id}/reviews",
headers: authorization_headers,
query: { maxResults: 100 }
)
raise "Google Play reviews fetch failed (#{response.code}): #{response.body}" unless response.success?
response.parsed_response['reviews'] || []
end
# Returns a stable source_id for the reply so the outgoing message can be marked as sent.
# Google Play has no separate reply id, so we combine the review id with the developer comment's
# lastEdited timestamp from the response.
def reply_to_review(review_id, reply_text)
response = HTTParty.post(
"#{API_BASE_URL}/applications/#{app_id}/reviews/#{review_id}:reply",
headers: authorization_headers.merge('Content-Type' => 'application/json'),
body: { replyText: reply_text.to_s.truncate(MAX_REPLY_LENGTH) }.to_json
)
raise "Google Play reply failed (#{response.code}): #{response.body}" unless response.success?
last_edited = response.parsed_response.dig('result', 'lastEdited', 'seconds')
"#{review_id}::reply::#{last_edited}"
end
private
def authorization_headers
{ 'Authorization' => "Bearer #{access_token}" }
end
# Channels are connected through the Google OAuth flow; tokens are refreshed on demand
def access_token
Google::RefreshOauthTokenService.new(channel: self).access_token
end
end
+4
View File
@@ -146,6 +146,10 @@ class Inbox < ApplicationRecord
channel_type == 'Channel::Email'
end
def google_play?
channel_type == 'Channel::GooglePlay'
end
def twilio?
channel_type == 'Channel::TwilioSms'
end
@@ -14,7 +14,7 @@ class Conversations::MessageWindowService
private
def messaging_window
def messaging_window # rubocop:disable Metrics/CyclomaticComplexity
case @conversation.inbox.channel_type
when 'Channel::Api'
api_messaging_window
@@ -28,6 +28,10 @@ class Conversations::MessageWindowService
MESSAGING_WINDOW_24_HOURS
when 'Channel::TwilioSms'
twilio_messaging_window
when 'Channel::GooglePlay'
# Google Play API only allows replying to reviews left within the last 7 days.
# https://developers.google.com/android-publisher/reviews
MESSAGING_WINDOW_7_DAYS
end
end
@@ -0,0 +1,94 @@
# Builds a contact, conversation and incoming message from a single Google Play review.
# Each review maps to one conversation (keyed on the review id). When a reviewer edits
# their review, a fresh incoming message is appended to the same conversation.
class GooglePlay::ReviewBuilder
pattr_initialize [:review!, :channel!]
def perform
return if user_comment.blank? || review_text.blank?
ActiveRecord::Base.transaction do
build_contact_inbox
build_conversation
build_message
end
end
private
def inbox
@inbox ||= channel.inbox
end
def user_comment
@user_comment ||= Array(review['comments']).filter_map { |comment| comment['userComment'] }.first
end
def review_id
review['reviewId']
end
def review_text
@review_text ||= user_comment['text'].to_s.strip
end
def star_rating
@star_rating ||= user_comment['starRating'].to_i
end
# A new lastModified timestamp (edited review) yields a new message in the same conversation
def message_source_id
"#{review_id}::#{user_comment.dig('lastModified', 'seconds')}"
end
def build_contact_inbox
@contact_inbox = ::ContactInboxWithContactBuilder.new(
source_id: review_id,
inbox: inbox,
contact_attributes: {
name: review['authorName'].presence || 'Google Play User',
additional_attributes: { source_id: "google_play:#{review_id}" }
}
).perform
end
def build_conversation
@conversation = @contact_inbox.conversations.last || ::Conversation.create!(
account_id: inbox.account_id,
inbox_id: inbox.id,
contact_id: @contact_inbox.contact_id,
contact_inbox_id: @contact_inbox.id,
additional_attributes: { source: 'google_play', app_id: channel.app_id }
)
end
def build_message
return if @conversation.messages.exists?(source_id: message_source_id)
@conversation.messages.create!(
account_id: inbox.account_id,
inbox_id: inbox.id,
sender: @conversation.contact,
message_type: :incoming,
source_id: message_source_id,
content: message_content,
content_attributes: review_metadata
)
end
def message_content
stars = ('★' * star_rating) + ('☆' * (5 - star_rating))
"#{stars} (#{star_rating}/5)\n\n#{review_text}"
end
def review_metadata
{
google_play: {
star_rating: star_rating,
app_version: user_comment['appVersionName'],
device: user_comment['device'],
reviewer_language: user_comment['reviewerLanguage']
}
}
end
end
@@ -0,0 +1,15 @@
class GooglePlay::SendOnGooglePlayService < Base::SendOnChannelService
private
def channel_class
Channel::GooglePlay
end
def perform_reply
source_id = channel.reply_to_review(message.conversation.contact_inbox.source_id, message.content)
message.update!(source_id: source_id) if source_id.present?
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: message.account).capture_exception
message.update!(status: :failed, external_error: e.message.to_s.truncate(255))
end
end
+7
View File
@@ -22,10 +22,12 @@ Rails.application.routes.draw do
get '/app/accounts/:account_id/settings/inboxes/new/microsoft', to: 'dashboard#index', as: 'app_new_microsoft_inbox'
get '/app/accounts/:account_id/settings/inboxes/new/instagram', to: 'dashboard#index', as: 'app_new_instagram_inbox'
get '/app/accounts/:account_id/settings/inboxes/new/tiktok', to: 'dashboard#index', as: 'app_new_tiktok_inbox'
get '/app/accounts/:account_id/settings/inboxes/new/google_play', to: 'dashboard#index', as: 'app_new_google_play_inbox'
get '/app/accounts/:account_id/settings/inboxes/new/:inbox_id/agents', to: 'dashboard#index', as: 'app_twitter_inbox_agents'
get '/app/accounts/:account_id/settings/inboxes/new/:inbox_id/agents', to: 'dashboard#index', as: 'app_email_inbox_agents'
get '/app/accounts/:account_id/settings/inboxes/new/:inbox_id/agents', to: 'dashboard#index', as: 'app_instagram_inbox_agents'
get '/app/accounts/:account_id/settings/inboxes/new/:inbox_id/agents', to: 'dashboard#index', as: 'app_tiktok_inbox_agents'
get '/app/accounts/:account_id/settings/inboxes/new/:inbox_id/agents', to: 'dashboard#index', as: 'app_google_play_inbox_agents'
get '/app/accounts/:account_id/settings/inboxes/:inbox_id', to: 'dashboard#index', as: 'app_instagram_inbox_settings'
get '/app/accounts/:account_id/settings/inboxes/:inbox_id', to: 'dashboard#index', as: 'app_tiktok_inbox_settings'
get '/app/accounts/:account_id/settings/inboxes/:inbox_id', to: 'dashboard#index', as: 'app_email_inbox_settings'
@@ -318,6 +320,10 @@ Rails.application.routes.draw do
resource :authorization, only: [:create]
end
namespace :google_play do
resource :authorization, only: [:create]
end
namespace :instagram do
resource :authorization, only: [:create]
end
@@ -644,6 +650,7 @@ Rails.application.routes.draw do
get 'microsoft/callback', to: 'microsoft/callbacks#show'
get 'google/callback', to: 'google/callbacks#show'
get 'google_play/callback', to: 'google_play/callbacks#show'
get 'instagram/callback', to: 'instagram/callbacks#show'
get 'tiktok/callback', to: 'tiktok/callbacks#show'
get 'notion/callback', to: 'notion/callbacks#show'
+6
View File
@@ -26,6 +26,12 @@ trigger_imap_email_inboxes_job:
class: 'Inboxes::FetchImapEmailInboxesJob'
queue: scheduled_jobs
# executed every 15 minutes to fetch Google Play Store app reviews
trigger_google_play_review_inboxes_job:
cron: '*/15 * * * *'
class: 'Inboxes::FetchGooglePlayReviewInboxesJob'
queue: scheduled_jobs
# executed daily at 2230 UTC
# which is our lowest traffic time
remove_stale_contact_inboxes_job.rb:
@@ -0,0 +1,13 @@
class CreateChannelGooglePlay < ActiveRecord::Migration[7.1]
def change
create_table :channel_google_play do |t|
t.bigint :account_id, null: false
t.string :app_id, null: false
t.jsonb :provider_config, null: false, default: {}
t.timestamps
end
add_index :channel_google_play, [:account_id, :app_id], unique: true
end
end
+56 -34
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.1].define(version: 2026_05_15_000000) do
ActiveRecord::Schema[7.1].define(version: 2026_05_19_000000) do
# These extensions should be enabled to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
@@ -52,6 +52,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_05_15_000000) do
t.boolean "auto_offline", default: true, null: false
t.bigint "custom_role_id"
t.bigint "agent_capacity_policy_id"
t.integer "status", default: 0, null: false
t.index ["account_id", "user_id"], name: "uniq_user_id_per_account_id", unique: true
t.index ["account_id"], name: "index_account_users_on_account_id"
t.index ["agent_capacity_policy_id"], name: "index_account_users_on_agent_capacity_policy_id"
@@ -71,6 +72,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_05_15_000000) do
t.jsonb "limits", default: {}
t.jsonb "custom_attributes", default: {}
t.integer "status", default: 0
t.integer "contactable_contacts_count", default: 0
t.jsonb "internal_attributes", default: {}, null: false
t.jsonb "settings", default: {}
t.index ["status"], name: "index_accounts_on_status"
@@ -473,8 +475,8 @@ ActiveRecord::Schema[7.1].define(version: 2026_05_15_000000) do
t.boolean "smtp_enable_ssl_tls", default: false
t.jsonb "provider_config", default: {}
t.string "provider"
t.string "imap_authentication", default: "plain"
t.boolean "verified_for_sending", default: false, null: false
t.string "imap_authentication", default: "plain"
t.index ["email"], name: "index_channel_email_on_email", unique: true
t.index ["forward_to_email"], name: "index_channel_email_on_forward_to_email", unique: true
end
@@ -491,6 +493,15 @@ ActiveRecord::Schema[7.1].define(version: 2026_05_15_000000) do
t.index ["page_id"], name: "index_channel_facebook_pages_on_page_id"
end
create_table "channel_google_play", force: :cascade do |t|
t.bigint "account_id", null: false
t.string "app_id", null: false
t.jsonb "provider_config", default: {}, null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["account_id", "app_id"], name: "index_channel_google_play_on_account_id_and_app_id", unique: true
end
create_table "channel_instagram", force: :cascade do |t|
t.string "access_token", null: false
t.datetime "expires_at", null: false
@@ -613,7 +624,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_05_15_000000) do
t.bigint "account_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.integer "contacts_count"
t.integer "contacts_count", default: 0, null: false
t.jsonb "additional_attributes", default: {}
t.jsonb "custom_attributes", default: {}
t.datetime "last_activity_at", precision: nil
@@ -622,7 +633,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_05_15_000000) do
t.index ["name", "account_id"], name: "index_companies_on_name_and_account_id"
end
create_table "contact_inboxes", force: :cascade do |t|
create_table "contact_inboxes", id: :bigint, default: -> { "nextval('contact_inboxes2_id_seq'::regclass)" }, force: :cascade do |t|
t.bigint "contact_id"
t.bigint "inbox_id"
t.text "source_id", null: false
@@ -630,14 +641,14 @@ ActiveRecord::Schema[7.1].define(version: 2026_05_15_000000) do
t.datetime "updated_at", null: false
t.boolean "hmac_verified", default: false
t.string "pubsub_token"
t.index ["contact_id"], name: "index_contact_inboxes_on_contact_id"
t.index ["inbox_id", "source_id"], name: "index_contact_inboxes_on_inbox_id_and_source_id", unique: true
t.index ["inbox_id"], name: "index_contact_inboxes_on_inbox_id"
t.index ["pubsub_token"], name: "index_contact_inboxes_on_pubsub_token", unique: true
t.index ["source_id"], name: "index_contact_inboxes_on_source_id"
t.index ["contact_id"], name: "contact_inboxes2_contact_id_idx"
t.index ["inbox_id", "source_id"], name: "contact_inboxes2_inbox_id_source_id_idx", unique: true
t.index ["inbox_id"], name: "contact_inboxes2_inbox_id_idx"
t.index ["pubsub_token"], name: "contact_inboxes2_pubsub_token_idx", unique: true
t.index ["source_id"], name: "contact_inboxes2_source_id_idx"
end
create_table "contacts", id: :serial, force: :cascade do |t|
create_table "contacts", id: :integer, default: -> { "nextval('contacts2_id_seq'::regclass)" }, force: :cascade do |t|
t.string "name", default: ""
t.string "email"
t.string "phone_number"
@@ -648,25 +659,27 @@ ActiveRecord::Schema[7.1].define(version: 2026_05_15_000000) do
t.string "identifier"
t.jsonb "custom_attributes", default: {}
t.datetime "last_activity_at", precision: nil
t.integer "contact_type", default: 0
t.boolean "resolved", default: false, null: false
t.integer "contact_type", default: 0, null: false
t.string "middle_name", default: ""
t.string "last_name", default: ""
t.string "location", default: ""
t.string "country_code", default: ""
t.boolean "blocked", default: false, null: false
t.bigint "company_id"
t.index "lower((email)::text), account_id", name: "index_contacts_on_lower_email_account_id"
t.index "lower((email)::text), account_id", name: "contacts2_lower_account_id_idx"
t.index ["account_id", "contact_type"], name: "index_contacts_on_account_id_and_contact_type"
t.index ["account_id", "email", "phone_number", "identifier"], name: "index_contacts_on_nonempty_fields", where: "(((email)::text <> ''::text) OR ((phone_number)::text <> ''::text) OR ((identifier)::text <> ''::text))"
t.index ["account_id", "email", "phone_number", "identifier"], name: "contacts2_account_id_email_phone_number_identifier_idx", where: "(((email)::text <> ''::text) OR ((phone_number)::text <> ''::text) OR ((identifier)::text <> ''::text))"
t.index ["account_id", "last_activity_at"], name: "index_contacts_on_account_id_and_last_activity_at", order: { last_activity_at: "DESC NULLS LAST" }
t.index ["account_id"], name: "index_contacts_on_account_id"
t.index ["account_id"], name: "index_resolved_contact_account_id", where: "(((email)::text <> ''::text) OR ((phone_number)::text <> ''::text) OR ((identifier)::text <> ''::text))"
t.index ["account_id", "resolved"], name: "index_contacts_on_account_id_and_resolved"
t.index ["account_id"], name: "contacts2_account_id_idx"
t.index ["account_id"], name: "contacts2_account_id_idx1", where: "(((email)::text <> ''::text) OR ((phone_number)::text <> ''::text) OR ((identifier)::text <> ''::text))"
t.index ["blocked"], name: "index_contacts_on_blocked"
t.index ["company_id"], name: "index_contacts_on_company_id"
t.index ["email", "account_id"], name: "uniq_email_per_account_contact", unique: true
t.index ["identifier", "account_id"], name: "uniq_identifier_per_account_contact", unique: true
t.index ["name", "email", "phone_number", "identifier"], name: "index_contacts_on_name_email_phone_number_identifier", opclass: :gin_trgm_ops, using: :gin
t.index ["phone_number", "account_id"], name: "index_contacts_on_phone_number_and_account_id"
t.index ["email", "account_id"], name: "contacts2_email_account_id_idx", unique: true
t.index ["identifier", "account_id"], name: "contacts2_identifier_account_id_idx", unique: true
t.index ["name", "email", "phone_number", "identifier"], name: "contacts2_name_email_phone_number_identifier_idx", opclass: :gin_trgm_ops, using: :gin
t.index ["phone_number", "account_id"], name: "contacts2_phone_number_account_id_idx"
end
create_table "conversation_participants", force: :cascade do |t|
@@ -694,7 +707,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_05_15_000000) do
t.datetime "agent_last_seen_at", precision: nil
t.jsonb "additional_attributes", default: {}
t.bigint "contact_inbox_id"
t.uuid "uuid", default: -> { "gen_random_uuid()" }, null: false
t.uuid "uuid", default: -> { "public.gen_random_uuid()" }, null: false
t.string "identifier"
t.datetime "last_activity_at", precision: nil, default: -> { "CURRENT_TIMESTAMP" }, null: false
t.bigint "team_id"
@@ -708,6 +721,12 @@ ActiveRecord::Schema[7.1].define(version: 2026_05_15_000000) do
t.datetime "waiting_since"
t.text "cached_label_list"
t.bigint "assignee_agent_bot_id"
t.bigint "last_message_id"
t.bigint "last_incoming_message_id"
t.bigint "last_non_activity_message_id"
t.text "cached_summary"
t.datetime "cached_summary_at"
t.index ["account_id", "created_at", "inbox_id"], name: "index_conversations_on_account_created_inbox"
t.index ["account_id", "display_id"], name: "index_conversations_on_account_id_and_display_id", unique: true
t.index ["account_id", "id"], name: "index_conversations_on_id_and_account_id"
t.index ["account_id", "inbox_id", "status", "assignee_id"], name: "conv_acid_inbid_stat_asgnid_idx"
@@ -719,6 +738,9 @@ ActiveRecord::Schema[7.1].define(version: 2026_05_15_000000) do
t.index ["first_reply_created_at"], name: "index_conversations_on_first_reply_created_at"
t.index ["identifier", "account_id"], name: "index_conversations_on_identifier_and_account_id"
t.index ["inbox_id"], name: "index_conversations_on_inbox_id"
t.index ["last_incoming_message_id"], name: "index_conversations_on_last_incoming_message_id"
t.index ["last_message_id"], name: "index_conversations_on_last_message_id"
t.index ["last_non_activity_message_id"], name: "index_conversations_on_last_non_activity_message_id"
t.index ["priority"], name: "index_conversations_on_priority"
t.index ["status", "account_id"], name: "index_conversations_on_status_and_account_id"
t.index ["status", "priority"], name: "index_conversations_on_status_and_priority"
@@ -785,7 +807,6 @@ ActiveRecord::Schema[7.1].define(version: 2026_05_15_000000) do
t.string "regex_pattern"
t.string "regex_cue"
t.index ["account_id"], name: "index_custom_attribute_definitions_on_account_id"
t.index ["attribute_key", "attribute_model", "account_id"], name: "attribute_key_model_index", unique: true
end
create_table "custom_filters", force: :cascade do |t|
@@ -968,7 +989,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_05_15_000000) do
t.integer "visibility", default: 0
t.bigint "created_by_id"
t.bigint "updated_by_id"
t.jsonb "actions", default: {}, null: false
t.jsonb "actions", default: "{}", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["account_id"], name: "index_macros_on_account_id"
@@ -987,7 +1008,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_05_15_000000) do
t.index ["user_id"], name: "index_mentions_on_user_id"
end
create_table "messages", id: :serial, force: :cascade do |t|
create_table "messages", id: :integer, default: -> { "nextval('messages2_id_seq'::regclass)" }, force: :cascade do |t|
t.text "content"
t.integer "account_id", null: false
t.integer "inbox_id", null: false
@@ -1006,18 +1027,18 @@ ActiveRecord::Schema[7.1].define(version: 2026_05_15_000000) do
t.jsonb "additional_attributes", default: {}
t.text "processed_message_content"
t.jsonb "sentiment", default: {}
t.index "((additional_attributes -> 'campaign_id'::text))", name: "index_messages_on_additional_attributes_campaign_id", using: :gin
t.index "((additional_attributes -> 'campaign_id'::text))", name: "messages2_expr_idx", using: :gin
t.index ["account_id", "content_type", "created_at"], name: "idx_messages_account_content_created"
t.index ["account_id", "created_at", "message_type"], name: "index_messages_on_account_created_type"
t.index ["account_id", "inbox_id"], name: "index_messages_on_account_id_and_inbox_id"
t.index ["account_id"], name: "index_messages_on_account_id"
t.index ["content"], name: "index_messages_on_content", opclass: :gin_trgm_ops, using: :gin
t.index ["conversation_id", "account_id", "message_type", "created_at"], name: "index_messages_on_conversation_account_type_created"
t.index ["conversation_id"], name: "index_messages_on_conversation_id"
t.index ["created_at"], name: "index_messages_on_created_at"
t.index ["inbox_id"], name: "index_messages_on_inbox_id"
t.index ["sender_type", "sender_id"], name: "index_messages_on_sender_type_and_sender_id"
t.index ["source_id"], name: "index_messages_on_source_id"
t.index ["account_id", "inbox_id"], name: "messages2_account_id_inbox_id_idx"
t.index ["account_id"], name: "messages2_account_id_idx"
t.index ["content"], name: "messages2_content_idx", opclass: :gin_trgm_ops, using: :gin
t.index ["conversation_id", "account_id", "message_type", "created_at"], name: "messages2_conversation_id_account_id_message_type_created_a_idx"
t.index ["conversation_id"], name: "messages2_conversation_id_idx"
t.index ["created_at"], name: "messages2_created_at_idx"
t.index ["inbox_id"], name: "messages2_inbox_id_idx"
t.index ["sender_type", "sender_id"], name: "messages2_sender_type_sender_id_idx"
t.index ["source_id"], name: "messages2_source_id_idx"
end
create_table "notes", force: :cascade do |t|
@@ -1149,6 +1170,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_05_15_000000) do
t.float "value_in_business_hours"
t.datetime "event_start_time", precision: nil
t.datetime "event_end_time", precision: nil
t.index ["account_id", "name", "created_at", "inbox_id"], name: "index_reporting_events_on_account_name_created_inbox"
t.index ["account_id", "name", "created_at"], name: "reporting_events__account_id__name__created_at"
t.index ["account_id", "name", "inbox_id", "created_at"], name: "index_reporting_events_for_response_distribution"
t.index ["account_id"], name: "index_reporting_events_on_account_id"
@@ -1282,7 +1304,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_05_15_000000) do
t.text "message_signature"
t.string "otp_secret"
t.integer "consumed_timestep"
t.boolean "otp_required_for_login", default: false
t.boolean "otp_required_for_login", default: false, null: false
t.text "otp_backup_codes"
t.index ["email"], name: "index_users_on_email"
t.index ["otp_required_for_login"], name: "index_users_on_otp_required_for_login"