Feature: Availability Statuses (#874)

Co-authored-by: Pranav Raj S <pranav@thoughtwoot.com>
This commit is contained in:
Sojan Jose
2020-07-04 11:42:47 +05:30
committed by GitHub
parent bd87927576
commit c98907db49
35 changed files with 413 additions and 77 deletions

View File

@@ -1,10 +1,42 @@
class RoomChannel < ApplicationCable::Channel class RoomChannel < ApplicationCable::Channel
def subscribed def subscribed
stream_from params[:pubsub_token] ensure_stream
::OnlineStatusTracker.add_subscription(params[:pubsub_token]) current_user
current_account
update_subscription
end end
def unsubscribed def update_presence
::OnlineStatusTracker.remove_subscription(params[:pubsub_token]) update_subscription
data = { account_id: @current_account.id, users: ::OnlineStatusTracker.get_available_users(@current_account.id) }
data[:contacts] = ::OnlineStatusTracker.get_available_contacts(@current_account.id) if @current_user.is_a? User
ActionCable.server.broadcast(@pubsub_token, { event: 'presence.update', data: data })
end
private
def ensure_stream
@pubsub_token = params[:pubsub_token]
stream_from @pubsub_token
end
def update_subscription
::OnlineStatusTracker.update_presence(@current_account.id, @current_user.class.name, @current_user.id)
end
def current_user
@current_user ||= if params[:user_id].blank?
Contact.find_by!(pubsub_token: @pubsub_token)
else
User.find_by!(pubsub_token: @pubsub_token, id: params[:user_id])
end
end
def current_account
@current_account ||= if @current_user.is_a? Contact
@current_user.account
else
@current_user.accounts.find(params[:account_id])
end
end end
end end

View File

@@ -16,6 +16,6 @@ class Api::V1::ProfilesController < Api::BaseController
end end
def profile_params def profile_params
params.require(:profile).permit(:email, :name, :password, :password_confirmation, :avatar) params.require(:profile).permit(:email, :name, :password, :password_confirmation, :avatar, :availability)
end end
end end

View File

@@ -118,21 +118,18 @@ export default {
return axios.post(urlData.url, { email }); return axios.post(urlData.url, { email });
}, },
profileUpdate({ name, email, password, password_confirmation, avatar }) { profileUpdate({ password, password_confirmation, ...profileAttributes }) {
const formData = new FormData(); const formData = new FormData();
if (name) { Object.keys(profileAttributes).forEach(key => {
formData.append('profile[name]', name); const value = profileAttributes[key];
} if (value) {
if (email) { formData.append(`profile[${key}]`, value);
formData.append('profile[email]', email); }
} });
if (password && password_confirmation) { if (password && password_confirmation) {
formData.append('profile[password]', password); formData.append('profile[password]', password);
formData.append('profile[password_confirmation]', password_confirmation); formData.append('profile[password_confirmation]', password_confirmation);
} }
if (avatar) {
formData.append('profile[avatar]', avatar);
}
return axios.put(endPoints('profileUpdate').url, formData); return axios.put(endPoints('profileUpdate').url, formData);
}, },
}; };

View File

@@ -56,7 +56,11 @@
</div> </div>
</transition> </transition>
<div class="current-user" @click.prevent="showOptions()"> <div class="current-user" @click.prevent="showOptions()">
<thumbnail :src="currentUser.avatar_url" :username="currentUser.name" /> <thumbnail
:src="currentUser.avatar_url"
:username="currentUser.name"
:status="currentUser.availability_status"
/>
<div class="current-user--data"> <div class="current-user--data">
<h3 class="current-user--name"> <h3 class="current-user--name">
{{ currentUser.name }} {{ currentUser.name }}

View File

