Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf7066601c | ||
|
|
94daf26ead | ||
|
|
0846298b53 | ||
|
|
7422b656cd | ||
|
|
39a93db007 | ||
|
|
c44609420d |
@@ -89,7 +89,7 @@ gem 'rails-i18n', '~> 7.0'
|
||||
# two-factor authentication
|
||||
gem 'devise-two-factor', '>= 5.0.0'
|
||||
# authorization
|
||||
gem 'jwt'
|
||||
gem 'jwt', '~> 2.10', '>= 2.10.3'
|
||||
gem 'pundit'
|
||||
|
||||
# super admin
|
||||
|
||||
+3
-3
@@ -301,7 +301,7 @@ GEM
|
||||
railties (>= 5.0.0)
|
||||
faker (3.2.0)
|
||||
i18n (>= 1.8.11, < 2)
|
||||
faraday (2.14.1)
|
||||
faraday (2.14.2)
|
||||
faraday-net_http (>= 2.0, < 3.5)
|
||||
json
|
||||
logger
|
||||
@@ -491,7 +491,7 @@ GEM
|
||||
judoscale-sidekiq (1.8.2)
|
||||
judoscale-ruby (= 1.8.2)
|
||||
sidekiq (>= 5.0)
|
||||
jwt (2.10.1)
|
||||
jwt (2.10.3)
|
||||
base64
|
||||
kaminari (1.2.2)
|
||||
activesupport (>= 4.1.0)
|
||||
@@ -1102,7 +1102,7 @@ DEPENDENCIES
|
||||
json_schemer
|
||||
judoscale-rails
|
||||
judoscale-sidekiq
|
||||
jwt
|
||||
jwt (~> 2.10, >= 2.10.3)
|
||||
kaminari
|
||||
koala
|
||||
letter_opener
|
||||
|
||||
@@ -16,7 +16,6 @@ const props = defineProps({
|
||||
name: { type: String, default: '' },
|
||||
email: { type: String, default: '' },
|
||||
additionalAttributes: { type: Object, default: () => ({}) },
|
||||
countryCode: { type: String, default: '' },
|
||||
phoneNumber: { type: String, default: '' },
|
||||
thumbnail: { type: String, default: '' },
|
||||
availabilityStatus: { type: String, default: null },
|
||||
@@ -43,7 +42,6 @@ const getInitialContactData = () => ({
|
||||
name: props.name,
|
||||
email: props.email,
|
||||
phoneNumber: props.phoneNumber,
|
||||
countryCode: props.countryCode,
|
||||
additionalAttributes: props.additionalAttributes,
|
||||
});
|
||||
|
||||
@@ -60,13 +58,12 @@ const countriesMap = computed(() => {
|
||||
|
||||
const countryDetails = computed(() => {
|
||||
const attributes = props.additionalAttributes || {};
|
||||
const { country, countryCode: additionalCountryCode, city } = attributes;
|
||||
const countryCode = props.countryCode || additionalCountryCode;
|
||||
const { country, countryCode, city } = attributes;
|
||||
|
||||
if (!country && !countryCode) return null;
|
||||
|
||||
const activeCountry =
|
||||
countriesMap.value[countryCode] || countriesMap.value[country];
|
||||
countriesMap.value[country] || countriesMap.value[countryCode];
|
||||
|
||||
if (!activeCountry) return null;
|
||||
|
||||
|
||||
@@ -96,7 +96,6 @@ const prepareStateBasedOnProps = () => {
|
||||
name = '',
|
||||
email: emailAddress,
|
||||
phoneNumber,
|
||||
countryCode: contactCountryCode = '',
|
||||
additionalAttributes = {},
|
||||
} = props.contactData || {};
|
||||
const { firstName, lastName } = splitName(name || '');
|
||||
@@ -123,7 +122,7 @@ const prepareStateBasedOnProps = () => {
|
||||
additionalAttributes: {
|
||||
description,
|
||||
companyName,
|
||||
countryCode: contactCountryCode || countryCode,
|
||||
countryCode,
|
||||
country,
|
||||
city,
|
||||
socialProfiles: {
|
||||
|
||||
@@ -94,7 +94,6 @@ const handleAvatarHover = (id, isHovered) => {
|
||||
:email="contact.email"
|
||||
:thumbnail="contact.thumbnail"
|
||||
:phone-number="contact.phoneNumber"
|
||||
:country-code="contact.countryCode"
|
||||
:additional-attributes="contact.additionalAttributes"
|
||||
:availability-status="contact.availabilityStatus"
|
||||
:is-expanded="expandedCardId === contact.id"
|
||||
|
||||
@@ -78,26 +78,27 @@ function changeAvailabilityStatus(availability) {
|
||||
<template>
|
||||
<DropdownSection class="[&>ul]:overflow-visible">
|
||||
<div class="grid gap-0">
|
||||
<DropdownItem preserve-open>
|
||||
<div class="flex-grow flex items-center gap-1">
|
||||
<DropdownItem preserve-open class="gap-1">
|
||||
<div class="flex-grow flex items-center gap-1 min-w-0">
|
||||
{{ $t('SIDEBAR.SET_YOUR_AVAILABILITY') }}
|
||||
</div>
|
||||
<DropdownContainer>
|
||||
<DropdownContainer class="shrink-0">
|
||||
<template #trigger="{ toggle }">
|
||||
<Button
|
||||
size="sm"
|
||||
color="slate"
|
||||
variant="faded"
|
||||
class="min-w-[96px]"
|
||||
icon="i-lucide-chevron-down"
|
||||
trailing-icon
|
||||
@click="toggle"
|
||||
>
|
||||
<div class="flex gap-1 items-center flex-grow text-sm">
|
||||
<div class="flex gap-1 items-center min-w-0 text-sm">
|
||||
<div class="p-1 flex-shrink-0">
|
||||
<div class="size-2 rounded-sm" :class="activeStatus.color" />
|
||||
</div>
|
||||
<span>{{ activeStatus.label }}</span>
|
||||
<span class="truncate max-w-[7rem]">
|
||||
{{ activeStatus.label }}
|
||||
</span>
|
||||
</div>
|
||||
</Button>
|
||||
</template>
|
||||
@@ -114,12 +115,12 @@ function changeAvailabilityStatus(availability) {
|
||||
</DropdownContainer>
|
||||
</DropdownItem>
|
||||
<DropdownItem>
|
||||
<div class="flex-grow flex items-center gap-1">
|
||||
<div class="flex-grow min-w-0">
|
||||
{{ $t('SIDEBAR.SET_AUTO_OFFLINE.TEXT') }}
|
||||
<Icon
|
||||
v-tooltip.top="$t('SIDEBAR.SET_AUTO_OFFLINE.INFO_SHORT')"
|
||||
icon="i-lucide-info"
|
||||
class="size-4 text-n-slate-10"
|
||||
class="inline-block align-middle ms-1 size-4 text-n-slate-10"
|
||||
/>
|
||||
</div>
|
||||
<ToggleSwitch v-model="autoOfflineToggle" />
|
||||
|
||||
@@ -37,10 +37,6 @@ const props = defineProps({
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
countryCode: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
updatedAt: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
@@ -64,17 +60,12 @@ const updatedAtTime = computed(() => {
|
||||
});
|
||||
|
||||
const countryDetails = computed(() => {
|
||||
const {
|
||||
country,
|
||||
countryCode: additionalCountryCode,
|
||||
city,
|
||||
} = props.additionalAttributes;
|
||||
const countryCode = props.countryCode || additionalCountryCode;
|
||||
const { country, countryCode, city } = props.additionalAttributes;
|
||||
|
||||
if (!country && !countryCode) return null;
|
||||
|
||||
const activeCountry =
|
||||
countriesMap.value[countryCode] || countriesMap.value[country];
|
||||
countriesMap.value[country] || countriesMap.value[countryCode];
|
||||
|
||||
if (!activeCountry) return null;
|
||||
|
||||
|
||||
@@ -41,7 +41,6 @@ const accountId = useMapGetter('getCurrentAccountId');
|
||||
:name="contact.name"
|
||||
:email="contact.email"
|
||||
:phone="contact.phoneNumber"
|
||||
:country-code="contact.countryCode"
|
||||
:additional-attributes="contact.additionalAttributes"
|
||||
:account-id="accountId"
|
||||
:thumbnail="contact.thumbnail"
|
||||
|
||||
@@ -72,14 +72,12 @@ export default {
|
||||
city = '',
|
||||
country_code: countryCode,
|
||||
} = this.additionalAttributes;
|
||||
const canonicalCountryCode =
|
||||
this.contact.country_code || this.contact.countryCode || countryCode;
|
||||
const cityAndCountry = [city, country].filter(item => !!item).join(', ');
|
||||
|
||||
if (!cityAndCountry) {
|
||||
return '';
|
||||
}
|
||||
return this.findCountryFlag(canonicalCountryCode, cityAndCountry);
|
||||
return this.findCountryFlag(countryCode, cityAndCountry);
|
||||
},
|
||||
socialProfiles() {
|
||||
const {
|
||||
|
||||
@@ -32,7 +32,6 @@ class Account::ContactsExportJob < ApplicationJob
|
||||
|
||||
def value_for_header(contact, header)
|
||||
return contact_labels_by_id.fetch(contact.id, []).join(LABELS_DELIMITER) if header == LABELS_COLUMN
|
||||
return contact.canonical_country_code if header == 'country_code'
|
||||
|
||||
contact.send(header)
|
||||
end
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
module ContactCountryHelpers
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def canonical_country_code
|
||||
attributes = additional_attributes || {}
|
||||
|
||||
CountryCodeNormalizer.normalize(self[:country_code]) ||
|
||||
CountryCodeNormalizer.normalize(attributes['country_code']) ||
|
||||
CountryCodeNormalizer.normalize(attributes['country'])
|
||||
end
|
||||
|
||||
def canonical_country_name
|
||||
CountryCodeNormalizer.name_for(canonical_country_code)
|
||||
end
|
||||
|
||||
def additional_attributes_with_canonical_country
|
||||
attributes = additional_attributes.deep_dup || {}
|
||||
code = canonical_country_code
|
||||
return attributes if code.blank?
|
||||
|
||||
attributes.merge(
|
||||
'country_code' => code,
|
||||
'country' => canonical_country_name || attributes['country']
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -44,7 +44,6 @@
|
||||
class Contact < ApplicationRecord
|
||||
include Avatarable
|
||||
include AvailabilityStatusable
|
||||
include ContactCountryHelpers
|
||||
include Labelable
|
||||
include LlmFormattable
|
||||
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
require 'tzinfo'
|
||||
|
||||
class CountryCodeNormalizer
|
||||
COUNTRY_ALIASES = {
|
||||
'united states of america' => 'US',
|
||||
'usa' => 'US',
|
||||
'uk' => 'GB',
|
||||
'united kingdom' => 'GB'
|
||||
}.freeze
|
||||
|
||||
class << self
|
||||
def normalize(value)
|
||||
return if value.blank?
|
||||
|
||||
value = value.to_s.strip
|
||||
code = value.upcase
|
||||
return code if country_by_code.key?(code)
|
||||
|
||||
country_by_name[normalized_name(value)] || COUNTRY_ALIASES[normalized_name(value)]
|
||||
end
|
||||
|
||||
def name_for(code)
|
||||
country_by_code[normalize(code)]&.name
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def country_by_code
|
||||
@country_by_code ||= TZInfo::Country.all_codes.index_with { |code| TZInfo::Country.get(code) }
|
||||
end
|
||||
|
||||
def country_by_name
|
||||
@country_by_name ||= country_by_code.each_with_object({}) do |(code, country), result|
|
||||
result[normalized_name(country.name)] = code
|
||||
end
|
||||
end
|
||||
|
||||
def normalized_name(value)
|
||||
value.to_s.strip.downcase
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -26,7 +26,7 @@ class LlmFormatter::ContactLlmFormatter < LlmFormatter::DefaultLlmFormatter
|
||||
attributes << "Email: #{@record.email}"
|
||||
attributes << "Phone: #{@record.phone_number}"
|
||||
attributes << "Location: #{@record.location}"
|
||||
attributes << "Country Code: #{@record.canonical_country_code}"
|
||||
attributes << "Country Code: #{@record.country_code}"
|
||||
@record.account.custom_attribute_definitions.with_attribute_model('contact_attribute').each do |attribute|
|
||||
attributes << "#{attribute.attribute_display_name}: #{@record.custom_attributes[attribute.attribute_key]}"
|
||||
end
|
||||
|
||||
@@ -3,6 +3,5 @@ json.id contact.id
|
||||
json.name contact.name
|
||||
json.phone_number contact.phone_number
|
||||
json.identifier contact.identifier
|
||||
json.country_code contact.canonical_country_code
|
||||
json.additional_attributes contact.additional_attributes_with_canonical_country
|
||||
json.additional_attributes contact.additional_attributes
|
||||
json.last_activity_at contact.last_activity_at&.to_i
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
json.additional_attributes resource.additional_attributes_with_canonical_country
|
||||
json.additional_attributes resource.additional_attributes
|
||||
json.availability_status resource.availability_status
|
||||
json.country_code resource.canonical_country_code
|
||||
json.email resource.email
|
||||
json.id resource.id
|
||||
json.name resource.name
|
||||
|
||||
@@ -9,6 +9,7 @@ SAML_SETUP_PROC = proc do |env|
|
||||
account_id = request.params['account_id'] ||
|
||||
env['omniauth.params']&.dig('account_id')
|
||||
relay_state = request.params['RelayState'] || ''
|
||||
for_mobile = relay_state.casecmp('mobile').zero?
|
||||
|
||||
if account_id
|
||||
# Keep SAML request context in OmniAuth env so the callback can be processed
|
||||
@@ -21,9 +22,17 @@ SAML_SETUP_PROC = proc do |env|
|
||||
settings = AccountSamlSettings.find_by(account_id: account_id)
|
||||
|
||||
if settings
|
||||
# Carry the relay state in the ACS URL (like account_id) only for mobile,
|
||||
# so the mobile signal survives the cross-site callback POST without
|
||||
# relying on the session cookie or the IdP echoing RelayState back. Web
|
||||
# logins keep the registered ACS URL untouched so strict IdPs that
|
||||
# validate it exactly are unaffected.
|
||||
acs_url = "#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/omniauth/saml/callback?account_id=#{account_id}"
|
||||
acs_url += "&RelayState=#{ERB::Util.url_encode(relay_state)}" if for_mobile
|
||||
|
||||
# Configure the strategy options dynamically
|
||||
env['omniauth.strategy'].options[:idp_sso_service_url_runtime_params] = { RelayState: :RelayState }
|
||||
env['omniauth.strategy'].options[:assertion_consumer_service_url] = "#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/omniauth/saml/callback?account_id=#{account_id}"
|
||||
env['omniauth.strategy'].options[:assertion_consumer_service_url] = acs_url
|
||||
env['omniauth.strategy'].options[:sp_entity_id] = settings.sp_entity_id
|
||||
env['omniauth.strategy'].options[:idp_entity_id] = settings.idp_entity_id
|
||||
env['omniauth.strategy'].options[:idp_sso_service_url] = settings.sso_url
|
||||
|
||||
@@ -54,23 +54,6 @@ RSpec.describe 'Contacts API', type: :request do
|
||||
expect(contact_inboxes_source_ids).to include(contact_inbox.source_id)
|
||||
end
|
||||
|
||||
it 'returns canonical country attributes for contact display' do
|
||||
contact.update!(additional_attributes: { country: 'United States' })
|
||||
|
||||
get "/api/v1/accounts/#{account.id}/contacts",
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
response_body = response.parsed_body
|
||||
response_contact = response_body['payload'].find { |item| item['id'] == contact.id }
|
||||
|
||||
expect(response_contact['additional_attributes']).to include(
|
||||
'country_code' => 'US',
|
||||
'country' => 'United States'
|
||||
)
|
||||
expect(response_contact['country_code']).to eq('US')
|
||||
end
|
||||
|
||||
it 'returns all contacts without contact inboxes' do
|
||||
get "/api/v1/accounts/#{account.id}/contacts?include_contact_inboxes=false",
|
||||
headers: admin.create_new_auth_token,
|
||||
|
||||
@@ -8,12 +8,12 @@ RSpec.describe 'Google::CallbacksController', type: :request do
|
||||
|
||||
describe 'GET /google/callback' do
|
||||
let(:response_body_success) do
|
||||
{ id_token: JWT.encode({ email: email, name: 'test' }, false), access_token: SecureRandom.hex(10), token_type: 'Bearer',
|
||||
{ id_token: JWT.encode({ email: email, name: 'test' }, nil, 'none'), access_token: SecureRandom.hex(10), token_type: 'Bearer',
|
||||
refresh_token: SecureRandom.hex(10) }
|
||||
end
|
||||
|
||||
let(:response_body_success_without_name) do
|
||||
{ id_token: JWT.encode({ email: email }, false), access_token: SecureRandom.hex(10), token_type: 'Bearer',
|
||||
{ id_token: JWT.encode({ email: email }, nil, 'none'), access_token: SecureRandom.hex(10), token_type: 'Bearer',
|
||||
refresh_token: SecureRandom.hex(10) }
|
||||
end
|
||||
|
||||
|
||||
@@ -8,12 +8,12 @@ RSpec.describe 'Microsoft::CallbacksController', type: :request do
|
||||
|
||||
describe 'GET /microsoft/callback' do
|
||||
let(:response_body_success) do
|
||||
{ id_token: JWT.encode({ email: email, name: 'test' }, false), access_token: SecureRandom.hex(10), token_type: 'Bearer',
|
||||
{ id_token: JWT.encode({ email: email, name: 'test' }, nil, 'none'), access_token: SecureRandom.hex(10), token_type: 'Bearer',
|
||||
refresh_token: SecureRandom.hex(10) }
|
||||
end
|
||||
|
||||
let(:response_body_success_without_name) do
|
||||
{ id_token: JWT.encode({ email: email }, false), access_token: SecureRandom.hex(10), token_type: 'Bearer',
|
||||
{ id_token: JWT.encode({ email: email }, nil, 'none'), access_token: SecureRandom.hex(10), token_type: 'Bearer',
|
||||
refresh_token: SecureRandom.hex(10) }
|
||||
end
|
||||
|
||||
|
||||
@@ -82,6 +82,7 @@ RSpec.describe Instagram::IntegrationHelper do
|
||||
|
||||
context 'when client secret is not configured' do
|
||||
let(:client_secret) { nil }
|
||||
let(:valid_token) { 'any-token' }
|
||||
|
||||
it 'returns nil' do
|
||||
expect(verify_instagram_token(valid_token)).to be_nil
|
||||
|
||||
@@ -65,6 +65,7 @@ RSpec.describe Linear::IntegrationHelper do
|
||||
|
||||
context 'when client secret is not configured' do
|
||||
let(:client_secret) { nil }
|
||||
let(:valid_token) { 'any-token' }
|
||||
|
||||
it 'returns nil' do
|
||||
expect(verify_linear_token(valid_token)).to be_nil
|
||||
|
||||
@@ -65,6 +65,7 @@ RSpec.describe Shopify::IntegrationHelper do
|
||||
|
||||
context 'when client secret is not configured' do
|
||||
let(:client_secret) { nil }
|
||||
let(:valid_token) { 'any-token' }
|
||||
|
||||
it 'returns nil' do
|
||||
expect(verify_shopify_token(valid_token)).to be_nil
|
||||
|
||||
@@ -99,19 +99,6 @@ RSpec.describe Account::ContactsExportJob do
|
||||
expect(row['labels']).to eq('vip')
|
||||
end
|
||||
|
||||
it 'exports canonical country codes from legacy country data' do
|
||||
legacy_country_contact = create(:contact, account: account, email: 'legacy-country@example.com',
|
||||
additional_attributes: { country: 'United States' })
|
||||
|
||||
described_class.perform_now(account.id, user.id, %w[email country_code], {})
|
||||
|
||||
csv_content = account.contacts_export.download.force_encoding('UTF-8').delete_prefix("\xEF\xBB\xBF")
|
||||
csv_data = CSV.parse(csv_content, headers: true)
|
||||
row = csv_data.find { |record| record['email'] == legacy_country_contact.email }
|
||||
|
||||
expect(row['country_code']).to eq('US')
|
||||
end
|
||||
|
||||
it 'bulk loads labels while exporting contacts' do
|
||||
create(:label, account: account, title: 'vip')
|
||||
create(:label, account: account, title: 'support')
|
||||
|
||||
@@ -93,42 +93,6 @@ RSpec.describe Contact do
|
||||
end
|
||||
end
|
||||
|
||||
describe '#canonical_country_code' do
|
||||
it 'prefers the contact country_code column when it has a valid country code' do
|
||||
contact = create(:contact, additional_attributes: { country_code: 'IN', country: 'India' })
|
||||
contact[:country_code] = 'US'
|
||||
|
||||
expect(contact.canonical_country_code).to eq('US')
|
||||
expect(contact.canonical_country_name).to eq('United States')
|
||||
end
|
||||
|
||||
it 'falls back to additional_attributes country_code' do
|
||||
contact = create(:contact, additional_attributes: { country_code: 'in' })
|
||||
|
||||
expect(contact.canonical_country_code).to eq('IN')
|
||||
expect(contact.canonical_country_name).to eq('India')
|
||||
end
|
||||
|
||||
it 'normalizes legacy country names from additional_attributes country' do
|
||||
contact = create(:contact, additional_attributes: { country: 'United States' })
|
||||
|
||||
expect(contact.canonical_country_code).to eq('US')
|
||||
expect(contact.canonical_country_name).to eq('United States')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#additional_attributes_with_canonical_country' do
|
||||
it 'returns additional attributes with normalized country values without persisting them' do
|
||||
contact = create(:contact, additional_attributes: { country: 'United States' })
|
||||
|
||||
expect(contact.additional_attributes_with_canonical_country).to include(
|
||||
'country_code' => 'US',
|
||||
'country' => 'United States'
|
||||
)
|
||||
expect(contact.reload.additional_attributes).not_to include('country_code')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when a contact is created' do
|
||||
it 'has contact type "visitor" by default' do
|
||||
contact = create(:contact)
|
||||
|
||||
@@ -48,16 +48,6 @@ RSpec.describe LlmFormatter::ContactLlmFormatter do
|
||||
end
|
||||
end
|
||||
|
||||
context 'when contact has legacy country data' do
|
||||
before do
|
||||
contact.update!(additional_attributes: { country: 'United States' })
|
||||
end
|
||||
|
||||
it 'uses the canonical country code' do
|
||||
expect(formatter.format).to include('Country Code: US')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when contact has custom attributes' do
|
||||
let!(:custom_attribute) do
|
||||
create(:custom_attribute_definition, account: account, attribute_model: 'contact_attribute', attribute_display_name: 'Company')
|
||||
|
||||
@@ -11,9 +11,6 @@ properties:
|
||||
availability_status:
|
||||
type: string
|
||||
description: The availability status of the contact
|
||||
country_code:
|
||||
type: string
|
||||
description: The normalized ISO country code of the contact
|
||||
email:
|
||||
type: string
|
||||
description: The email address of the contact
|
||||
|
||||
Reference in New Issue
Block a user