fix(shangwutong): 完善访客资料同步

This commit is contained in:
Rogee
2026-08-06 15:14:32 +08:00
parent 3bceb2d0b2
commit 5c47d1d5fe
24 changed files with 1800 additions and 952 deletions
+3 -1
View File
@@ -51,7 +51,9 @@ func (c *Contact) syncLocationAndCountryCode() {
if city, ok := attrs["city"].(string); ok && city != "" {
c.Location = city
}
if country, ok := attrs["country"].(string); ok && country != "" {
if countryCode, ok := attrs["country_code"].(string); ok && countryCode != "" {
c.CountryCode = countryCode
} else if country, ok := attrs["country"].(string); ok && country != "" {
c.CountryCode = country
}
}
+19
View File
@@ -0,0 +1,19 @@
package model
import (
"testing"
"gorm.io/datatypes"
)
func TestContactSyncLocationAndCountryCodePrefersISOCode(t *testing.T) {
contact := Contact{
AdditionalAttributes: datatypes.JSON([]byte(`{"city":"贵州贵阳","country":"China","country_code":"CN"}`)),
}
contact.syncLocationAndCountryCode()
if contact.Location != "贵州贵阳" || contact.CountryCode != "CN" {
t.Fatalf("location=%q country_code=%q", contact.Location, contact.CountryCode)
}
}
@@ -0,0 +1,27 @@
UPDATE custom_attribute_definitions
SET
attribute_display_name = 'Custom Priority',
description = 'Custom ticket priority',
attribute_values = '["Low", "Medium", "High"]'::jsonb,
updated_at = CURRENT_TIMESTAMP
WHERE attribute_name = 'custom_priority'
AND attribute_model = 'conversation_attribute'
AND attribute_display_name = '自定义优先级'
AND description = '自定义工单优先级'
AND attribute_values = '["低", "中", "高"]'::jsonb;
UPDATE conversations
SET
custom_attributes = jsonb_set(
custom_attributes,
'{custom_priority}',
to_jsonb(
CASE custom_attributes->>'custom_priority'
WHEN '低' THEN 'Low'
WHEN '中' THEN 'Medium'
WHEN '高' THEN 'High'
END
)
),
updated_at = CURRENT_TIMESTAMP
WHERE custom_attributes->>'custom_priority' IN ('低', '中', '高');
@@ -0,0 +1,27 @@
UPDATE custom_attribute_definitions
SET
attribute_display_name = '自定义优先级',
description = '自定义工单优先级',
attribute_values = '["低", "中", "高"]'::jsonb,
updated_at = CURRENT_TIMESTAMP
WHERE attribute_name = 'custom_priority'
AND attribute_model = 'conversation_attribute'
AND attribute_display_name = 'Custom Priority'
AND description = 'Custom ticket priority'
AND attribute_values = '["Low", "Medium", "High"]'::jsonb;
UPDATE conversations
SET
custom_attributes = jsonb_set(
custom_attributes,
'{custom_priority}',
to_jsonb(
CASE custom_attributes->>'custom_priority'
WHEN 'Low' THEN '低'
WHEN 'Medium' THEN '中'
WHEN 'High' THEN '高'
END
)
),
updated_at = CURRENT_TIMESTAMP
WHERE custom_attributes->>'custom_priority' IN ('Low', 'Medium', 'High');
@@ -0,0 +1,12 @@
DELETE FROM custom_attribute_definitions
WHERE attribute_model = 'contact_attribute'
AND attribute_name IN (
'swt_ip', 'swt_ip_location', 'swt_isp', 'swt_resolution',
'swt_language', 'swt_timezone', 'swt_os', 'swt_browser',
'swt_browser_version', 'swt_user_agent', 'swt_device',
'swt_query_title', 'swt_query_word', 'swt_traffic_source',
'swt_profile_channel', 'swt_site_id'
);
-- Contact values are intentionally retained in custom_attributes. Removing
-- them would discard visitor diagnostics that may have arrived after upgrade.
@@ -0,0 +1,83 @@
INSERT INTO custom_attribute_definitions (
account_id,
attribute_name,
attribute_display_name,
attribute_type,
attribute_model,
description,
created_at,
updated_at
)
SELECT
accounts.id,
definitions.attribute_name,
definitions.attribute_display_name,
'text',
'contact_attribute',
definitions.description,
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
FROM accounts
CROSS JOIN (VALUES
('swt_ip', '访客 IP', '商务通访客 IP 地址'),
('swt_ip_location', 'IP 归属地', '商务通解析的访客 IP 归属地'),
('swt_isp', '网络运营商', '商务通访客网络运营商'),
('swt_resolution', '屏幕分辨率', '商务通访客设备屏幕分辨率'),
('swt_language', '浏览器语言', '商务通访客浏览器语言'),
('swt_timezone', '访客时区', '商务通访客设备时区'),
('swt_os', '操作系统', '商务通访客设备操作系统'),
('swt_browser', '浏览器', '商务通访客浏览器名称'),
('swt_browser_version', '浏览器版本', '商务通访客浏览器版本'),
('swt_user_agent', 'User Agent', '商务通访客浏览器 User Agent'),
('swt_device', '访客设备', '商务通访客设备信息'),
('swt_query_title', '搜索主题', '商务通访客进入页面时的搜索主题'),
('swt_query_word', '搜索关键词', '商务通访客进入页面时的搜索关键词'),
('swt_traffic_source', '流量来源', '商务通访客流量来源'),
('swt_profile_channel', '来源渠道', '商务通访客来源渠道'),
('swt_site_id', '商务通站点 ID', '商务通站点标识')
) AS definitions(attribute_name, attribute_display_name, description)
WHERE NOT EXISTS (
SELECT 1
FROM custom_attribute_definitions existing
WHERE existing.account_id = accounts.id
AND existing.attribute_name = definitions.attribute_name
AND existing.attribute_model = 'contact_attribute'
AND existing.deleted_at IS NULL
);
UPDATE contacts
SET
custom_attributes = COALESCE(custom_attributes, '{}'::jsonb)
|| jsonb_strip_nulls(jsonb_build_object(
'swt_ip', additional_attributes->'swt_ip',
'swt_ip_location', additional_attributes->'swt_ip_location',
'swt_isp', additional_attributes->'swt_isp',
'swt_resolution', additional_attributes->'swt_resolution',
'swt_language', additional_attributes->'swt_language',
'swt_timezone', additional_attributes->'swt_timezone',
'swt_os', additional_attributes->'swt_os',
'swt_browser', additional_attributes->'swt_browser',
'swt_browser_version', additional_attributes->'swt_browser_version',
'swt_user_agent', additional_attributes->'swt_user_agent',
'swt_device', additional_attributes->'swt_device',
'swt_query_title', additional_attributes->'swt_query_title',
'swt_query_word', additional_attributes->'swt_query_word',
'swt_traffic_source', additional_attributes->'swt_traffic_source',
'swt_profile_channel', additional_attributes->'swt_profile_channel',
'swt_site_id', additional_attributes->'swt_site_id'
)),
updated_at = CURRENT_TIMESTAMP
WHERE EXISTS (
SELECT 1 FROM contact_inboxes
WHERE contact_inboxes.contact_id = contacts.id
AND contact_inboxes.inbox_id IN (
SELECT id FROM inboxes WHERE channel_type = 'shangwutong'
)
)
AND COALESCE(additional_attributes, '{}'::jsonb) ?| ARRAY[
'swt_ip', 'swt_ip_location', 'swt_isp', 'swt_resolution',
'swt_language', 'swt_timezone', 'swt_os', 'swt_browser',
'swt_browser_version', 'swt_user_agent', 'swt_device',
'swt_query_title', 'swt_query_word', 'swt_traffic_source',
'swt_profile_channel', 'swt_site_id'
];
@@ -0,0 +1,3 @@
-- Message sender names are denormalized presentation metadata. The original
-- hard-coded values cannot be restored reliably after contacts are renamed.
SELECT 1;
@@ -0,0 +1,37 @@
WITH latest_names AS (
SELECT DISTINCT ON (conversation_id)
conversation_id,
NULLIF(BTRIM(additional_attributes->>'senderName'), '') AS visitor_name
FROM messages
WHERE message_type = 'incoming'
AND NULLIF(BTRIM(additional_attributes->>'senderName'), '') IS NOT NULL
AND BTRIM(additional_attributes->>'senderName') <> '商务通访客'
ORDER BY conversation_id, created_at DESC, id DESC
)
UPDATE contacts
SET
name = latest_names.visitor_name,
updated_at = CURRENT_TIMESTAMP
FROM conversations
JOIN latest_names ON latest_names.conversation_id = conversations.id
JOIN inboxes ON inboxes.id = conversations.inbox_id
WHERE contacts.id = conversations.contact_id
AND inboxes.channel_type = 'shangwutong'
AND contacts.name <> latest_names.visitor_name;
-- Existing imported messages used a hard-coded senderName. Contact identity is
-- the authoritative sender for incoming messages after this repair.
UPDATE messages
SET additional_attributes = jsonb_set(
COALESCE(messages.additional_attributes, '{}'::jsonb),
'{senderName}',
to_jsonb(contacts.name)
)
FROM conversations
JOIN contacts ON contacts.id = conversations.contact_id
JOIN inboxes ON inboxes.id = conversations.inbox_id
WHERE messages.conversation_id = conversations.id
AND messages.message_type = 'incoming'
AND inboxes.channel_type = 'shangwutong'
AND BTRIM(contacts.name) <> ''
AND COALESCE(messages.additional_attributes->>'senderName', '') <> contacts.name;
@@ -0,0 +1,3 @@
-- Percent-decoded visitor metadata cannot be losslessly restored to the
-- original hex casing and plus-sign representation.
SELECT 1;
@@ -0,0 +1,74 @@
CREATE OR REPLACE FUNCTION gochat_percent_decode(value TEXT)
RETURNS TEXT
LANGUAGE plpgsql
IMMUTABLE
STRICT
AS $$
DECLARE
position INTEGER := 1;
encoded_bytes TEXT := '';
token TEXT;
BEGIN
WHILE position <= char_length(value) LOOP
token := substr(value, position, 1);
IF token = '%' AND substr(value, position + 1, 2) ~ '^[0-9A-Fa-f]{2}$' THEN
encoded_bytes := encoded_bytes || substr(value, position + 1, 2);
position := position + 3;
ELSIF token = '+' THEN
encoded_bytes := encoded_bytes || '20';
position := position + 1;
ELSE
encoded_bytes := encoded_bytes || encode(convert_to(token, 'UTF8'), 'hex');
position := position + 1;
END IF;
END LOOP;
RETURN convert_from(decode(encoded_bytes, 'hex'), 'UTF8');
EXCEPTION WHEN OTHERS THEN
RETURN value;
END;
$$;
UPDATE contacts
SET
custom_attributes = COALESCE(custom_attributes, '{}'::jsonb)
|| jsonb_strip_nulls(jsonb_build_object(
'swt_ip_location', gochat_percent_decode(custom_attributes->>'swt_ip_location'),
'swt_color_depth', gochat_percent_decode(custom_attributes->>'swt_color_depth'),
'swt_resolution', gochat_percent_decode(custom_attributes->>'swt_resolution'),
'swt_os', gochat_percent_decode(custom_attributes->>'swt_os'),
'city', CASE
WHEN COALESCE(custom_attributes->>'city', '') = ''
THEN gochat_percent_decode(custom_attributes->>'swt_ip_location')
END
)),
additional_attributes = COALESCE(additional_attributes, '{}'::jsonb)
|| jsonb_strip_nulls(jsonb_build_object(
'swt_ip_location', gochat_percent_decode(additional_attributes->>'swt_ip_location'),
'swt_color_depth', gochat_percent_decode(additional_attributes->>'swt_color_depth'),
'swt_resolution', gochat_percent_decode(additional_attributes->>'swt_resolution'),
'swt_os', gochat_percent_decode(additional_attributes->>'swt_os'),
'city', CASE
WHEN COALESCE(additional_attributes->>'city', '') = ''
THEN gochat_percent_decode(additional_attributes->>'swt_ip_location')
END
)),
updated_at = CURRENT_TIMESTAMP
WHERE EXISTS (
SELECT 1
FROM contact_inboxes
JOIN inboxes ON inboxes.id = contact_inboxes.inbox_id
WHERE contact_inboxes.contact_id = contacts.id
AND inboxes.channel_type = 'shangwutong'
)
AND (
COALESCE(custom_attributes->>'swt_ip_location', '') ~ '%[0-9A-Fa-f]{2}'
OR COALESCE(custom_attributes->>'swt_color_depth', '') ~ '%[0-9A-Fa-f]{2}'
OR COALESCE(custom_attributes->>'swt_resolution', '') ~ '%[0-9A-Fa-f]{2}'
OR COALESCE(custom_attributes->>'swt_os', '') ~ '%[0-9A-Fa-f]{2}'
OR COALESCE(additional_attributes->>'swt_ip_location', '') ~ '%[0-9A-Fa-f]{2}'
OR COALESCE(additional_attributes->>'swt_color_depth', '') ~ '%[0-9A-Fa-f]{2}'
OR COALESCE(additional_attributes->>'swt_resolution', '') ~ '%[0-9A-Fa-f]{2}'
OR COALESCE(additional_attributes->>'swt_os', '') ~ '%[0-9A-Fa-f]{2}'
);
DROP FUNCTION gochat_percent_decode(TEXT);
@@ -0,0 +1,3 @@
-- Repaired environment fields cannot be reconstructed to the previous
-- misaligned values without reintroducing corrupt contact metadata.
SELECT 1;
@@ -0,0 +1,91 @@
CREATE OR REPLACE FUNCTION gochat_percent_decode(value TEXT)
RETURNS TEXT
LANGUAGE plpgsql
IMMUTABLE
STRICT
AS $$
DECLARE
position INTEGER := 1;
encoded_bytes TEXT := '';
token TEXT;
BEGIN
WHILE position <= char_length(value) LOOP
token := substr(value, position, 1);
IF token = '%' AND substr(value, position + 1, 2) ~ '^[0-9A-Fa-f]{2}$' THEN
encoded_bytes := encoded_bytes || substr(value, position + 1, 2);
position := position + 3;
ELSIF token = '+' THEN
encoded_bytes := encoded_bytes || '20';
position := position + 1;
ELSE
encoded_bytes := encoded_bytes || encode(convert_to(token, 'UTF8'), 'hex');
position := position + 1;
END IF;
END LOOP;
RETURN convert_from(decode(encoded_bytes, 'hex'), 'UTF8');
EXCEPTION WHEN OTHERS THEN
RETURN value;
END;
$$;
WITH profiles AS (
SELECT DISTINCT ON (contacts.id)
contacts.id,
split_part(
COALESCE(
NULLIF(contacts.custom_attributes->>'swt_xst_profile', ''),
contacts.additional_attributes->>'swt_xst_profile'
),
'|||||',
1
) AS profile
FROM contacts
JOIN contact_inboxes ON contact_inboxes.contact_id = contacts.id
JOIN inboxes ON inboxes.id = contact_inboxes.inbox_id
WHERE inboxes.channel_type = 'shangwutong'
AND COALESCE(
NULLIF(contacts.custom_attributes->>'swt_xst_profile', ''),
contacts.additional_attributes->>'swt_xst_profile'
) IS NOT NULL
), decoded AS (
SELECT
id,
NULLIF(gochat_percent_decode(split_part(profile, chr(26), 5)), '') AS location,
NULLIF(gochat_percent_decode(split_part(profile, chr(26), 11)), '') AS resolution,
NULLIF(gochat_percent_decode(split_part(profile, chr(26), 12)), '') AS user_agent,
NULLIF(gochat_percent_decode(split_part(profile, chr(26), 13)), '') AS ip_address
FROM profiles
)
UPDATE contacts
SET
custom_attributes = COALESCE(contacts.custom_attributes, '{}'::jsonb)
|| jsonb_strip_nulls(jsonb_build_object(
'swt_ip_location', decoded.location,
'city', decoded.location,
'swt_resolution', decoded.resolution,
'swt_user_agent', decoded.user_agent,
'swt_device', decoded.user_agent,
'swt_os', decoded.user_agent,
'swt_ip', decoded.ip_address
)),
additional_attributes = COALESCE(contacts.additional_attributes, '{}'::jsonb)
|| jsonb_strip_nulls(jsonb_build_object(
'swt_ip_location', decoded.location,
'city', decoded.location,
'swt_resolution', decoded.resolution,
'swt_user_agent', decoded.user_agent,
'swt_device', decoded.user_agent,
'swt_os', decoded.user_agent,
'swt_ip', decoded.ip_address
)),
updated_at = CURRENT_TIMESTAMP
FROM decoded
WHERE contacts.id = decoded.id
AND (
decoded.location IS NOT NULL
OR decoded.resolution IS NOT NULL
OR decoded.user_agent IS NOT NULL
OR decoded.ip_address IS NOT NULL
);
DROP FUNCTION gochat_percent_decode(TEXT);
@@ -0,0 +1,6 @@
DELETE FROM custom_attribute_definitions
WHERE attribute_name = 'swt_environment_version'
AND attribute_model = 'contact_attribute';
-- The previous swt_isp and duplicated UA-derived values were semantically
-- incorrect and are intentionally not restored.
@@ -0,0 +1,116 @@
INSERT INTO custom_attribute_definitions (
account_id,
attribute_name,
attribute_display_name,
attribute_type,
attribute_model,
description,
created_at,
updated_at
)
SELECT
accounts.id,
'swt_environment_version',
'环境协议版本',
'text',
'contact_attribute',
'商务通 kind=7 环境信息格式版本;不是网络运营商',
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
FROM accounts
WHERE NOT EXISTS (
SELECT 1
FROM custom_attribute_definitions existing
WHERE existing.account_id = accounts.id
AND existing.attribute_name = 'swt_environment_version'
AND existing.attribute_model = 'contact_attribute'
AND existing.deleted_at IS NULL
);
DELETE FROM custom_attribute_definitions
WHERE attribute_name = 'swt_isp'
AND attribute_model = 'contact_attribute';
WITH swt_contacts AS (
SELECT DISTINCT contacts.id
FROM contacts
JOIN contact_inboxes ON contact_inboxes.contact_id = contacts.id
JOIN inboxes ON inboxes.id = contact_inboxes.inbox_id
WHERE inboxes.channel_type = 'shangwutong'
), source AS (
SELECT
contacts.id,
COALESCE(
NULLIF(contacts.custom_attributes->>'swt_user_agent', ''),
NULLIF(contacts.additional_attributes->>'swt_user_agent', ''),
NULLIF(contacts.custom_attributes->>'swt_os', ''),
NULLIF(contacts.additional_attributes->>'swt_os', '')
) AS user_agent,
COALESCE(
NULLIF(contacts.custom_attributes->>'swt_environment_version', ''),
NULLIF(contacts.custom_attributes->>'swt_isp', ''),
NULLIF(contacts.additional_attributes->>'swt_environment_version', ''),
NULLIF(contacts.additional_attributes->>'swt_isp', '')
) AS environment_version
FROM contacts
JOIN swt_contacts ON swt_contacts.id = contacts.id
), parsed AS (
SELECT
id,
user_agent,
environment_version,
CASE
WHEN user_agent ~ 'OpenHarmony[[:space:]]+[0-9.]+'
THEN 'OpenHarmony ' || substring(user_agent FROM 'OpenHarmony[[:space:]]+([0-9.]+)')
WHEN user_agent ~ 'Android[[:space:]]+[^;]+'
THEN 'Android ' || BTRIM(substring(user_agent FROM 'Android[[:space:]]+([^;]+)'))
WHEN user_agent ~ 'iPhone OS[[:space:]]+[0-9_]+'
THEN 'iOS ' || replace(substring(user_agent FROM 'iPhone OS[[:space:]]+([0-9_]+)'), '_', '.')
WHEN user_agent LIKE '%Windows NT 10.0%'
THEN 'Windows 10/11'
WHEN user_agent LIKE '%Windows%'
THEN 'Windows'
WHEN user_agent ~ 'Mac OS X[[:space:]]+[0-9_]+'
THEN 'macOS ' || replace(substring(user_agent FROM 'Mac OS X[[:space:]]+([0-9_]+)'), '_', '.')
WHEN user_agent LIKE '%Linux%'
THEN 'Linux'
END AS operating_system,
CASE
WHEN user_agent LIKE '%OpenHarmony%'
THEN BTRIM(split_part(user_agent, ';', 1))
WHEN user_agent ~ 'Android[[:space:]]+[^;]+;[[:space:]]*[^;]+ Build/'
THEN BTRIM(substring(user_agent FROM 'Android[[:space:]]+[^;]+;[[:space:]]*([^;]+) Build/'))
WHEN user_agent LIKE '%Android%'
THEN 'Android 设备'
WHEN user_agent LIKE '%iPhone OS%'
THEN 'iPhone'
WHEN user_agent LIKE '%Windows%'
THEN 'Windows PC'
WHEN user_agent LIKE '%Mac OS X%'
THEN 'Mac'
WHEN user_agent LIKE '%Linux%'
THEN 'Linux 设备'
END AS device
FROM source
)
UPDATE contacts
SET
custom_attributes = (
COALESCE(contacts.custom_attributes, '{}'::jsonb) - 'swt_isp' - 'swt_color_depth'
) || jsonb_strip_nulls(jsonb_build_object(
'swt_environment_version', parsed.environment_version,
'swt_user_agent', parsed.user_agent,
'swt_os', parsed.operating_system,
'swt_device', parsed.device
)),
additional_attributes = (
COALESCE(contacts.additional_attributes, '{}'::jsonb) - 'swt_isp' - 'swt_color_depth'
) || jsonb_strip_nulls(jsonb_build_object(
'swt_environment_version', parsed.environment_version,
'swt_user_agent', parsed.user_agent,
'swt_os', parsed.operating_system,
'swt_device', parsed.device
)),
updated_at = CURRENT_TIMESTAMP
FROM parsed
WHERE contacts.id = parsed.id;
@@ -0,0 +1,2 @@
-- The previous device values duplicated the full User Agent and were invalid.
SELECT 1;
@@ -0,0 +1,75 @@
WITH swt_contacts AS (
SELECT DISTINCT contacts.id
FROM contacts
JOIN contact_inboxes ON contact_inboxes.contact_id = contacts.id
JOIN inboxes ON inboxes.id = contact_inboxes.inbox_id
WHERE inboxes.channel_type = 'shangwutong'
), source AS (
SELECT
contacts.id,
COALESCE(
NULLIF(contacts.custom_attributes->>'swt_user_agent', ''),
NULLIF(contacts.additional_attributes->>'swt_user_agent', ''),
NULLIF(contacts.custom_attributes->>'swt_device', ''),
NULLIF(contacts.additional_attributes->>'swt_device', '')
) AS user_agent
FROM contacts
JOIN swt_contacts ON swt_contacts.id = contacts.id
), parsed AS (
SELECT
id,
user_agent,
CASE
WHEN user_agent ~ 'OpenHarmony[[:space:]]+[0-9.]+'
THEN 'OpenHarmony ' || substring(user_agent FROM 'OpenHarmony[[:space:]]+([0-9.]+)')
WHEN user_agent ~ 'Android[[:space:]]+[^;]+'
THEN 'Android ' || BTRIM(substring(user_agent FROM 'Android[[:space:]]+([^;]+)'))
WHEN user_agent ~ 'iPhone OS[[:space:]]+[0-9_]+'
THEN 'iOS ' || replace(substring(user_agent FROM 'iPhone OS[[:space:]]+([0-9_]+)'), '_', '.')
WHEN user_agent LIKE '%Windows NT 10.0%'
THEN 'Windows 10/11'
WHEN user_agent LIKE '%Windows%'
THEN 'Windows'
WHEN user_agent ~ 'Mac OS X[[:space:]]+[0-9_]+'
THEN 'macOS ' || replace(substring(user_agent FROM 'Mac OS X[[:space:]]+([0-9_]+)'), '_', '.')
WHEN user_agent LIKE '%Linux%'
THEN 'Linux'
END AS operating_system,
CASE
WHEN user_agent LIKE '%OpenHarmony%'
THEN BTRIM(split_part(user_agent, ';', 1))
WHEN user_agent ~ 'Android[[:space:]]+[^;]+;[[:space:]]*[^;]+ Build/'
THEN BTRIM(substring(user_agent FROM 'Android[[:space:]]+[^;]+;[[:space:]]*([^;]+) Build/'))
WHEN user_agent ~ 'Android[[:space:]]+[^;]+;[[:space:]]*[^;]+;[[:space:]]*[^;]+ Build/'
THEN BTRIM(substring(user_agent FROM 'Android[[:space:]]+[^;]+;[[:space:]]*[^;]+;[[:space:]]*([^;]+) Build/'))
WHEN user_agent LIKE '%Android%'
THEN 'Android 设备'
WHEN user_agent LIKE '%iPhone OS%'
THEN 'iPhone'
WHEN user_agent LIKE '%Windows%'
THEN 'Windows PC'
WHEN user_agent LIKE '%Mac OS X%'
THEN 'Mac'
WHEN user_agent LIKE '%Linux%'
THEN 'Linux 设备'
END AS device
FROM source
WHERE user_agent IS NOT NULL
)
UPDATE contacts
SET
custom_attributes = COALESCE(contacts.custom_attributes, '{}'::jsonb)
|| jsonb_strip_nulls(jsonb_build_object(
'swt_user_agent', parsed.user_agent,
'swt_device', parsed.device,
'swt_os', parsed.operating_system
)),
additional_attributes = COALESCE(contacts.additional_attributes, '{}'::jsonb)
|| jsonb_strip_nulls(jsonb_build_object(
'swt_user_agent', parsed.user_agent,
'swt_device', parsed.device,
'swt_os', parsed.operating_system
)),
updated_at = CURRENT_TIMESTAMP
FROM parsed
WHERE contacts.id = parsed.id;
@@ -0,0 +1,3 @@
-- Country metadata inferred from IP location cannot be safely distinguished from
-- user-entered values, so this data repair is intentionally irreversible.
SELECT 1;
@@ -0,0 +1,22 @@
UPDATE contacts
SET
country_code = 'CN',
location = CASE
WHEN COALESCE(location, '') = '' THEN custom_attributes->>'swt_ip_location'
ELSE location
END,
additional_attributes = COALESCE(additional_attributes, '{}'::jsonb)
|| jsonb_build_object(
'country_code', 'CN',
'country', 'China',
'city', custom_attributes->>'swt_ip_location'
),
updated_at = CURRENT_TIMESTAMP
WHERE EXISTS (
SELECT 1
FROM contact_inboxes
JOIN inboxes ON inboxes.id = contact_inboxes.inbox_id
WHERE contact_inboxes.contact_id = contacts.id
AND inboxes.channel_type = 'shangwutong'
)
AND COALESCE(custom_attributes->>'swt_ip_location', '') ~ '^(北京|天津|上海|重庆|河北|山西|辽宁|吉林|黑龙江|江苏|浙江|安徽|福建|江西|山东|河南|湖北|湖南|广东|海南|四川|贵州|云南|陕西|甘肃|青海|台湾|内蒙古|广西|西藏|宁夏|新疆|香港|澳门)';
@@ -152,9 +152,15 @@ func (i *Inbound) ensureResources(ctx context.Context, account *dbgen.Account, e
contactAttributes := cloneMap(mapped.ContactAttributes)
contactAttributes["swt_inbox_id"] = account.GochatInboxID
contactAdditional := cloneMap(mapped.ContactAdditional)
if inferred := shangwutongLocationAttributes(mapped.ContactAttributes); len(inferred) > 0 {
for key, value := range inferred {
contactAdditional[key] = value
}
}
contactRequest := gochat.ContactRequest{
SourceID: event.SwtSid, Name: mapped.ContactName, PhoneNumber: mapped.ContactPhone,
AdditionalAttributes: contactAttributes,
CustomAttributes: contactAttributes, AdditionalAttributes: contactAdditional,
}
needsContactWrite := state.contactID == 0 || mapped.ContactName != "" || mapped.ContactPhone != "" || len(mapped.ContactAttributes) > 0
if mapped.RequiresContact && needsContactWrite {
@@ -21,6 +21,7 @@ type mappedEvent struct {
ContactName string
ContactPhone string
ContactAttributes map[string]any
ContactAdditional map[string]any
ConversationAttrs map[string]any
ConversationStatus string
Typing *bool
@@ -64,6 +65,7 @@ func mapInboundEvent(kind int64, seqID int64, text, operator, rawTimestamp, sour
mapped.Media = media
mapped.Subtype = subtype
mapped.Message.AdditionalAttributes["senderName"] = "商务通访客"
baseAttributes["swt"].(map[string]any)["operator_name"] = cleanText(operator)
case 3:
content, subtype, fallback, media := normalizeMessageContent(text)
message("outgoing", "text", content, chooseStrategy(fallback, "fallback_text", "native_message"))
@@ -124,7 +126,6 @@ func mapInboundEvent(kind int64, seqID int64, text, operator, rawTimestamp, sour
case 7:
mapped.Strategy, mapped.RawOnly, mapped.RequiresContact = "contact_attributes", false, true
mapped.ContactAttributes = parseEnvironment(text)
mapped.ContactName = visitorDisplayName(mapped.ContactAttributes)
case 8:
mapped.Strategy, mapped.RawOnly, mapped.RequiresContact = "conversation_attributes", false, true
mapped.ConversationAttrs = parseSource(text)
@@ -132,8 +133,8 @@ func mapInboundEvent(kind int64, seqID int64, text, operator, rawTimestamp, sour
case 11:
// Account presence is applied in the heartbeat transaction.
case 12:
mapped.Strategy, mapped.RawOnly, mapped.RequiresContact, mapped.RequiresConversation = "conversation_attributes", false, true, true
mapped.ConversationAttrs = map[string]any{"swt_operator_alias": cleanText(text)}
mapped.Strategy, mapped.RawOnly, mapped.RequiresContact = "contact_attributes", false, true
mapped.ContactName = cleanText(text)
case 15:
content, subtype, fallback, media := normalizeMessageContent(text)
message("incoming", "text", firstText(content, "访客发送了文件"), "fallback_text")
@@ -143,6 +144,7 @@ func mapInboundEvent(kind int64, seqID int64, text, operator, rawTimestamp, sour
}
case 26:
mapped.ContactName, mapped.ContactPhone = callbackIdentity(text)
mapped.RequiresContact = true
activity("访客请求电话回拨")
mapped.Strategy = "activity"
case 29:
@@ -158,7 +160,10 @@ func mapInboundEvent(kind int64, seqID int64, text, operator, rawTimestamp, sour
case 35:
mapped.Strategy, mapped.RawOnly, mapped.RequiresContact = "contact_attributes", false, true
mapped.ContactAttributes = map[string]any{"swt_third_party_source": truncate(cleanText(text), 2048)}
case 41, 61:
case 41:
mapped.Strategy, mapped.RawOnly, mapped.RequiresContact, mapped.RequiresConversation = "conversation_attributes", false, true, true
mapped.ConversationAttrs = map[string]any{"swt_outcome": cleanText(text)}
case 61:
mapped.Strategy, mapped.RawOnly, mapped.RequiresContact = "contact_attributes", false, true
mapped.ContactName = cleanText(text)
case 52:
@@ -172,7 +177,6 @@ func mapInboundEvent(kind int64, seqID int64, text, operator, rawTimestamp, sour
case 65:
mapped.Strategy, mapped.RawOnly, mapped.RequiresContact = "contact_attributes", false, true
mapped.ContactAttributes = parseXSTProfile(text)
mapped.ContactName = visitorDisplayName(mapped.ContactAttributes)
case 66:
mapped.Strategy, mapped.RawOnly, mapped.RequiresContact, mapped.RequiresConversation = "conversation_attributes", false, true, true
mapped.ConversationAttrs = parseSearchSource(text)
@@ -438,16 +442,16 @@ func cleanText(value string) string {
}
func parseEnvironment(text string) map[string]any {
parts := strings.Fields(text)
indexes := map[int]string{0: "swt_ip", 1: "swt_ip_location", 2: "swt_isp", 7: "swt_resolution", 8: "swt_color_depth", 9: "swt_language", 10: "swt_timezone", 11: "swt_os", 13: "swt_browser", 14: "swt_browser_version"}
parts := strings.Split(text, " ")
indexes := map[int]string{0: "swt_ip", 1: "swt_ip_location", 4: "swt_environment_version", 7: "swt_resolution", 9: "swt_language", 16: "swt_timezone", 17: "swt_user_agent"}
result := map[string]any{}
for index, key := range indexes {
if index < len(parts) && parts[index] != "0" && parts[index] != "null" {
result[key] = truncate(decodeSWTValue(parts[index]), 512)
}
}
if len(parts) > 19 {
result["swt_user_agent"] = truncate(decodeSWTValue(strings.Join(parts[19:], " ")), 1024)
if userAgent, ok := result["swt_user_agent"].(string); ok && userAgent != "" {
setVisitorUserAgentAttributes(result, userAgent)
}
if location, ok := result["swt_ip_location"].(string); ok && location != "" {
result["city"] = location
@@ -455,6 +459,86 @@ func parseEnvironment(text string) map[string]any {
return result
}
func setVisitorUserAgentAttributes(attributes map[string]any, userAgent string) {
operatingSystem, device := parseVisitorUserAgent(userAgent)
if operatingSystem != "" {
attributes["swt_os"] = operatingSystem
}
if device != "" {
attributes["swt_device"] = device
}
}
func shangwutongLocationAttributes(attributes map[string]any) map[string]any {
location, _ := attributes["swt_ip_location"].(string)
location = strings.TrimSpace(location)
if location == "" {
return nil
}
return map[string]any{
"city": location,
"country": "China",
"country_code": "CN",
}
}
func parseVisitorUserAgent(userAgent string) (operatingSystem, device string) {
parts := strings.Split(userAgent, ";")
for index := range parts {
parts[index] = strings.TrimSpace(parts[index])
}
switch {
case strings.Contains(userAgent, "OpenHarmony"):
match := regexp.MustCompile(`OpenHarmony\s+([\d.]+)`).FindStringSubmatch(userAgent)
operatingSystem = "OpenHarmony"
if len(match) > 1 {
operatingSystem += " " + match[1]
}
device = firstText(firstEnvironmentPart(parts), "HarmonyOS 设备")
case strings.Contains(userAgent, "Android"):
match := regexp.MustCompile(`Android\s+([^;]+)`).FindStringSubmatch(userAgent)
operatingSystem = "Android"
if len(match) > 1 {
operatingSystem += " " + strings.TrimSpace(match[1])
}
for _, part := range parts {
if strings.Contains(part, " Build/") {
device = strings.TrimSpace(strings.SplitN(part, " Build/", 2)[0])
break
}
}
if device == "" {
device = firstText(last(parts), "Android 设备")
}
case strings.Contains(userAgent, "iPhone OS"):
match := regexp.MustCompile(`iPhone OS\s+([\d_]+)`).FindStringSubmatch(userAgent)
operatingSystem, device = "iOS", "iPhone"
if len(match) > 1 {
operatingSystem += " " + strings.ReplaceAll(match[1], "_", ".")
}
case strings.Contains(userAgent, "Windows NT 10.0"):
operatingSystem, device = "Windows 10/11", "Windows PC"
case strings.Contains(userAgent, "Windows"):
operatingSystem, device = "Windows", "Windows PC"
case strings.Contains(userAgent, "Mac OS X"):
match := regexp.MustCompile(`Mac OS X\s+([\d_]+)`).FindStringSubmatch(userAgent)
operatingSystem, device = "macOS", "Mac"
if len(match) > 1 {
operatingSystem += " " + strings.ReplaceAll(match[1], "_", ".")
}
case strings.Contains(userAgent, "Linux"):
operatingSystem, device = "Linux", "Linux 设备"
}
return operatingSystem, device
}
func firstEnvironmentPart(parts []string) string {
if len(parts) == 0 {
return ""
}
return parts[0]
}
func parseXSTProfile(text string) map[string]any {
result := map[string]any{"swt_xst_profile": truncate(cleanText(text), 2048)}
profiles := strings.Split(text, "|||||")
@@ -465,7 +549,7 @@ func parseXSTProfile(text string) map[string]any {
fields := map[int]string{
0: "swt_profile_channel", 1: "swt_visitor_nickname", 2: "swt_query_title",
3: "swt_query_word", 4: "swt_profile_location", 5: "swt_site_id",
8: "swt_traffic_mode", 10: "swt_resolution", 11: "swt_device",
8: "swt_traffic_mode", 10: "swt_resolution", 11: "swt_user_agent",
12: "swt_ip", 13: "swt_traffic_source",
}
for index, key := range fields {
@@ -480,34 +564,12 @@ func parseXSTProfile(text string) map[string]any {
if location, ok := result["swt_profile_location"].(string); ok && location != "" {
result["city"] = location
}
if userAgent, ok := result["swt_user_agent"].(string); ok && userAgent != "" {
setVisitorUserAgentAttributes(result, userAgent)
}
return result
}
func visitorDisplayName(attributes map[string]any) string {
for _, key := range []string{"swt_visitor_nickname", "swt_ip_location", "swt_profile_location", "swt_ip"} {
if value, ok := attributes[key].(string); ok && usableVisitorLabel(key, value) {
return truncate(strings.TrimSpace(value), 64)
}
}
return ""
}
func usableVisitorLabel(key, value string) bool {
value = strings.TrimSpace(value)
if value == "" {
return false
}
if key != "swt_visitor_nickname" {
return true
}
// XST may put an opaque tracking token in the nickname slot. Never expose
// that token as the contact name; fall back to location or IP instead.
if len([]rune(value)) > 32 || regexp.MustCompile(`^[A-Za-z0-9_-]{24,}$`).MatchString(value) {
return false
}
return true
}
func decodeSWTValue(value string) string {
value = strings.TrimSpace(value)
if decoded, err := url.QueryUnescape(value); err == nil {
@@ -554,7 +616,7 @@ func callbackIdentity(text string) (string, string) {
if phone != "" && !strings.HasPrefix(phone, "+") {
phone = "+86" + strings.TrimPrefix(phone, "86")
}
name := strings.TrimSpace(strings.Replace(cleaned, phonePattern.FindString(cleaned), "", 1))
name := strings.Trim(strings.TrimSpace(strings.Replace(cleaned, phonePattern.FindString(cleaned), "", 1)), "|,,::;-_")
return truncate(name, 255), phone
}
@@ -39,19 +39,57 @@ func TestMapInboundEventCoreStrategies(t *testing.T) {
}
}
func TestKind2UsesSequenceAsExternalMessageID(t *testing.T) {
mapped := mapInboundEvent(2, 98765, "hello", "", "", "swt:10:sid:2:98765:0", 10, time.Now())
func TestKind2UsesSequenceAsExternalMessageIDWithoutOverwritingContactName(t *testing.T) {
mapped := mapInboundEvent(2, 98765, "hello", "商务通客服名称", "", "swt:10:sid:2:98765:0", 10, time.Now())
if mapped.Message == nil || mapped.Message.ExternalSourceIDs["shangwutong"] != "98765" {
t.Fatalf("external source IDs = %#v", mapped.Message)
}
if mapped.ContactName != "" || mapped.Message.AdditionalAttributes["senderName"] != "商务通访客" {
t.Fatalf("visitor identity = %#v", mapped)
}
}
func TestKind2FallsBackToGenericNameWhenMessageHasNoVisitorName(t *testing.T) {
mapped := mapInboundEvent(2, 98765, "hello", "", "", "swt:10:sid:2:98765:0", 10, time.Now())
if mapped.ContactName != "" || mapped.Message.AdditionalAttributes["senderName"] != "商务通访客" {
t.Fatalf("visitor identity = %#v", mapped)
}
}
func TestKind12UpdatesContactNickname(t *testing.T) {
mapped := mapInboundEvent(12, 42, "贵州贵阳客人1219", "", "", "source", 10, time.Now())
if mapped.ContactName != "贵州贵阳客人1219" || !mapped.RequiresContact || mapped.RequiresConversation {
t.Fatalf("visitor nickname mapping = %#v", mapped)
}
}
func TestKind26UpdatesCurrentContactPhone(t *testing.T) {
mapped := mapInboundEvent(26, 42, "张三|13800138000", "", "", "source", 10, time.Now())
if mapped.ContactName != "张三" || mapped.ContactPhone != "+8613800138000" || !mapped.RequiresContact {
t.Fatalf("callback identity mapping = %#v", mapped)
}
}
func TestKind41StoresConversationOutcomeWithoutOverwritingContactName(t *testing.T) {
mapped := mapInboundEvent(41, 42, "留联", "", "", "source", 10, time.Now())
if mapped.ContactName != "" || !mapped.RequiresConversation || mapped.ConversationAttrs["swt_outcome"] != "留联" {
t.Fatalf("conversation outcome mapping = %#v", mapped)
}
}
func TestVisitorProfileMapsContactNameAndAttributes(t *testing.T) {
environment := mapInboundEvent(7, 1, "220.197.4.178 %e8%b4%b5%e5%b7%9e%e8%b4%b5%e9%98%b3 ISP 0 0 0 0 393x798 24 zh-CN +8 iPhone 0 Safari 18", "", "", "source", 10, time.Now())
if environment.ContactName != "贵州贵阳" {
environment := mapInboundEvent(7, 1, "220.197.4.178 %e8%b4%b5%e5%b7%9e%e8%b4%b5%e9%98%b3 5.6 1 1 393x798 zh-CN iPhone iPhone%3b+CPU+iPhone+OS+18_7+like+Mac+OS+X ", "", "", "source", 10, time.Now())
if environment.ContactName != "" {
t.Fatalf("environment contact name = %q", environment.ContactName)
}
if environment.ContactAttributes["swt_ip"] != "220.197.4.178" || environment.ContactAttributes["city"] != "贵州贵阳" {
if environment.ContactAttributes["swt_ip"] != "220.197.4.178" ||
environment.ContactAttributes["swt_ip_location"] != "贵州贵阳" ||
environment.ContactAttributes["city"] != "贵州贵阳" ||
environment.ContactAttributes["swt_resolution"] != "393x798" ||
environment.ContactAttributes["swt_environment_version"] != "5.6" ||
environment.ContactAttributes["swt_user_agent"] != "iPhone; CPU iPhone OS 18_7 like Mac OS X" ||
environment.ContactAttributes["swt_os"] != "iOS 18.7" ||
environment.ContactAttributes["swt_device"] != "iPhone" {
t.Fatalf("environment attributes = %#v", environment.ContactAttributes)
}
@@ -60,19 +98,42 @@ func TestVisitorProfileMapsContactNameAndAttributes(t *testing.T) {
"xst%7csbox%7czhinengzx", "医院", "fc", "1", "393x798", "iPhone%3b+CPU+iPhone+OS+18_7", "220.197.4.178", "百度搜索推广",
}, "\x1a")
profile := mapInboundEvent(65, 2, profileText, "", "", "source", 10, time.Now())
if profile.ContactName != "访客昵称" {
if profile.ContactName != "" {
t.Fatalf("profile contact name = %q", profile.ContactName)
}
if profile.ContactAttributes["swt_query_word"] != "甘油三酯高怎么办" || profile.ContactAttributes["swt_device"] != "iPhone; CPU iPhone OS 18_7" {
if profile.ContactAttributes["swt_query_word"] != "甘油三酯高怎么办" ||
profile.ContactAttributes["swt_user_agent"] != "iPhone; CPU iPhone OS 18_7" ||
profile.ContactAttributes["swt_device"] != "iPhone" ||
profile.ContactAttributes["swt_os"] != "iOS 18.7" {
t.Fatalf("profile attributes = %#v", profile.ContactAttributes)
}
profileText = strings.Join([]string{
"xst|sbox|zhinengzx", "TLnqPW6zPjDdPH9BPAc1nh7WPhfsuy79uH9-nHwBmy7WmHRKnHTvPjmsrjD1ns", "胆固醇高", "甘油三酯高怎么办", "贵州贵阳", "48989266",
}, "\x1a")
profile = mapInboundEvent(65, 3, profileText, "", "", "source", 10, time.Now())
if profile.ContactName != "贵州贵阳" {
t.Fatalf("opaque nickname fallback contact name = %q", profile.ContactName)
}
func TestParseVisitorUserAgent(t *testing.T) {
tests := []struct {
userAgent string
os string
device string
}{
{"Linux; Android 16; V2302A Build/BP2A.250605.031.A3; wv", "Android 16", "V2302A"},
{"Phone; OpenHarmony 6.1", "OpenHarmony 6.1", "Phone"},
{"iPhone; CPU iPhone OS 18_7 like Mac OS X", "iOS 18.7", "iPhone"},
{"Windows NT 10.0; Win64; x64", "Windows 10/11", "Windows PC"},
{"Macintosh; Intel Mac OS X 10_15_7", "macOS 10.15.7", "Mac"},
}
for _, test := range tests {
operatingSystem, device := parseVisitorUserAgent(test.userAgent)
if operatingSystem != test.os || device != test.device {
t.Fatalf("parseVisitorUserAgent(%q) = %q, %q", test.userAgent, operatingSystem, device)
}
}
}
func TestShangwutongLocationAttributesDefaultToChina(t *testing.T) {
attributes := shangwutongLocationAttributes(map[string]any{"swt_ip_location": "贵州贵阳"})
if attributes["country_code"] != "CN" || attributes["country"] != "China" || attributes["city"] != "贵州贵阳" {
t.Fatalf("location attributes = %#v", attributes)
}
}
@@ -96,12 +157,12 @@ func TestKnownKindMappingMatrix(t *testing.T) {
{3, "hello", "native_message", false}, {5, "unverified", "raw_only", true},
{7, "127.0.0.1 Beijing ISP 0 0 0 0 1920x1080 24 zh-CN +8 Windows 0 Chrome 149", "contact_attributes", false},
{8, "https://example.test 0 Landing 0 0 0 friendlink", "conversation_attributes", false},
{11, "3", "raw_only", true}, {12, "alias", "conversation_attributes", false},
{11, "3", "raw_only", true}, {12, "alias", "contact_attributes", false},
{15, "filemsg|name|https://media.example/file", "native_message", false},
{26, "张三|13800138000", "activity", false}, {29, "red", "conversation_attributes", false},
{30, "system", "activity", false}, {31, "guest_open_chat", "activity", false},
{34, "active", "conversation_attributes", false}, {35, "source", "contact_attributes", false},
{39, "unverified", "raw_only", true}, {41, "访客", "contact_attributes", false},
{39, "unverified", "raw_only", true}, {41, "留联", "conversation_attributes", false},
{52, "unverified#history", "raw_only", true}, {56, "web", "conversation_attributes", false},
{58, "left", "activity", false}, {61, "访客", "contact_attributes", false},
{65, "profile", "contact_attributes", false}, {66, "keyword|referrer|engine", "conversation_attributes", false},
@@ -1,8 +1,8 @@
<script>
import { useAlert } from 'dashboard/composables';
import {
DuplicateContactException,
ExceptionWithMessage,
DuplicateContactException,
ExceptionWithMessage,
} from 'shared/helpers/CustomErrors';
import { required, email } from '@vuelidate/validators';
import { useVuelidate } from '@vuelidate/core';
@@ -14,424 +14,472 @@ import Avatar from 'next/avatar/Avatar.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
export default {
components: {
NextButton,
Avatar,
ComboBox,
},
props: {
contact: {
type: Object,
default: () => ({}),
},
inProgress: {
type: Boolean,
default: false,
},
onSubmit: {
type: Function,
default: () => {},
},
},
emits: ['cancel', 'success'],
setup() {
return { v$: useVuelidate() };
},
data() {
return {
countries: countries,
companyName: '',
description: '',
email: '',
name: '',
phoneNumber: '',
activeDialCode: '',
avatarFile: null,
avatarUrl: '',
country: {
id: '',
name: '',
},
city: '',
socialProfileUserNames: {
facebook: '',
twitter: '',
linkedin: '',
github: '',
telegram: '',
},
socialProfileKeys: [
{ key: 'facebook', prefixURL: 'https://facebook.com/' },
{ key: 'twitter', prefixURL: 'https://twitter.com/' },
{ key: 'linkedin', prefixURL: 'https://linkedin.com/' },
{ key: 'github', prefixURL: 'https://github.com/' },
{ key: 'telegram', prefixURL: 'https://t.me/' },
{ key: 'tiktok', prefixURL: 'https://tiktok.com/@' },
],
};
},
validations: {
name: {
required,
},
description: {},
email: {
email,
},
companyName: {},
phoneNumber: {},
bio: {},
},
computed: {
parsePhoneNumber() {
return parsePhoneNumber(this.phoneNumber);
},
isPhoneNumberNotValid() {
if (this.phoneNumber !== '') {
return (
!isPhoneNumberValid(this.phoneNumber, this.activeDialCode) ||
(this.phoneNumber !== '' ? this.activeDialCode === '' : false)
);
}
return false;
},
phoneNumberError() {
if (this.activeDialCode === '') {
return this.$t('CONTACT_FORM.FORM.PHONE_NUMBER.DIAL_CODE_ERROR');
}
if (!isPhoneNumberValid(this.phoneNumber, this.activeDialCode)) {
return this.$t('CONTACT_FORM.FORM.PHONE_NUMBER.ERROR');
}
return '';
},
setPhoneNumber() {
if (this.parsePhoneNumber && this.parsePhoneNumber.countryCallingCode) {
return this.phoneNumber;
}
if (this.phoneNumber === '' && this.activeDialCode !== '') {
return '';
}
return this.activeDialCode
? `${this.activeDialCode}${this.phoneNumber}`
: '';
},
},
watch: {
contact() {
this.setContactObject();
},
},
mounted() {
this.setContactObject();
this.setDialCode();
},
methods: {
onCancel() {
this.$emit('cancel');
},
onSuccess() {
this.$emit('success');
},
countryNameWithCode({ name, id }) {
if (!id) return name;
if (!name && !id) return '';
return `${name} (${id})`;
},
onCountryChange(value) {
const selected = this.countries.find(c => c.id === value);
this.country = selected
? { id: selected.id, name: selected.name }
: { id: '', name: '' };
},
setDialCode() {
if (
this.phoneNumber !== '' &&
this.parsePhoneNumber &&
this.parsePhoneNumber.countryCallingCode
) {
const dialCode = this.parsePhoneNumber.countryCallingCode;
this.activeDialCode = `+${dialCode}`;
}
},
setContactObject() {
const {
email: emailAddress,
phone_number: phoneNumber,
name,
} = this.contact;
const additionalAttributes = this.contact.additional_attributes || {};
components: {
NextButton,
Avatar,
ComboBox,
},
props: {
contact: {
type: Object,
default: () => ({}),
},
inProgress: {
type: Boolean,
default: false,
},
onSubmit: {
type: Function,
default: () => {},
},
},
emits: ['cancel', 'success'],
setup() {
return { v$: useVuelidate() };
},
data() {
return {
countries: countries,
companyName: '',
description: '',
email: '',
name: '',
phoneNumber: '',
activeDialCode: '',
avatarFile: null,
avatarUrl: '',
country: {
id: '',
name: '',
},
city: '',
socialProfileUserNames: {
facebook: '',
twitter: '',
linkedin: '',
github: '',
telegram: '',
},
socialProfileKeys: [
{ key: 'facebook', prefixURL: 'https://facebook.com/' },
{ key: 'twitter', prefixURL: 'https://twitter.com/' },
{ key: 'linkedin', prefixURL: 'https://linkedin.com/' },
{ key: 'github', prefixURL: 'https://github.com/' },
{ key: 'telegram', prefixURL: 'https://t.me/' },
{ key: 'tiktok', prefixURL: 'https://tiktok.com/@' },
],
};
},
validations: {
name: {
required,
},
description: {},
email: {
email,
},
companyName: {},
phoneNumber: {},
bio: {},
},
computed: {
parsePhoneNumber() {
return parsePhoneNumber(this.phoneNumber);
},
isPhoneNumberNotValid() {
if (this.phoneNumber !== '') {
return (
!isPhoneNumberValid(
this.phoneNumber,
this.activeDialCode
) ||
(this.phoneNumber !== ''
? this.activeDialCode === ''
: false)
);
}
return false;
},
phoneNumberError() {
if (this.activeDialCode === '') {
return this.$t(
'CONTACT_FORM.FORM.PHONE_NUMBER.DIAL_CODE_ERROR'
);
}
if (!isPhoneNumberValid(this.phoneNumber, this.activeDialCode)) {
return this.$t('CONTACT_FORM.FORM.PHONE_NUMBER.ERROR');
}
return '';
},
setPhoneNumber() {
if (
this.parsePhoneNumber &&
this.parsePhoneNumber.countryCallingCode
) {
return this.phoneNumber;
}
if (this.phoneNumber === '' && this.activeDialCode !== '') {
return '';
}
return this.activeDialCode
? `${this.activeDialCode}${this.phoneNumber}`
: '';
},
},
watch: {
contact() {
this.setContactObject();
},
},
mounted() {
this.setContactObject();
this.setDialCode();
},
methods: {
onCancel() {
this.$emit('cancel');
},
onSuccess() {
this.$emit('success');
},
countryNameWithCode({ name, id }) {
if (!id) return name;
if (!name && !id) return '';
return `${name} (${id})`;
},
onCountryChange(value) {
const selected = this.countries.find(c => c.id === value);
this.country = selected
? { id: selected.id, name: selected.name }
: { id: '', name: '' };
this.setPhoneCode(selected?.dial_code || '');
},
setDialCode() {
if (
this.phoneNumber !== '' &&
this.parsePhoneNumber &&
this.parsePhoneNumber.countryCallingCode
) {
const dialCode = this.parsePhoneNumber.countryCallingCode;
this.activeDialCode = `+${dialCode}`;
}
},
setContactObject() {
const {
email: emailAddress,
phone_number: phoneNumber,
name,
} = this.contact;
const additionalAttributes =
this.contact.additional_attributes || {};
this.name = name || '';
this.email = emailAddress || '';
this.phoneNumber = phoneNumber || '';
this.companyName = additionalAttributes.company_name || '';
this.country = {
id: additionalAttributes.country_code || '',
name:
additionalAttributes.country ||
this.$t('CONTACT_FORM.FORM.COUNTRY.SELECT_COUNTRY'),
};
this.city = additionalAttributes.city || '';
this.description = additionalAttributes.description || '';
this.avatarUrl = this.contact.thumbnail || '';
const {
social_profiles: socialProfiles = {},
screen_name: twitterScreenName,
social_telegram_user_name: telegramUserName,
} = additionalAttributes;
this.socialProfileUserNames = {
twitter: socialProfiles.twitter || twitterScreenName || '',
facebook: socialProfiles.facebook || '',
linkedin: socialProfiles.linkedin || '',
github: socialProfiles.github || '',
telegram: socialProfiles.telegram || telegramUserName || '',
instagram: socialProfiles.instagram || '',
tiktok: socialProfiles.tiktok || '',
};
},
getContactObject() {
if (this.country === null) {
this.country = {
id: '',
name: '',
};
}
const contactObject = {
id: this.contact.id,
name: this.name,
email: this.email,
phone_number: this.setPhoneNumber,
additional_attributes: {
...this.contact.additional_attributes,
description: this.description,
company_name: this.companyName,
country_code: this.country.id,
country:
this.country.name ===
this.$t('CONTACT_FORM.FORM.COUNTRY.SELECT_COUNTRY')
? ''
: this.country.name,
city: this.city,
social_profiles: this.socialProfileUserNames,
},
};
if (this.avatarFile) {
contactObject.avatar = this.avatarFile;
contactObject.isFormData = true;
}
return contactObject;
},
setPhoneCode(code) {
if (this.phoneNumber !== '' && this.parsePhoneNumber) {
const dialCode = this.parsePhoneNumber.countryCallingCode;
if (dialCode === code) {
return;
}
this.activeDialCode = `+${dialCode}`;
const newPhoneNumber = this.phoneNumber.replace(
`+${dialCode}`,
`${code}`
);
this.phoneNumber = newPhoneNumber;
} else {
this.activeDialCode = code;
}
},
async handleSubmit() {
this.v$.$touch();
if (this.v$.$invalid || this.isPhoneNumberNotValid) {
return;
}
try {
await this.onSubmit(this.getContactObject());
this.onSuccess();
useAlert(this.$t('CONTACT_FORM.SUCCESS_MESSAGE'));
} catch (error) {
if (error instanceof DuplicateContactException) {
if (error.data.includes('email')) {
useAlert(this.$t('CONTACT_FORM.FORM.EMAIL_ADDRESS.DUPLICATE'));
} else if (error.data.includes('phone_number')) {
useAlert(this.$t('CONTACT_FORM.FORM.PHONE_NUMBER.DUPLICATE'));
}
} else if (error instanceof ExceptionWithMessage) {
useAlert(error.data);
} else {
useAlert(this.$t('CONTACT_FORM.ERROR_MESSAGE'));
}
}
},
handleImageUpload({ file, url }) {
this.avatarFile = file;
this.avatarUrl = url;
},
async handleAvatarDelete() {
try {
if (this.contact && this.contact.id) {
await this.$store.dispatch('contacts/deleteAvatar', this.contact.id);
useAlert(this.$t('CONTACT_FORM.DELETE_AVATAR.API.SUCCESS_MESSAGE'));
}
this.avatarFile = null;
this.avatarUrl = '';
this.activeDialCode = '';
} catch (error) {
useAlert(
error.message
? error.message
: this.$t('CONTACT_FORM.DELETE_AVATAR.API.ERROR_MESSAGE')
);
}
},
},
this.name = name || '';
this.email = emailAddress || '';
this.phoneNumber = phoneNumber || '';
this.companyName = additionalAttributes.company_name || '';
this.country = {
id:
additionalAttributes.country_code ||
this.contact.country_code ||
'',
name:
additionalAttributes.country ||
this.countries.find(
country =>
country.id ===
(additionalAttributes.country_code ||
this.contact.country_code)
)?.name ||
this.$t('CONTACT_FORM.FORM.COUNTRY.SELECT_COUNTRY'),
};
if (!this.phoneNumber && this.country.id) {
this.activeDialCode =
this.countries.find(
country => country.id === this.country.id
)?.dial_code || '';
}
this.city = additionalAttributes.city || '';
this.description = additionalAttributes.description || '';
this.avatarUrl = this.contact.thumbnail || '';
const {
social_profiles: socialProfiles = {},
screen_name: twitterScreenName,
social_telegram_user_name: telegramUserName,
} = additionalAttributes;
this.socialProfileUserNames = {
twitter: socialProfiles.twitter || twitterScreenName || '',
facebook: socialProfiles.facebook || '',
linkedin: socialProfiles.linkedin || '',
github: socialProfiles.github || '',
telegram: socialProfiles.telegram || telegramUserName || '',
instagram: socialProfiles.instagram || '',
tiktok: socialProfiles.tiktok || '',
};
},
getContactObject() {
if (this.country === null) {
this.country = {
id: '',
name: '',
};
}
const contactObject = {
id: this.contact.id,
name: this.name,
email: this.email,
phone_number: this.setPhoneNumber,
additional_attributes: {
...this.contact.additional_attributes,
description: this.description,
company_name: this.companyName,
country_code: this.country.id,
country:
this.country.name ===
this.$t('CONTACT_FORM.FORM.COUNTRY.SELECT_COUNTRY')
? ''
: this.country.name,
city: this.city,
social_profiles: this.socialProfileUserNames,
},
};
if (this.avatarFile) {
contactObject.avatar = this.avatarFile;
contactObject.isFormData = true;
}
return contactObject;
},
setPhoneCode(code) {
if (this.phoneNumber !== '' && this.parsePhoneNumber) {
const dialCode = this.parsePhoneNumber.countryCallingCode;
if (dialCode === code) {
return;
}
this.activeDialCode = `+${dialCode}`;
const newPhoneNumber = this.phoneNumber.replace(
`+${dialCode}`,
`${code}`
);
this.phoneNumber = newPhoneNumber;
} else {
this.activeDialCode = code;
}
},
async handleSubmit() {
this.v$.$touch();
if (this.v$.$invalid || this.isPhoneNumberNotValid) {
return;
}
try {
await this.onSubmit(this.getContactObject());
this.onSuccess();
useAlert(this.$t('CONTACT_FORM.SUCCESS_MESSAGE'));
} catch (error) {
if (error instanceof DuplicateContactException) {
if (error.data.includes('email')) {
useAlert(
this.$t('CONTACT_FORM.FORM.EMAIL_ADDRESS.DUPLICATE')
);
} else if (error.data.includes('phone_number')) {
useAlert(
this.$t('CONTACT_FORM.FORM.PHONE_NUMBER.DUPLICATE')
);
}
} else if (error instanceof ExceptionWithMessage) {
useAlert(error.data);
} else {
useAlert(this.$t('CONTACT_FORM.ERROR_MESSAGE'));
}
}
},
handleImageUpload({ file, url }) {
this.avatarFile = file;
this.avatarUrl = url;
},
async handleAvatarDelete() {
try {
if (this.contact && this.contact.id) {
await this.$store.dispatch(
'contacts/deleteAvatar',
this.contact.id
);
useAlert(
this.$t(
'CONTACT_FORM.DELETE_AVATAR.API.SUCCESS_MESSAGE'
)
);
}
this.avatarFile = null;
this.avatarUrl = '';
this.activeDialCode = '';
} catch (error) {
useAlert(
error.message
? error.message
: this.$t(
'CONTACT_FORM.DELETE_AVATAR.API.ERROR_MESSAGE'
)
);
}
},
},
};
</script>
<template>
<form
class="w-full px-8 pt-6 pb-8 contact--form"
@submit.prevent="handleSubmit"
>
<div class="flex flex-col mb-4 items-start gap-1 w-full">
<label class="mb-0.5 text-sm font-medium text-n-slate-12">
{{ $t('CONTACT_FORM.FORM.AVATAR.LABEL') }}
</label>
<Avatar
:src="avatarUrl"
:size="72"
:name="contact.name"
allow-upload
rounded-full
@upload="handleImageUpload"
@delete="handleAvatarDelete"
/>
</div>
<div>
<div class="w-full">
<label :class="{ error: v$.name.$error }">
{{ $t('CONTACT_FORM.FORM.NAME.LABEL') }}
<input
v-model="name"
type="text"
:placeholder="$t('CONTACT_FORM.FORM.NAME.PLACEHOLDER')"
@input="v$.name.$touch"
/>
</label>
<form
class="w-full px-8 pt-6 pb-8 contact--form"
@submit.prevent="handleSubmit"
>
<div class="flex flex-col mb-4 items-start gap-1 w-full">
<label class="mb-0.5 text-sm font-medium text-n-slate-12">
{{ $t('CONTACT_FORM.FORM.AVATAR.LABEL') }}
</label>
<Avatar
:src="avatarUrl"
:size="72"
:name="contact.name"
allow-upload
rounded-full
@upload="handleImageUpload"
@delete="handleAvatarDelete"
/>
</div>
<div>
<div class="w-full">
<label :class="{ error: v$.name.$error }">
{{ $t('CONTACT_FORM.FORM.NAME.LABEL') }}
<input
v-model="name"
type="text"
:placeholder="$t('CONTACT_FORM.FORM.NAME.PLACEHOLDER')"
@input="v$.name.$touch"
/>
</label>
<label :class="{ error: v$.email.$error }">
{{ $t('CONTACT_FORM.FORM.EMAIL_ADDRESS.LABEL') }}
<input
v-model="email"
type="text"
:placeholder="$t('CONTACT_FORM.FORM.EMAIL_ADDRESS.PLACEHOLDER')"
@input="v$.email.$touch"
/>
<span v-if="v$.email.$error" class="message">
{{ $t('CONTACT_FORM.FORM.EMAIL_ADDRESS.ERROR') }}
</span>
</label>
</div>
</div>
<div class="w-full">
<label :class="{ error: v$.description.$error }">
{{ $t('CONTACT_FORM.FORM.BIO.LABEL') }}
<textarea
v-model="description"
type="text"
:placeholder="$t('CONTACT_FORM.FORM.BIO.PLACEHOLDER')"
@input="v$.description.$touch"
/>
</label>
</div>
<div>
<div class="w-full">
<label
:class="{
error: isPhoneNumberNotValid,
}"
>
{{ $t('CONTACT_FORM.FORM.PHONE_NUMBER.LABEL') }}
<woot-phone-input
v-model="phoneNumber"
:value="phoneNumber"
:error="isPhoneNumberNotValid"
:placeholder="$t('CONTACT_FORM.FORM.PHONE_NUMBER.PLACEHOLDER')"
@blur="v$.phoneNumber.$touch"
@set-code="setPhoneCode"
/>
<span v-if="isPhoneNumberNotValid" class="message">
{{ phoneNumberError }}
</span>
</label>
<div
v-if="isPhoneNumberNotValid || !phoneNumber"
class="relative mx-0 mt-0 mb-2.5 p-2 rounded-md text-sm border border-solid border-n-amber-5 text-n-amber-12 bg-n-amber-3"
>
{{ $t('CONTACT_FORM.FORM.PHONE_NUMBER.HELP') }}
</div>
</div>
</div>
<woot-input
v-model="companyName"
class="w-full"
:label="$t('CONTACT_FORM.FORM.COMPANY_NAME.LABEL')"
:placeholder="$t('CONTACT_FORM.FORM.COMPANY_NAME.PLACEHOLDER')"
/>
<div class="w-full mb-4">
<label>
{{ $t('CONTACT_FORM.FORM.COUNTRY.LABEL') }}
</label>
<ComboBox
:model-value="country.id"
:options="
countries.map(c => ({
value: c.id,
label: countryNameWithCode(c),
}))
"
class="[&>div>button]:!bg-n-alpha-black2"
:placeholder="$t('CONTACT_FORM.FORM.COUNTRY.PLACEHOLDER')"
:search-placeholder="$t('CONTACT_FORM.FORM.COUNTRY.SELECT_PLACEHOLDER')"
@update:model-value="onCountryChange"
/>
</div>
<woot-input
v-model="city"
class="w-full"
:label="$t('CONTACT_FORM.FORM.CITY.LABEL')"
:placeholder="$t('CONTACT_FORM.FORM.CITY.PLACEHOLDER')"
/>
<label :class="{ error: v$.email.$error }">
{{ $t('CONTACT_FORM.FORM.EMAIL_ADDRESS.LABEL') }}
<input
v-model="email"
type="text"
:placeholder="
$t('CONTACT_FORM.FORM.EMAIL_ADDRESS.PLACEHOLDER')
"
@input="v$.email.$touch"
/>
<span v-if="v$.email.$error" class="message">
{{ $t('CONTACT_FORM.FORM.EMAIL_ADDRESS.ERROR') }}
</span>
</label>
</div>
</div>
<div class="w-full">
<label :class="{ error: v$.description.$error }">
{{ $t('CONTACT_FORM.FORM.BIO.LABEL') }}
<textarea
v-model="description"
type="text"
:placeholder="$t('CONTACT_FORM.FORM.BIO.PLACEHOLDER')"
@input="v$.description.$touch"
/>
</label>
</div>
<div>
<div class="w-full">
<label
:class="{
error: isPhoneNumberNotValid,
}"
>
{{ $t('CONTACT_FORM.FORM.PHONE_NUMBER.LABEL') }}
<woot-phone-input
v-model="phoneNumber"
:value="phoneNumber"
:error="isPhoneNumberNotValid"
:placeholder="
$t('CONTACT_FORM.FORM.PHONE_NUMBER.PLACEHOLDER')
"
@blur="v$.phoneNumber.$touch"
@set-code="setPhoneCode"
/>
<span v-if="isPhoneNumberNotValid" class="message">
{{ phoneNumberError }}
</span>
</label>
<div
v-if="isPhoneNumberNotValid || !phoneNumber"
class="relative mx-0 mt-0 mb-2.5 p-2 rounded-md text-sm border border-solid border-n-amber-5 text-n-amber-12 bg-n-amber-3"
>
{{ $t('CONTACT_FORM.FORM.PHONE_NUMBER.HELP') }}
</div>
</div>
</div>
<woot-input
v-model="companyName"
class="w-full"
:label="$t('CONTACT_FORM.FORM.COMPANY_NAME.LABEL')"
:placeholder="$t('CONTACT_FORM.FORM.COMPANY_NAME.PLACEHOLDER')"
/>
<div class="w-full mb-4">
<label>
{{ $t('CONTACT_FORM.FORM.COUNTRY.LABEL') }}
</label>
<ComboBox
:model-value="country.id"
:options="
countries.map(c => ({
value: c.id,
label: countryNameWithCode(c),
}))
"
class="[&>div>button]:!bg-n-alpha-black2"
:placeholder="$t('CONTACT_FORM.FORM.COUNTRY.PLACEHOLDER')"
:search-placeholder="
$t('CONTACT_FORM.FORM.COUNTRY.SELECT_PLACEHOLDER')
"
@update:model-value="onCountryChange"
/>
</div>
<woot-input
v-model="city"
class="w-full"
:label="$t('CONTACT_FORM.FORM.CITY.LABEL')"
:placeholder="$t('CONTACT_FORM.FORM.CITY.PLACEHOLDER')"
/>
<div class="w-full">
<label>{{ $t('CONTACTS_PAGE.LIST.TABLE_HEADER.SOCIAL_PROFILES') }}</label>
<div
v-for="socialProfile in socialProfileKeys"
:key="socialProfile.key"
class="flex items-stretch w-full mb-4"
>
<span
class="flex items-center h-10 px-2 text-sm border-solid border-y ltr:border-l rtl:border-r ltr:rounded-l-md rtl:rounded-r-md bg-n-solid-3 text-n-slate-11 border-n-weak"
>
{{ socialProfile.prefixURL }}
</span>
<input
v-model="socialProfileUserNames[socialProfile.key]"
class="input-group-field ltr:!rounded-l-none rtl:!rounded-r-none !mb-0"
type="text"
/>
</div>
</div>
<div class="flex flex-row justify-start w-full gap-2 px-0 py-2">
<NextButton
type="submit"
:label="$t('CONTACT_FORM.FORM.SUBMIT')"
:is-loading="inProgress"
/>
<NextButton
faded
slate
type="reset"
:label="$t('CONTACT_FORM.FORM.CANCEL')"
@click.prevent="onCancel"
/>
</div>
</form>
<div class="w-full">
<label>{{
$t('CONTACTS_PAGE.LIST.TABLE_HEADER.SOCIAL_PROFILES')
}}</label>
<div
v-for="socialProfile in socialProfileKeys"
:key="socialProfile.key"
class="flex items-stretch w-full mb-4"
>
<span
class="flex items-center h-10 px-2 text-sm border-solid border-y ltr:border-l rtl:border-r ltr:rounded-l-md rtl:rounded-r-md bg-n-solid-3 text-n-slate-11 border-n-weak"
>
{{ socialProfile.prefixURL }}
</span>
<input
v-model="socialProfileUserNames[socialProfile.key]"
class="input-group-field ltr:!rounded-l-none rtl:!rounded-r-none !mb-0"
type="text"
/>
</div>
</div>
<div class="flex flex-row justify-start w-full gap-2 px-0 py-2">
<NextButton
type="submit"
:label="$t('CONTACT_FORM.FORM.SUBMIT')"
:is-loading="inProgress"
/>
<NextButton
faded
slate
type="reset"
:label="$t('CONTACT_FORM.FORM.CANCEL')"
@click.prevent="onCancel"
/>
</div>
</form>
</template>
@@ -2,8 +2,8 @@
import { mapGetters } from 'vuex';
import { useAlert } from 'dashboard/composables';
import {
DuplicateContactException,
ExceptionWithMessage,
DuplicateContactException,
ExceptionWithMessage,
} from 'shared/helpers/CustomErrors';
import { dynamicTime } from 'shared/helpers/timeHelper';
import { useAdmin } from 'dashboard/composables/useAdmin';
@@ -19,341 +19,403 @@ import VoiceCallButton from 'dashboard/components-next/Contacts/VoiceCallButton.
import InlineInput from 'dashboard/components-next/inline-input/InlineInput.vue';
export default {
components: {
NextButton,
ContactInfoRow,
EditContact,
Avatar,
ComposeConversation,
SocialIcons,
ContactMergeModal,
ContactDeleteModal,
VoiceCallButton,
InlineInput,
},
props: {
contact: {
type: Object,
default: () => ({}),
},
showAvatar: {
type: Boolean,
default: true,
},
},
emits: ['panelClose'],
setup() {
const { isAdmin } = useAdmin();
return {
isAdmin,
};
},
data() {
return {
showEditModal: false,
isEditingName: false,
editName: '',
};
},
computed: {
...mapGetters({ uiFlags: 'contacts/getUIFlags' }),
contactProfileLink() {
return `/app/accounts/${this.$route.params.accountId}/contacts/${this.contact.id}`;
},
additionalAttributes() {
return this.contact.additional_attributes || {};
},
location() {
const {
country = '',
city = '',
country_code: countryCode,
} = this.additionalAttributes;
const cityAndCountry = [city, country].filter(item => !!item).join(', ');
components: {
NextButton,
ContactInfoRow,
EditContact,
Avatar,
ComposeConversation,
SocialIcons,
ContactMergeModal,
ContactDeleteModal,
VoiceCallButton,
InlineInput,
},
props: {
contact: {
type: Object,
default: () => ({}),
},
showAvatar: {
type: Boolean,
default: true,
},
},
emits: ['panelClose'],
setup() {
const { isAdmin } = useAdmin();
return {
isAdmin,
};
},
data() {
return {
showEditModal: false,
isEditingName: false,
editName: '',
};
},
computed: {
...mapGetters({ uiFlags: 'contacts/getUIFlags' }),
contactProfileLink() {
return `/app/accounts/${this.$route.params.accountId}/contacts/${this.contact.id}`;
},
additionalAttributes() {
return this.contact.additional_attributes || {};
},
quickPhonePrefix() {
const countryCode = (
this.additionalAttributes.country_code ||
this.contact.country_code ||
''
).toUpperCase();
const country = this.additionalAttributes.country || '';
return countryCode === 'CN' || country === 'China' ? '+86' : '';
},
location() {
const {
country = '',
city = '',
country_code: countryCode,
} = this.additionalAttributes;
const cityAndCountry = [city, country]
.filter(item => !!item)
.join(', ');
if (!cityAndCountry) {
return '';
}
return this.findCountryFlag(countryCode, cityAndCountry);
},
socialProfiles() {
const {
social_profiles: socialProfiles,
screen_name: twitterScreenName,
social_telegram_user_name: telegramUsername,
} = this.additionalAttributes;
if (!cityAndCountry) {
return '';
}
return this.findCountryFlag(countryCode, cityAndCountry);
},
socialProfiles() {
const {
social_profiles: socialProfiles,
screen_name: twitterScreenName,
social_telegram_user_name: telegramUsername,
} = this.additionalAttributes;
const telegram = socialProfiles?.telegram || telegramUsername || '';
const twitter = socialProfiles?.twitter || twitterScreenName || '';
const telegram = socialProfiles?.telegram || telegramUsername || '';
const twitter = socialProfiles?.twitter || twitterScreenName || '';
return {
...(socialProfiles || {}),
twitter,
telegram,
};
},
},
watch: {
'contact.id': {
handler(id) {
this.$store.dispatch('contacts/fetchContactableInbox', id);
},
immediate: true,
},
},
methods: {
dynamicTime,
toggleEditModal() {
this.showEditModal = !this.showEditModal;
},
findCountryFlag(countryCode, cityAndCountry) {
try {
if (!countryCode) {
return `${cityAndCountry} 🌎`;
}
return {
...(socialProfiles || {}),
twitter,
telegram,
};
},
},
watch: {
'contact.id': {
handler(id) {
this.$store.dispatch('contacts/fetchContactableInbox', id);
},
immediate: true,
},
},
methods: {
dynamicTime,
toggleEditModal() {
this.showEditModal = !this.showEditModal;
},
findCountryFlag(countryCode, cityAndCountry) {
try {
if (!countryCode) {
return `${cityAndCountry} 🌎`;
}
const code = countryCode?.toLowerCase();
return `${cityAndCountry} <span class="fi fi-${code} size-3.5"></span>`;
} catch (error) {
return '';
}
},
startEditingName() {
this.editName = this.contact.name || '';
this.isEditingName = true;
this.$nextTick(() => {
this.$refs.nameInput?.focus();
});
},
saveNameEdit() {
if (!this.isEditingName) return;
this.isEditingName = false;
const trimmed = this.editName.trim();
if (trimmed && trimmed !== this.contact.name) {
this.updateContactField({ name: trimmed });
}
},
cancelNameEdit() {
this.isEditingName = false;
},
onFieldUpdate(field, value) {
this.updateContactField({ [field]: value });
},
async updateContactField(attrs) {
const contactId = this.contact.id;
try {
await this.$store.dispatch('contacts/update', {
id: contactId,
...attrs,
});
useAlert(this.$t('CONTACT_FORM.SUCCESS_MESSAGE'));
await this.$store.dispatch('contacts/fetchContactableInbox', contactId);
} catch (error) {
if (error instanceof DuplicateContactException) {
const detail = error.contactErrorDetail;
if (detail) {
useAlert(detail);
} else {
const invalidAttrs = Array.isArray(error.data) ? error.data : [];
if (invalidAttrs.includes('email')) {
useAlert(this.$t('CONTACT_FORM.FORM.EMAIL_ADDRESS.DUPLICATE'));
} else if (invalidAttrs.includes('phone_number')) {
useAlert(this.$t('CONTACT_FORM.FORM.PHONE_NUMBER.DUPLICATE'));
} else {
useAlert(this.$t('CONTACT_FORM.ERROR_MESSAGE'));
}
}
} else if (error instanceof ExceptionWithMessage) {
useAlert(error.data);
} else {
useAlert(error.message || this.$t('CONTACT_FORM.ERROR_MESSAGE'));
}
}
},
},
const code = countryCode?.toLowerCase();
return `${cityAndCountry} <span class="fi fi-${code} size-3.5"></span>`;
} catch (error) {
return '';
}
},
startEditingName() {
this.editName = this.contact.name || '';
this.isEditingName = true;
this.$nextTick(() => {
this.$refs.nameInput?.focus();
});
},
saveNameEdit() {
if (!this.isEditingName) return;
this.isEditingName = false;
const trimmed = this.editName.trim();
if (trimmed && trimmed !== this.contact.name) {
this.updateContactField({ name: trimmed });
}
},
cancelNameEdit() {
this.isEditingName = false;
},
onFieldUpdate(field, value) {
const normalizedValue =
field === 'phone_number'
? this.normalizeQuickPhone(value)
: value;
this.updateContactField({ [field]: normalizedValue });
},
normalizeQuickPhone(value) {
const phone = value.trim().replace(/[\s()-]/g, '');
if (!phone || phone.startsWith('+')) return phone;
const countryCode = (
this.additionalAttributes.country_code ||
this.contact.country_code ||
''
).toUpperCase();
const country = this.additionalAttributes.country || '';
if (countryCode === 'CN' || country === 'China') {
return `+86${phone.replace(/^0+/, '')}`;
}
return phone;
},
async updateContactField(attrs) {
const contactId = this.contact.id;
try {
await this.$store.dispatch('contacts/update', {
id: contactId,
...attrs,
});
useAlert(this.$t('CONTACT_FORM.SUCCESS_MESSAGE'));
await this.$store.dispatch(
'contacts/fetchContactableInbox',
contactId
);
} catch (error) {
if (error instanceof DuplicateContactException) {
const detail = error.contactErrorDetail;
if (detail) {
useAlert(detail);
} else {
const invalidAttrs = Array.isArray(error.data)
? error.data
: [];
if (invalidAttrs.includes('email')) {
useAlert(
this.$t(
'CONTACT_FORM.FORM.EMAIL_ADDRESS.DUPLICATE'
)
);
} else if (invalidAttrs.includes('phone_number')) {
useAlert(
this.$t(
'CONTACT_FORM.FORM.PHONE_NUMBER.DUPLICATE'
)
);
} else {
useAlert(this.$t('CONTACT_FORM.ERROR_MESSAGE'));
}
}
} else if (error instanceof ExceptionWithMessage) {
useAlert(error.data);
} else {
useAlert(
error.message || this.$t('CONTACT_FORM.ERROR_MESSAGE')
);
}
}
},
},
};
</script>
<template>
<div class="relative items-center w-full p-4">
<div class="flex flex-col w-full gap-2 text-left rtl:text-right">
<div class="flex flex-row justify-between">
<Avatar
v-if="showAvatar"
:src="contact.thumbnail"
:name="contact.name"
:status="contact.availability_status"
:size="48"
hide-offline-status
rounded-full
/>
</div>
<div class="relative items-center w-full p-4">
<div class="flex flex-col w-full gap-2 text-left rtl:text-right">
<div class="flex flex-row justify-between">
<Avatar
v-if="showAvatar"
:src="contact.thumbnail"
:name="contact.name"
:status="contact.availability_status"
:size="48"
hide-offline-status
rounded-full
/>
</div>
<div class="flex flex-col items-start gap-1.5 min-w-0 w-full">
<div v-if="showAvatar" class="flex items-center w-full min-w-0 gap-3">
<InlineInput
v-if="isEditingName"
ref="nameInput"
v-model="editName"
custom-input-class="!text-base !font-medium"
class="!w-fit"
@enter-press="saveNameEdit"
@escape-press="cancelNameEdit"
@blur="saveNameEdit"
/>
<h3
v-else
class="group/name flex-shrink max-w-full min-w-0 my-0 text-base capitalize break-words text-n-slate-12 cursor-pointer hover:text-n-slate-12/80"
:title="$t('CONTACT_PANEL.CLICK_TO_EDIT')"
@click="startEditingName"
>
{{ contact.name }}
<span
class="i-lucide-pencil text-xs text-n-slate-10 opacity-0 group-hover/name:opacity-100 transition-opacity ml-1 align-middle"
/>
</h3>
<div class="flex flex-row items-center gap-2">
<span
v-if="contact.created_at"
v-tooltip.left="
`${$t('CONTACT_PANEL.CREATED_AT_LABEL')} ${dynamicTime(
contact.created_at
)}`
"
class="i-lucide-info text-sm text-n-slate-10"
/>
<a
:href="contactProfileLink"
target="_blank"
rel="noopener nofollow noreferrer"
class="leading-3"
>
<span class="i-lucide-external-link text-sm text-n-slate-10" />
</a>
</div>
</div>
<div class="flex flex-col items-start gap-1.5 min-w-0 w-full">
<div
v-if="showAvatar"
class="flex items-center w-full min-w-0 gap-3"
>
<InlineInput
v-if="isEditingName"
ref="nameInput"
v-model="editName"
custom-input-class="!text-base !font-medium"
class="!w-fit"
@enter-press="saveNameEdit"
@escape-press="cancelNameEdit"
@blur="saveNameEdit"
/>
<h3
v-else
class="group/name flex-shrink max-w-full min-w-0 my-0 text-base capitalize break-words text-n-slate-12 cursor-pointer hover:text-n-slate-12/80"
:title="$t('CONTACT_PANEL.CLICK_TO_EDIT')"
@click="startEditingName"
>
{{ contact.name }}
<span
class="i-lucide-pencil text-xs text-n-slate-10 opacity-0 group-hover/name:opacity-100 transition-opacity ml-1 align-middle"
/>
</h3>
<div class="flex flex-row items-center gap-2">
<span
v-if="contact.created_at"
v-tooltip.left="
`${$t('CONTACT_PANEL.CREATED_AT_LABEL')} ${dynamicTime(
contact.created_at
)}`
"
class="i-lucide-info text-sm text-n-slate-10"
/>
<a
:href="contactProfileLink"
target="_blank"
rel="noopener nofollow noreferrer"
class="leading-3"
>
<span
class="i-lucide-external-link text-sm text-n-slate-10"
/>
</a>
</div>
</div>
<p v-if="additionalAttributes.description" class="break-words mb-0.5">
{{ additionalAttributes.description }}
</p>
<div class="flex flex-col items-start w-full gap-2">
<ContactInfoRow
:href="contact.email ? `mailto:${contact.email}` : ''"
:value="contact.email"
icon="mail"
emoji="✉️"
:title="$t('CONTACT_PANEL.EMAIL_ADDRESS')"
show-copy
editable
@update="value => onFieldUpdate('email', value)"
/>
<ContactInfoRow
:href="contact.phone_number ? `tel:${contact.phone_number}` : ''"
:value="contact.phone_number"
icon="call"
emoji="📞"
:title="$t('CONTACT_PANEL.PHONE_NUMBER')"
show-copy
editable
@update="value => onFieldUpdate('phone_number', value)"
/>
<ContactInfoRow
v-if="contact.identifier"
:value="contact.identifier"
icon="contact-identify"
emoji="🪪"
:title="$t('CONTACT_PANEL.IDENTIFIER')"
/>
<ContactInfoRow
:value="additionalAttributes.company_name"
icon="building-bank"
emoji="🏢"
:title="$t('CONTACT_PANEL.COMPANY')"
editable
@update="
value =>
updateContactField({
additional_attributes: {
...additionalAttributes,
company_name: value,
},
})
"
/>
<ContactInfoRow
v-if="location || additionalAttributes.location"
:value="location || additionalAttributes.location"
icon="map"
emoji="🌍"
:title="$t('CONTACT_PANEL.LOCATION')"
/>
<SocialIcons :social-profiles="socialProfiles" />
</div>
</div>
<div class="flex items-center w-full mt-0.5 gap-2">
<ComposeConversation :contact-id="String(contact.id)">
<template #trigger>
<NextButton
v-tooltip.top-end="$t('CONTACT_PANEL.NEW_MESSAGE')"
icon="i-ph-chat-circle-dots"
slate
faded
sm
/>
</template>
</ComposeConversation>
<VoiceCallButton
:phone="contact.phone_number"
:contact-id="contact.id"
icon="i-ri-phone-fill"
size="sm"
:tooltip-label="$t('CONTACT_PANEL.CALL')"
slate
faded
/>
<NextButton
v-tooltip.top-end="$t('EDIT_CONTACT.BUTTON_LABEL')"
icon="i-ph-pencil-simple"
slate
faded
sm
@click="toggleEditModal"
/>
<ContactMergeModal :primary-contact="contact">
<template #trigger>
<NextButton
v-tooltip.top-end="$t('CONTACT_PANEL.MERGE_CONTACT')"
icon="i-ph-arrows-merge"
slate
faded
sm
:disabled="uiFlags.isMerging"
/>
</template>
</ContactMergeModal>
<ContactDeleteModal
v-if="isAdmin"
:contact="contact"
@deleted="$emit('panelClose')"
>
<template #trigger>
<NextButton
v-tooltip.top-end="$t('DELETE_CONTACT.BUTTON_LABEL')"
icon="i-ph-trash"
slate
faded
sm
ruby
:disabled="uiFlags.isDeleting"
/>
</template>
</ContactDeleteModal>
</div>
<EditContact
:show="showEditModal"
:contact="contact"
@cancel="toggleEditModal"
/>
</div>
</div>
<p
v-if="additionalAttributes.description"
class="break-words mb-0.5"
>
{{ additionalAttributes.description }}
</p>
<div class="flex flex-col items-start w-full gap-2">
<ContactInfoRow
:href="contact.email ? `mailto:${contact.email}` : ''"
:value="contact.email"
icon="mail"
emoji="✉️"
:title="$t('CONTACT_PANEL.EMAIL_ADDRESS')"
show-copy
editable
@update="value => onFieldUpdate('email', value)"
/>
<ContactInfoRow
:href="
contact.phone_number
? `tel:${contact.phone_number}`
: ''
"
:value="contact.phone_number"
:edit-prefix="quickPhonePrefix"
icon="call"
emoji="📞"
:title="$t('CONTACT_PANEL.PHONE_NUMBER')"
show-copy
editable
@update="value => onFieldUpdate('phone_number', value)"
/>
<ContactInfoRow
v-if="contact.identifier"
:value="contact.identifier"
icon="contact-identify"
emoji="🪪"
:title="$t('CONTACT_PANEL.IDENTIFIER')"
/>
<ContactInfoRow
:value="additionalAttributes.company_name"
icon="building-bank"
emoji="🏢"
:title="$t('CONTACT_PANEL.COMPANY')"
editable
@update="
value =>
updateContactField({
additional_attributes: {
...additionalAttributes,
company_name: value,
},
})
"
/>
<ContactInfoRow
v-if="location || additionalAttributes.location"
:value="location || additionalAttributes.location"
icon="map"
emoji="🌍"
:title="$t('CONTACT_PANEL.LOCATION')"
/>
<SocialIcons :social-profiles="socialProfiles" />
</div>
</div>
<div class="flex items-center w-full mt-0.5 gap-2">
<ComposeConversation :contact-id="String(contact.id)">
<template #trigger>
<NextButton
v-tooltip.top-end="$t('CONTACT_PANEL.NEW_MESSAGE')"
icon="i-ph-chat-circle-dots"
slate
faded
sm
/>
</template>
</ComposeConversation>
<VoiceCallButton
:phone="contact.phone_number"
:contact-id="contact.id"
icon="i-ri-phone-fill"
size="sm"
:tooltip-label="$t('CONTACT_PANEL.CALL')"
slate
faded
/>
<NextButton
v-tooltip.top-end="$t('EDIT_CONTACT.BUTTON_LABEL')"
icon="i-ph-pencil-simple"
slate
faded
sm
@click="toggleEditModal"
/>
<ContactMergeModal :primary-contact="contact">
<template #trigger>
<NextButton
v-tooltip.top-end="
$t('CONTACT_PANEL.MERGE_CONTACT')
"
icon="i-ph-arrows-merge"
slate
faded
sm
:disabled="uiFlags.isMerging"
/>
</template>
</ContactMergeModal>
<ContactDeleteModal
v-if="isAdmin"
:contact="contact"
@deleted="$emit('panelClose')"
>
<template #trigger>
<NextButton
v-tooltip.top-end="
$t('DELETE_CONTACT.BUTTON_LABEL')
"
icon="i-ph-trash"
slate
faded
sm
ruby
:disabled="uiFlags.isDeleting"
/>
</template>
</ContactDeleteModal>
</div>
<EditContact
:show="showEditModal"
:contact="contact"
@cancel="toggleEditModal"
/>
</div>
</div>
</template>
@@ -6,165 +6,169 @@ import NextButton from 'dashboard/components-next/button/Button.vue';
import InlineInput from 'dashboard/components-next/inline-input/InlineInput.vue';
export default {
components: {
EmojiOrIcon,
NextButton,
InlineInput,
},
props: {
href: {
type: String,
default: '',
},
icon: {
type: String,
required: true,
},
emoji: {
type: String,
required: true,
},
value: {
type: String,
default: '',
},
showCopy: {
type: Boolean,
default: false,
},
editable: {
type: Boolean,
default: false,
},
title: {
type: String,
default: '',
},
},
emits: ['update'],
data() {
return {
isEditing: false,
editValue: '',
};
},
methods: {
async onCopy(e) {
e.preventDefault();
await copyTextToClipboard(this.value);
useAlert(this.$t('CONTACT_PANEL.COPY_SUCCESSFUL'));
},
startEditing() {
if (!this.editable) return;
this.editValue = this.value || '';
this.isEditing = true;
this.$nextTick(() => {
this.$refs.editInput?.focus();
});
},
saveEdit() {
if (!this.isEditing) return;
this.isEditing = false;
const trimmed = this.editValue.trim();
if (trimmed !== (this.value || '')) {
this.$emit('update', trimmed);
}
},
cancelEdit() {
this.isEditing = false;
},
},
components: {
EmojiOrIcon,
NextButton,
InlineInput,
},
props: {
href: {
type: String,
default: '',
},
icon: {
type: String,
required: true,
},
emoji: {
type: String,
required: true,
},
value: {
type: String,
default: '',
},
showCopy: {
type: Boolean,
default: false,
},
editable: {
type: Boolean,
default: false,
},
editPrefix: {
type: String,
default: '',
},
title: {
type: String,
default: '',
},
},
emits: ['update'],
data() {
return {
isEditing: false,
editValue: '',
};
},
methods: {
async onCopy(e) {
e.preventDefault();
await copyTextToClipboard(this.value);
useAlert(this.$t('CONTACT_PANEL.COPY_SUCCESSFUL'));
},
startEditing() {
if (!this.editable) return;
this.editValue = this.value || this.editPrefix;
this.isEditing = true;
this.$nextTick(() => {
this.$refs.editInput?.focus();
});
},
saveEdit() {
if (!this.isEditing) return;
this.isEditing = false;
const trimmed = this.editValue.trim();
if (trimmed !== (this.value || '')) {
this.$emit('update', trimmed);
}
},
cancelEdit() {
this.isEditing = false;
},
},
};
</script>
<template>
<div class="group/row w-full h-5 ltr:-ml-1 rtl:-mr-1">
<!-- Inline edit mode -->
<div v-if="isEditing" class="flex items-center gap-2">
<EmojiOrIcon
:icon="icon"
:emoji="emoji"
icon-size="14"
class="flex-shrink-0 ltr:ml-1 rtl:mr-1"
/>
<InlineInput
ref="editInput"
v-model="editValue"
:placeholder="title"
class="!w-fit"
@enter-press="saveEdit"
@escape-press="cancelEdit"
@blur="saveEdit"
/>
</div>
<div class="group/row w-full h-5 ltr:-ml-1 rtl:-mr-1">
<!-- Inline edit mode -->
<div v-if="isEditing" class="flex items-center gap-2">
<EmojiOrIcon
:icon="icon"
:emoji="emoji"
icon-size="14"
class="flex-shrink-0 ltr:ml-1 rtl:mr-1"
/>
<InlineInput
ref="editInput"
v-model="editValue"
:placeholder="title"
class="!w-fit"
@enter-press="saveEdit"
@escape-press="cancelEdit"
@blur="saveEdit"
/>
</div>
<!-- Read mode with link -->
<a
v-else-if="href"
:href="href"
class="flex items-center gap-2 text-n-slate-11 hover:underline"
>
<EmojiOrIcon
:icon="icon"
:emoji="emoji"
icon-size="14"
class="flex-shrink-0 ltr:ml-1 rtl:mr-1"
/>
<span
v-if="value"
class="overflow-hidden text-sm whitespace-nowrap text-ellipsis"
:title="value"
>
{{ value }}
</span>
<span v-else class="text-sm text-n-slate-11">
{{ $t('CONTACT_PANEL.NOT_AVAILABLE') }}
</span>
<NextButton
v-if="showCopy"
ghost
xs
slate
class="ltr:-ml-1 rtl:-mr-1"
icon="i-lucide-clipboard"
@click="onCopy"
/>
<NextButton
v-if="editable"
ghost
xs
slate
class="ltr:-ml-1 rtl:-mr-1 opacity-0 group-hover/row:opacity-100 transition-opacity"
icon="i-lucide-pencil"
@click.prevent="startEditing"
/>
</a>
<!-- Read mode with link -->
<a
v-else-if="href"
:href="href"
class="flex items-center gap-2 text-n-slate-11 hover:underline"
>
<EmojiOrIcon
:icon="icon"
:emoji="emoji"
icon-size="14"
class="flex-shrink-0 ltr:ml-1 rtl:mr-1"
/>
<span
v-if="value"
class="overflow-hidden text-sm whitespace-nowrap text-ellipsis"
:title="value"
>
{{ value }}
</span>
<span v-else class="text-sm text-n-slate-11">
{{ $t('CONTACT_PANEL.NOT_AVAILABLE') }}
</span>
<NextButton
v-if="showCopy"
ghost
xs
slate
class="ltr:-ml-1 rtl:-mr-1"
icon="i-lucide-clipboard"
@click="onCopy"
/>
<NextButton
v-if="editable"
ghost
xs
slate
class="ltr:-ml-1 rtl:-mr-1 opacity-0 group-hover/row:opacity-100 transition-opacity"
icon="i-lucide-pencil"
@click.prevent="startEditing"
/>
</a>
<!-- Read mode without link -->
<div v-else class="flex items-center gap-2 text-n-slate-11">
<EmojiOrIcon
:icon="icon"
:emoji="emoji"
icon-size="14"
class="flex-shrink-0 ltr:ml-1 rtl:mr-1"
/>
<span
v-if="value"
v-dompurify-html="value"
class="overflow-hidden text-sm whitespace-nowrap text-ellipsis"
/>
<span v-else class="text-sm text-n-slate-11">
{{ $t('CONTACT_PANEL.NOT_AVAILABLE') }}
</span>
<NextButton
v-if="editable"
ghost
xs
slate
class="ltr:-ml-1 rtl:-mr-1 opacity-0 group-hover/row:opacity-100 transition-opacity"
icon="i-lucide-pencil"
@click="startEditing"
/>
</div>
</div>
<!-- Read mode without link -->
<div v-else class="flex items-center gap-2 text-n-slate-11">
<EmojiOrIcon
:icon="icon"
:emoji="emoji"
icon-size="14"
class="flex-shrink-0 ltr:ml-1 rtl:mr-1"
/>
<span
v-if="value"
v-dompurify-html="value"
class="overflow-hidden text-sm whitespace-nowrap text-ellipsis"
/>
<span v-else class="text-sm text-n-slate-11">
{{ $t('CONTACT_PANEL.NOT_AVAILABLE') }}
</span>
<NextButton
v-if="editable"
ghost
xs
slate
class="ltr:-ml-1 rtl:-mr-1 opacity-0 group-hover/row:opacity-100 transition-opacity"
icon="i-lucide-pencil"
@click="startEditing"
/>
</div>
</div>
</template>