@@ -21,11 +21,6 @@
:style="badgeStyle" :style="badgeStyle"
src="~dashboard/assets/images/fb-badge.png" src="~dashboard/assets/images/fb-badge.png"
/> />
<div
v-else-if="status === 'online'"
class="source-badge user--online"
:style="statusStyle"
></div>
<img <img
v-if="badge === 'Channel::TwitterProfile'" v-if="badge === 'Channel::TwitterProfile'"
id="badge" id="badge"
@@ -33,7 +28,6 @@
:style="badgeStyle" :style="badgeStyle"
src="~dashboard/assets/images/twitter-badge.png" src="~dashboard/assets/images/twitter-badge.png"
/> />
<img <img
v-if="badge === 'Channel::TwilioSms'" v-if="badge === 'Channel::TwilioSms'"
id="badge" id="badge"
@@ -41,6 +35,11 @@
:style="badgeStyle" :style="badgeStyle"
src="~dashboard/assets/images/channels/whatsapp.png" src="~dashboard/assets/images/channels/whatsapp.png"
/> />
<div
v-if="showStatusIndicator"
:class="`source-badge user-online-status user-online-status--${status}`"
:style="statusStyle"
/>
</div> </div>
</template> </template>
<script> <script>
@@ -89,6 +88,9 @@ export default {
}; };
}, },
computed: { computed: {
showStatusIndicator() {
return this.status === 'online' || this.status === 'busy';
},
avatarSize() { avatarSize() {
return Number(this.size.replace(/\D+/g, '')); return Number(this.size.replace(/\D+/g, ''));
}, },
@@ -150,8 +152,7 @@ export default {
width: $space-slab; width: $space-slab;
} }
.user--online { .user-online-status {
background: $success-color;
border-radius: 50%; border-radius: 50%;
bottom: $space-micro; bottom: $space-micro;
@@ -159,5 +160,13 @@ export default {
content: ' '; content: ' ';
} }
} }
.user-online-status--online {
background: $success-color;
}
.user-online-status--busy {
background: $warning-color;
}
} }
</style> </style>

View File

@@ -10,6 +10,7 @@
:badge="currentContact.channel" :badge="currentContact.channel"
class="columns" class="columns"
:username="currentContact.name" :username="currentContact.name"
:status="currentContact.availability_status"
size="40px" size="40px"
/> />
<div class="conversation--details columns"> <div class="conversation--details columns">

View File

@@ -6,6 +6,7 @@
size="40px" size="40px"
:badge="currentContact.channel" :badge="currentContact.channel"
:username="currentContact.name" :username="currentContact.name"
:status="currentContact.availability_status"
/> />
<div class="user--profile__meta"> <div class="user--profile__meta">
<h3 v-if="!isContactPanelOpen" class="user--name text-truncate"> <h3 v-if="!isContactPanelOpen" class="user--name text-truncate">

View File

@@ -18,6 +18,7 @@ class ActionCableConnector extends BaseActionCableConnector {
'conversation.typing_on': this.onTypingOn, 'conversation.typing_on': this.onTypingOn,
'conversation.typing_off': this.onTypingOff, 'conversation.typing_off': this.onTypingOff,
'conversation.contact_changed': this.onConversationContactChange, 'conversation.contact_changed': this.onConversationContactChange,
'presence.update': this.onPresenceUpdate,
}; };
} }
@@ -29,6 +30,11 @@ class ActionCableConnector extends BaseActionCableConnector {
this.app.$store.dispatch('updateMessage', data); this.app.$store.dispatch('updateMessage', data);
}; };
onPresenceUpdate = data => {
this.app.$store.dispatch('contacts/updatePresence', data.contacts);
this.app.$store.dispatch('agents/updatePresence', data.users);
};
onConversationContactChange = payload => { onConversationContactChange = payload => {
const { meta = {}, id: conversationId } = payload; const { meta = {}, id: conversationId } = payload;
const { sender } = meta || {}; const { sender } = meta || {};

View File

@@ -48,6 +48,23 @@
"ERROR": "Please enter a valid name", "ERROR": "Please enter a valid name",
"PLACEHOLDER": "Please enter your name, this would be displayed in conversations" "PLACEHOLDER": "Please enter your name, this would be displayed in conversations"
}, },
"AVAILABILITY": {
"LABEL": "Availability",
"STATUSES_LIST": [
{
"value": "online",
"label": "Online"
},
{
"value": "busy",
"label": "Busy"
},
{
"value": "offline",
"label": "Offline"
}
]
},
"EMAIL": { "EMAIL": {
"LABEL": "Your email address", "LABEL": "Your email address",
"ERROR": "Please enter a valid email address", "ERROR": "Please enter a valid email address",

View File

@@ -38,6 +38,19 @@
{{ $t('PROFILE_SETTINGS.FORM.EMAIL.ERROR') }} {{ $t('PROFILE_SETTINGS.FORM.EMAIL.ERROR') }}
</span> </span>
</label> </label>
<label>
{{ $t('PROFILE_SETTINGS.FORM.AVAILABILITY.LABEL') }}
<select v-model="availability">
<option
v-for="status in availabilityStatuses"
:key="status.key"
class="text-capitalize"
:value="status.value"
>
{{ status.label }}
</option>
</select>
</label>
</div> </div>
</div> </div>
<div class="profile--settings--row row"> <div class="profile--settings--row row">
@@ -99,16 +112,17 @@
</template> </template>
<script> <script>
/* global bus */
import { required, minLength, email } from 'vuelidate/lib/validators'; import { required, minLength, email } from 'vuelidate/lib/validators';
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import { clearCookiesOnLogout } from '../../../../store/utils/api'; import { clearCookiesOnLogout } from '../../../../store/utils/api';
import NotificationSettings from './NotificationSettings'; import NotificationSettings from './NotificationSettings';
import alertMixin from 'shared/mixins/alertMixin';
export default { export default {
components: { components: {
NotificationSettings, NotificationSettings,
}, },
mixin: [alertMixin],
data() { data() {
return { return {
avatarFile: '', avatarFile: '',
@@ -117,7 +131,11 @@ export default {
email: '', email: '',
password: '', password: '',
passwordConfirmation: '', passwordConfirmation: '',
availability: 'online',
isUpdating: false, isUpdating: false,
availabilityStatuses: this.$t(
'PROFILE_SETTINGS.FORM.AVAILABILITY.STATUSES_LIST'
),
}; };
}, },
validations: { validations: {
@@ -164,11 +182,12 @@ export default {
this.name = this.currentUser.name; this.name = this.currentUser.name;
this.email = this.currentUser.email; this.email = this.currentUser.email;
this.avatarUrl = this.currentUser.avatar_url; this.avatarUrl = this.currentUser.avatar_url;
this.availability = this.currentUser.availability_status;
}, },
async updateUser() { async updateUser() {
this.$v.$touch(); this.$v.$touch();
if (this.$v.$invalid) { if (this.$v.$invalid) {
bus.$emit('newToastMessage', this.$t('PROFILE_SETTINGS.FORM.ERROR')); this.showAlert(this.$t('PROFILE_SETTINGS.FORM.ERROR'));
return; return;
} }
this.isUpdating = true; this.isUpdating = true;
@@ -179,15 +198,13 @@ export default {
email: this.email, email: this.email,
avatar: this.avatarFile, avatar: this.avatarFile,
password: this.password, password: this.password,
availability: this.availability,
password_confirmation: this.passwordConfirmation, password_confirmation: this.passwordConfirmation,
}); });
this.isUpdating = false; this.isUpdating = false;
if (hasEmailChanged) { if (hasEmailChanged) {
clearCookiesOnLogout(); clearCookiesOnLogout();
bus.$emit( this.showAlert(this.$t('PROFILE_SETTINGS.AFTER_EMAIL_CHANGED'));
'newToastMessage',
this.$t('PROFILE_SETTINGS.AFTER_EMAIL_CHANGED')
);
} }
} catch (error) { } catch (error) {
this.isUpdating = false; this.isUpdating = false;

View File

@@ -57,6 +57,13 @@ export const actions = {
throw new Error(error); throw new Error(error);
} }
}, },
updatePresence: async ({ commit }, data) => {
commit(types.default.SET_AGENT_UPDATING_STATUS, true);
commit(types.default.UPDATE_AGENTS_PRESENCE, data);
commit(types.default.SET_AGENT_UPDATING_STATUS, false);
},
delete: async ({ commit }, agentId) => { delete: async ({ commit }, agentId) => {
commit(types.default.SET_AGENT_DELETING_STATUS, true); commit(types.default.SET_AGENT_DELETING_STATUS, true);
try { try {
@@ -88,6 +95,7 @@ export const mutations = {
[types.default.ADD_AGENT]: MutationHelpers.create, [types.default.ADD_AGENT]: MutationHelpers.create,
[types.default.EDIT_AGENT]: MutationHelpers.update, [types.default.EDIT_AGENT]: MutationHelpers.update,
[types.default.DELETE_AGENT]: MutationHelpers.destroy, [types.default.DELETE_AGENT]: MutationHelpers.destroy,
[types.default.UPDATE_AGENTS_PRESENCE]: MutationHelpers.updatePresence,
}; };
export default { export default {

View File

@@ -59,6 +59,10 @@ export const actions = {
throw new Error(error); throw new Error(error);
} }
}, },
updatePresence: ({ commit }, data) => {
commit(types.default.UPDATE_CONTACTS_PRESENCE, data);
},
}; };
export const mutations = { export const mutations = {
@@ -88,6 +92,21 @@ export const mutations = {
[types.default.EDIT_CONTACT]: ($state, data) => { [types.default.EDIT_CONTACT]: ($state, data) => {
Vue.set($state.records, data.id, data); Vue.set($state.records, data.id, data);
}, },
[types.default.UPDATE_CONTACTS_PRESENCE]: ($state, data) => {
Object.values($state.records).forEach(element => {
const availabilityStatus = data[element.id];
if (availabilityStatus) {
Vue.set(
$state.records[element.id],
'availability_status',
availabilityStatus
);
} else {
Vue.delete($state.records[element.id], 'availability_status');
}
});
},
}; };
export default { export default {

View File

@@ -93,4 +93,16 @@ describe('#actions', () => {
]); ]);
}); });
}); });
describe('#updatePresence', () => {
it('sends correct actions if API is success', async () => {
const data = { users: { 1: 'online' }, contacts: { 2: 'online' } };
actions.updatePresence({ commit }, data);
expect(commit.mock.calls).toEqual([
[types.default.SET_AGENT_UPDATING_STATUS, true],
[types.default.UPDATE_AGENTS_PRESENCE, data],
[types.default.SET_AGENT_UPDATING_STATUS, false],
]);
});
});
}); });

View File

@@ -60,4 +60,40 @@ describe('#mutations', () => {
expect(state.records).toEqual([]); expect(state.records).toEqual([]);
}); });
}); });
describe('#UPDATE_AGENTS_PRESENCE', () => {
it('updates agent presence', () => {
const state = {
records: [
{
id: 1,
name: 'Agent1',
email: 'agent1@chatwoot.com',
availability_status: 'offline',
},
{
id: 2,
name: 'Agent1',
email: 'agent1@chatwoot.com',
availability_status: 'online',
},
],
};
mutations[types.default.UPDATE_AGENTS_PRESENCE](state, { '1': 'busy' });
expect(state.records).toEqual([
{
id: 1,
name: 'Agent1',
email: 'agent1@chatwoot.com',
availability_status: 'busy',
},
{
id: 2,
name: 'Agent1',
email: 'agent1@chatwoot.com',
},
]);
});
});
}); });

View File

@@ -59,6 +59,7 @@ export default {
ADD_AGENT: 'ADD_AGENT', ADD_AGENT: 'ADD_AGENT',
EDIT_AGENT: 'EDIT_AGENT', EDIT_AGENT: 'EDIT_AGENT',
DELETE_AGENT: 'DELETE_AGENT', DELETE_AGENT: 'DELETE_AGENT',
UPDATE_AGENTS_PRESENCE: 'UPDATE_AGENTS_PRESENCE',
// Canned Response // Canned Response
SET_CANNED_UI_FLAG: 'SET_CANNED_UI_FLAG', SET_CANNED_UI_FLAG: 'SET_CANNED_UI_FLAG',
@@ -91,6 +92,7 @@ export default {
SET_CONTACT_ITEM: 'SET_CONTACT_ITEM', SET_CONTACT_ITEM: 'SET_CONTACT_ITEM',
SET_CONTACTS: 'SET_CONTACTS', SET_CONTACTS: 'SET_CONTACTS',
EDIT_CONTACT: 'EDIT_CONTACT', EDIT_CONTACT: 'EDIT_CONTACT',
UPDATE_CONTACTS_PRESENCE: 'UPDATE_CONTACTS_PRESENCE',
// Contact Conversation // Contact Conversation
SET_CONTACT_CONVERSATIONS_UI_FLAG: 'SET_CONTACT_CONVERSATIONS_UI_FLAG', SET_CONTACT_CONVERSATIONS_UI_FLAG: 'SET_CONTACT_CONVERSATIONS_UI_FLAG',

View File

@@ -1,20 +1,31 @@
import { createConsumer } from '@rails/actioncable'; import { createConsumer } from '@rails/actioncable';
const PRESENCE_INTERVAL = 60000;
class BaseActionCableConnector { class BaseActionCableConnector {
constructor(app, pubsubToken) { constructor(app, pubsubToken) {
this.consumer = createConsumer(); this.consumer = createConsumer();
this.consumer.subscriptions.create( this.subscription = this.consumer.subscriptions.create(
{ {
channel: 'RoomChannel', channel: 'RoomChannel',
pubsub_token: pubsubToken, pubsub_token: pubsubToken,
account_id: app.$store.getters.getCurrentAccountId,
user_id: app.$store.getters.getCurrentUserID,
}, },
{ {
updatePresence() {
this.perform('update_presence');
},
received: this.onReceived, received: this.onReceived,
} }
); );
this.app = app; this.app = app;
this.events = {}; this.events = {};
this.isAValidEvent = () => true; this.isAValidEvent = () => true;
setInterval(() => {
this.subscription.updatePresence();
}, PRESENCE_INTERVAL);
} }
disconnect() { disconnect() {

View File

@@ -34,6 +34,17 @@ export const updateAttributes = (state, data) => {
}); });
}; };
export const updatePresence = (state, data) => {
state.records.forEach((element, index) => {
const availabilityStatus = data[element.id];
if (availabilityStatus) {
Vue.set(state.records[index], 'availability_status', availabilityStatus);
} else {
Vue.delete(state.records[index], 'availability_status');
}
});
};
export const destroy = (state, id) => { export const destroy = (state, id) => {
state.records = state.records.filter(record => record.id !== id); state.records = state.records.filter(record => record.id !== id);
}; };

View File

@@ -10,6 +10,7 @@ class ActionCableConnector extends BaseActionCableConnector {
'conversation.typing_off': this.onTypingOff, 'conversation.typing_off': this.onTypingOff,
'conversation.resolved': this.onStatusChange, 'conversation.resolved': this.onStatusChange,
'conversation.opened': this.onStatusChange, 'conversation.opened': this.onStatusChange,
'presence.update': this.onPresenceUpdate,
}; };
} }
@@ -25,6 +26,10 @@ class ActionCableConnector extends BaseActionCableConnector {
this.app.$store.dispatch('conversation/updateMessage', data); this.app.$store.dispatch('conversation/updateMessage', data);
}; };
onPresenceUpdate = data => {
this.app.$store.dispatch('agent/updatePresence', data.users);
};
onTypingOn = () => { onTypingOn = () => {
this.clearTimer(); this.clearTimer();
this.app.$store.dispatch('conversation/toggleAgentTyping', { this.app.$store.dispatch('conversation/toggleAgentTyping', {

View File

@@ -1,5 +1,6 @@
import Vue from 'vue'; import Vue from 'vue';
import { getAvailableAgents } from 'widget/api/agent'; import { getAvailableAgents } from 'widget/api/agent';
import * as MutationHelpers from 'shared/helpers/vuex/mutationHelpers';
const state = { const state = {
records: [], records: [],
@@ -27,12 +28,16 @@ export const actions = {
commit('setHasFetched', true); commit('setHasFetched', true);
} }
}, },
updatePresence: async ({ commit }, data) => {
commit('updatePresence', data);
},
}; };
export const mutations = { export const mutations = {
setAgents($state, data) { setAgents($state, data) {
Vue.set($state, 'records', data); Vue.set($state, 'records', data);
}, },
updatePresence: MutationHelpers.updatePresence,
setError($state, value) { setError($state, value) {
Vue.set($state.uiFlags, 'isError', value); Vue.set($state.uiFlags, 'isError', value);
}, },

View File

@@ -25,4 +25,9 @@ describe('#actions', () => {
]); ]);
}); });
}); });
describe('#updatePresence', () => {
actions.updatePresence({ commit }, { 1: 'online' });
expect(commit.mock.calls).toEqual([['updatePresence', { 1: 'online' }]]);
});
}); });

View File

@@ -1,5 +1,5 @@
import { mutations } from '../../agent'; import { mutations } from '../../agent';
import agents from './data'; import { agents } from './data';
describe('#mutations', () => { describe('#mutations', () => {
describe('#setAgents', () => { describe('#setAgents', () => {
@@ -25,4 +25,35 @@ describe('#mutations', () => {
expect(state.uiFlags.hasFetched).toEqual(true); expect(state.uiFlags.hasFetched).toEqual(true);
}); });
}); });
describe('#updatePresence', () => {
it('updates agent presence', () => {
const state = { records: agents };
mutations.updatePresence(state, { 1: 'busy', 2: 'online' });
expect(state.records).toEqual([
{
id: 1,
name: 'John',
avatar_url: '',
availability_status: 'busy',
},
{
id: 2,
name: 'Xavier',
avatar_url: '',
availability_status: 'online',
},
{
id: 3,
name: 'Pranav',
avatar_url: '',
},
{
id: 4,
name: 'Nithin',
avatar_url: '',
},
]);
});
});
}); });

View File

@@ -1,11 +1,20 @@
module AvailabilityStatusable module AvailabilityStatusable
extend ActiveSupport::Concern extend ActiveSupport::Concern
def online? def online_presence?
::OnlineStatusTracker.subscription_count(pubsub_token) != 0 ::OnlineStatusTracker.get_presence(availability_account_id, self.class.name, id)
end end
def availability_status def availability_status
online? ? 'online' : 'offline' return 'offline' unless online_presence?
return 'online' if is_a? Contact
::OnlineStatusTracker.get_status(availability_account_id, id) || 'online'
end
def availability_account_id
return account_id if is_a? Contact
Current.account.present? ? Current.account.id : accounts.first.id
end end
end end

View File

@@ -3,6 +3,7 @@
# Table name: users # Table name: users
# #
# id :integer not null, primary key # id :integer not null, primary key
# availability :integer default(0)
# confirmation_sent_at :datetime # confirmation_sent_at :datetime
# confirmation_token :string # confirmation_token :string
# confirmed_at :datetime # confirmed_at :datetime
@@ -53,6 +54,8 @@ class User < ApplicationRecord
:validatable, :validatable,
:confirmable :confirmable
enum availability: { online: 0, offline: 1, busy: 2 }
# The validation below has been commented out as it does not # The validation below has been commented out as it does not
# work because :validatable in devise overrides this. # work because :validatable in devise overrides this.
# validates_uniqueness_of :email, scope: :account_id # validates_uniqueness_of :email, scope: :account_id
@@ -75,6 +78,7 @@ class User < ApplicationRecord
before_validation :set_password_and_uid, on: :create before_validation :set_password_and_uid, on: :create
after_create :create_access_token after_create :create_access_token
after_save :update_presence_in_redis, if: :saved_change_to_availability?
def send_devise_notification(notification, *args) def send_devise_notification(notification, *args)
devise_mailer.send(notification, self, *args).deliver_later devise_mailer.send(notification, self, *args).deliver_later
@@ -126,7 +130,8 @@ class User < ApplicationRecord
id: id, id: id,
name: name, name: name,
avatar_url: avatar_url, avatar_url: avatar_url,
type: 'user' type: 'user',
availability_status: availability_status
} }
end end
@@ -138,4 +143,12 @@ class User < ApplicationRecord
type: 'user' type: 'user'
} }
end end
private
def update_presence_in_redis
accounts.each do |account|
OnlineStatusTracker.set_status(account.id, id, availability)
end
end
end end

View File

@@ -1,10 +1,5 @@
json.payload do json.payload do
json.array! @contacts do |contact| json.array! @contacts do |contact|
json.id contact.id json.partial! 'api/v1/models/contact.json.jbuilder', resource: contact
json.name contact.name
json.email contact.email
json.phone_number contact.phone_number
json.thumbnail contact.avatar_url
json.additional_attributes contact.additional_attributes
end end
end end

View File

@@ -1,9 +1,3 @@
json.payload do json.payload do
json.availability_status @contact.availability_status json.partial! 'api/v1/models/contact.json.jbuilder', resource: @contact
json.email @contact.email
json.id @contact.id
json.name @contact.name
json.phone_number @contact.phone_number
json.thumbnail @contact.avatar_url
json.additional_attributes @contact.additional_attributes
end end

View File

@@ -1,8 +1,3 @@
json.payload do json.payload do
json.id @contact.id json.partial! 'api/v1/models/contact.json.jbuilder', resource: @contact
json.name @contact.name
json.email @contact.email
json.phone_number @contact.phone_number
json.thumbnail @contact.avatar_url
json.additional_attributes @contact.additional_attributes
end end

View File

@@ -0,0 +1,7 @@
json.additional_attributes resource.additional_attributes
json.availability_status resource.availability_status
json.email resource.email
json.id resource.id
json.name resource.name
json.phone_number resource.phone_number
json.thumbnail resource.avatar_url

View File

@@ -11,6 +11,7 @@ json.inviter_id resource.active_account_user&.inviter_id
json.confirmed resource.confirmed? json.confirmed resource.confirmed?
json.avatar_url resource.avatar_url json.avatar_url resource.avatar_url
json.access_token resource.access_token.token json.access_token resource.access_token.token
json.availability_status resource.availability_status
json.accounts do json.accounts do
json.array! resource.account_users do |account_user| json.array! resource.account_users do |account_user|
json.id account_user.account_id json.id account_user.account_id

View File

@@ -1,17 +1,18 @@
development: default: &default
adapter: redis adapter: redis
url: <%= ENV.fetch('REDIS_URL', 'redis://127.0.0.1:6379') %> url: <%= ENV.fetch('REDIS_URL', 'redis://127.0.0.1:6379') %>
password: <%= ENV.fetch('REDIS_PASSWORD', nil).presence %> password: <%= ENV.fetch('REDIS_PASSWORD', nil).presence %>
channel_prefix: <%= "chatwoot_#{Rails.env}_action_cable" %>
development:
<<: *default
test: test:
adapter: test adapter: test
channel_prefix: <%= "chatwoot_#{Rails.env}_action_cable" %>
staging: staging:
adapter: redis <<: *default
url: <%= ENV.fetch('REDIS_URL', 'redis://127.0.0.1:6379') %>
password: <%= ENV.fetch('REDIS_PASSWORD', nil).presence %>
production: production:
adapter: redis <<: *default
url: <%= ENV.fetch('REDIS_URL', 'redis://127.0.0.1:6379') %>
password: <%= ENV.fetch('REDIS_PASSWORD', nil).presence %>

View File

@@ -0,0 +1,5 @@
class AddAvailabilityToUser < ActiveRecord::Migration[6.0]
def change
add_column :users, :availability, :integer, default: 0
end
end

View File

@@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 2020_06_25_154254) do ActiveRecord::Schema.define(version: 2020_06_29_122646) do
# These are extensions that must be enabled in order to support this database # These are extensions that must be enabled in order to support this database
enable_extension "pg_stat_statements" enable_extension "pg_stat_statements"
@@ -413,6 +413,7 @@ ActiveRecord::Schema.define(version: 2020_06_25_154254) do
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.string "pubsub_token" t.string "pubsub_token"
t.integer "availability", default: 0
t.index ["email"], name: "index_users_on_email" t.index ["email"], name: "index_users_on_email"
t.index ["pubsub_token"], name: "index_users_on_pubsub_token", unique: true t.index ["pubsub_token"], name: "index_users_on_pubsub_token", unique: true
t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true

View File

@@ -1,19 +1,50 @@
module OnlineStatusTracker module OnlineStatusTracker
def self.add_subscription(channel_id) PRESENCE_DURATION = 60.seconds
count = subscription_count(channel_id)
::Redis::Alfred.setex(channel_id, count + 1) # presence : sorted set with timestamp as the score & object id as value
# obj_type: Contact | User
def self.update_presence(account_id, obj_type, obj_id)
::Redis::Alfred.zadd(presence_key(account_id, obj_type), Time.now.to_i, obj_id)
end end
def self.remove_subscription(channel_id) def self.get_presence(account_id, obj_type, obj_id)
count = subscription_count(channel_id) connected_time = ::Redis::Alfred.zscore(presence_key(account_id, obj_type), obj_id)
if count == 1 connected_time && connected_time > (Time.zone.now - PRESENCE_DURATION).to_i
::Redis::Alfred.delete(channel_id) end
elsif count != 0
::Redis::Alfred.setex(channel_id, count - 1) def self.presence_key(account_id, type)
case type
when 'Contact'
Redis::Alfred::ONLINE_PRESENCE_CONTACTS % account_id
else
Redis::Alfred::ONLINE_PRESENCE_USERS % account_id
end end
end end
def self.subscription_count(channel_id) # online status : online | busy | offline
::Redis::Alfred.get(channel_id).to_i # redis hash with obj_id key && status as value
def self.set_status(account_id, user_id, status)
::Redis::Alfred.hset(status_key(account_id), user_id, status)
end
def self.get_status(account_id, user_id)
::Redis::Alfred.hget(status_key(account_id), user_id)
end
def self.status_key(account_id)
Redis::Alfred::ONLINE_STATUS % account_id
end
def self.get_available_contacts(account_id)
contact_ids = ::Redis::Alfred.zrangebyscore(presence_key(account_id, 'Contact'), (Time.zone.now - PRESENCE_DURATION).to_i, Time.now.to_i)
contact_ids.index_with { |_id| 'online' }
end
def self.get_available_users(account_id)
user_ids = ::Redis::Alfred.zrangebyscore(presence_key(account_id, 'User'), (Time.zone.now - PRESENCE_DURATION).to_i, Time.now.to_i)
user_availabilities = ::Redis::Alfred.hmget(status_key(account_id), user_ids)
user_ids.map.with_index { |id, index| [id, (user_availabilities[index] || 'online')] }.to_h
end end
end end

View File

@@ -1,6 +1,13 @@
module Redis::Alfred module Redis::Alfred
CONVERSATION_MAILER_KEY = 'CONVERSATION::%d'.freeze CONVERSATION_MAILER_KEY = 'CONVERSATION::%d'.freeze
# hash containing user_id key and status as value ONLINE_STATUS::%accountid
ONLINE_STATUS = 'ONLINE_STATUS::%s'.freeze
# sorted set storing online presense of account contacts : ONLINE_PRESENCE::%accountid::CONTACTS
ONLINE_PRESENCE_CONTACTS = 'ONLINE_PRESENCE::%s::CONTACTS'.freeze
# sorted set storing online presense of account users : ONLINE_PRESENCE::%accountid::USERS
ONLINE_PRESENCE_USERS = 'ONLINE_PRESENCE::%s::USERS'.freeze
class << self class << self
def rpoplpush(source, destination) def rpoplpush(source, destination)
$alfred.rpoplpush(source, destination) $alfred.rpoplpush(source, destination)
@@ -22,8 +29,46 @@ module Redis::Alfred
$alfred.setex(key, expiry, value) $alfred.setex(key, expiry, value)
end end
def set(key, value)
$alfred.set(key, value)
end
def get(key) def get(key)
$alfred.get(key) $alfred.get(key)
end end
# hash operation
# add a key value to redis hash
def hset(key, field, value)
$alfred.hset(key, field, value)
end
# get value from redis hash
def hget(key, field)
$alfred.hget(key, field)
end
# get values of multiple keys from redis hash
def hmget(key, fields)
$alfred.hmget(key, *fields)
end
# sorted set functions
# add score and value for a key
def zadd(key, score, value)
$alfred.zadd(key, score, value)
end
# get score of a value for key
def zscore(key, value)
$alfred.zscore(key, value)
end
# get values by score
def zrangebyscore(key, range_start, range_end)
$alfred.zrangebyscore(key, range_start, range_end)
end
end end
end end

View File

@@ -1,15 +1,15 @@
require 'rails_helper' require 'rails_helper'
RSpec.describe RoomChannel, type: :channel do RSpec.describe RoomChannel, type: :channel do
let!(:user) { create(:user) } let!(:contact) { create(:contact) }
before do before do
stub_connection stub_connection
end end
it 'subscribes to a stream when pubsub_token is provided' do it 'subscribes to a stream when pubsub_token is provided' do
subscribe(pubsub_token: user.uid) subscribe(pubsub_token: contact.pubsub_token)
expect(subscription).to be_confirmed expect(subscription).to be_confirmed
expect(subscription).to have_stream_for(user.uid) expect(subscription).to have_stream_for(contact.pubsub_token)
end end
end end

View File

@@ -77,6 +77,16 @@ RSpec.describe 'Profile API', type: :request do
agent.reload agent.reload
expect(agent.avatar.attached?).to eq(true) expect(agent.avatar.attached?).to eq(true)
end end
it 'updates the availability status' do
put '/api/v1/profile',
params: { profile: { availability: 'offline' } },
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(::OnlineStatusTracker.get_status(account.id, agent.id)).to eq('offline')
end
end end
end end
end end