diff --git a/.changeset/forty-dolphins-check.md b/.changeset/forty-dolphins-check.md new file mode 100644 index 0000000000000..b008352c629cc --- /dev/null +++ b/.changeset/forty-dolphins-check.md @@ -0,0 +1,10 @@ +--- +'@rocket.chat/model-typings': minor +'@rocket.chat/core-typings': minor +'@rocket.chat/apps-engine': minor +'@rocket.chat/omni-core': minor +'@rocket.chat/models': minor +'@rocket.chat/meteor': minor +--- + +Adds externalIds field to livechat visitors for external platform identification. diff --git a/apps/meteor/app/apps/server/bridges/livechat.ts b/apps/meteor/app/apps/server/bridges/livechat.ts index 6952ac6f5457c..ab0faf0b2c3b4 100644 --- a/apps/meteor/app/apps/server/bridges/livechat.ts +++ b/apps/meteor/app/apps/server/bridges/livechat.ts @@ -1,6 +1,13 @@ import type { IAppServerOrchestrator, IAppsLivechatMessage, IAppsMessage } from '@rocket.chat/apps'; import type { IExtraRoomParams } from '@rocket.chat/apps-engine/definition/accessors/ILivechatCreator'; -import type { IVisitor, ILivechatRoom, ILivechatTransferData, IDepartment } from '@rocket.chat/apps-engine/definition/livechat'; +import type { + IVisitorExternalIdentifier, + IVisitor, + ILivechatRoom, + ILivechatTransferData, + IDepartment, + ResolveVisitorContactData, +} from '@rocket.chat/apps-engine/definition/livechat'; import type { IMessage as IAppsEngineMessage } from '@rocket.chat/apps-engine/definition/messages'; import type { IUser } from '@rocket.chat/apps-engine/definition/users'; import { LivechatBridge } from '@rocket.chat/apps-engine/server/bridges/LivechatBridge'; @@ -16,6 +23,7 @@ import { setCustomFields } from '../../../livechat/server/lib/custom-fields'; import { getRoomMessages } from '../../../livechat/server/lib/getRoomMessages'; import type { ILivechatMessage } from '../../../livechat/server/lib/localTypes'; import { updateMessage, sendMessage } from '../../../livechat/server/lib/messages'; +import { resolveVisitor } from '../../../livechat/server/lib/resolveVisitor'; import { createRoom } from '../../../livechat/server/lib/rooms'; import { online } from '../../../livechat/server/lib/service-status'; import { transfer } from '../../../livechat/server/lib/transfer'; @@ -198,6 +206,10 @@ export class AppLivechatBridge extends LivechatBridge { return Promise.all(result.map((room) => this.orch.getConverters()?.get('rooms').convertRoom(room) as Promise)); } + /** + * @deprecated Use `createAndReturnVisitor` instead. + * Note: This method does not support `externalIds`. + */ protected async createVisitor(visitor: IVisitor, appId: string): Promise { this.orch.debugLog(`The App ${appId} is creating a livechat visitor.`); @@ -226,6 +238,9 @@ export class AppLivechatBridge extends LivechatBridge { protected async createAndReturnVisitor(visitor: IVisitor, appId: string): Promise { this.orch.debugLog(`The App ${appId} is creating a livechat visitor.`); + // Add appId to each externalId entry + const externalIds = visitor.externalIds?.map((entry) => ({ ...entry, appId })); + const registerData = { department: visitor.department, username: visitor.username, @@ -235,6 +250,7 @@ export class AppLivechatBridge extends LivechatBridge { id: visitor.id, ...(visitor.phone?.length && { phone: { number: visitor.phone[0].phoneNumber } }), ...(visitor.visitorEmails?.length && { email: visitor.visitorEmails[0].address }), + ...(externalIds?.length && { externalIds }), }; const livechatVisitor = await registerGuest(registerData, { @@ -335,6 +351,34 @@ export class AppLivechatBridge extends LivechatBridge { .convertVisitor(await LivechatVisitors.findOneVisitorByPhone(phoneNumber)); } + protected async resolveVisitor( + externalId: Omit, + contactData: ResolveVisitorContactData | undefined, + appId: string, + ): Promise { + this.orch.debugLog(`The App ${appId} is resolving a livechat visitor by external ID.`); + + const visitor = await resolveVisitor({ + appId, + externalId, + contactData, + }); + + return this.orch.getConverters()?.get('visitors').convertVisitor(visitor); + } + + protected async updateVisitorExternalId( + visitorId: string, + externalId: Omit, + appId: string, + ): Promise { + this.orch.debugLog(`The App ${appId} is updating externalId for visitor ${visitorId}.`); + + const visitor = await LivechatVisitors.updateExternalIdById(visitorId, appId, externalId); + + return this.orch.getConverters()?.get('visitors').convertVisitor(visitor); + } + protected async findDepartmentByIdOrName(value: string, appId: string): Promise { this.orch.debugLog(`The App ${appId} is looking for livechat departments.`); diff --git a/apps/meteor/app/apps/server/converters/visitors.js b/apps/meteor/app/apps/server/converters/visitors.js index 00b8b3888ae74..62d38bc88cc3b 100644 --- a/apps/meteor/app/apps/server/converters/visitors.js +++ b/apps/meteor/app/apps/server/converters/visitors.js @@ -37,6 +37,7 @@ export class AppVisitorsConverter { livechatData: 'livechatData', status: 'status', activity: 'activity', + externalIds: 'externalIds', }; return transformMappedData(visitor, map); @@ -57,6 +58,7 @@ export class AppVisitorsConverter { status: visitor.status || 'online', ...(visitor.visitorEmails && { visitorEmails: visitor.visitorEmails }), ...(visitor.department && { department: visitor.department }), + ...(visitor.externalIds && { externalIds: visitor.externalIds }), }; return Object.assign(newVisitor, visitor._unmappedProperties_); diff --git a/apps/meteor/app/livechat/server/lib/resolveVisitor.ts b/apps/meteor/app/livechat/server/lib/resolveVisitor.ts new file mode 100644 index 0000000000000..7275f7b4c2e6b --- /dev/null +++ b/apps/meteor/app/livechat/server/lib/resolveVisitor.ts @@ -0,0 +1,23 @@ +import type { IVisitorExternalIdentifier, ILivechatVisitor } from '@rocket.chat/core-typings'; +import { LivechatVisitors } from '@rocket.chat/models'; + +type ResolveVisitorContactData = { phone: string } | { email: string }; + +type ResolveVisitorParams = { + appId: string; + externalId: Omit; + contactData?: ResolveVisitorContactData; +}; + +export async function resolveVisitor({ appId, externalId, contactData }: ResolveVisitorParams): Promise { + const visitorByExternalId = await LivechatVisitors.findOneByExternalId(externalId.entityId); + if (visitorByExternalId) { + return visitorByExternalId; + } + + if (contactData && (('phone' in contactData && contactData.phone) || ('email' in contactData && contactData.email))) { + return LivechatVisitors.findOneVisitorByPhoneOrEmailAndAddExternalId(contactData, appId, externalId); + } + + return null; +} diff --git a/apps/meteor/client/views/omnichannel/contactInfo/tabs/ContactInfoChannels/ContactInfoChannelsItem.tsx b/apps/meteor/client/views/omnichannel/contactInfo/tabs/ContactInfoChannels/ContactInfoChannelsItem.tsx index 85e8e0b9c84a2..938c3583e177a 100644 --- a/apps/meteor/client/views/omnichannel/contactInfo/tabs/ContactInfoChannels/ContactInfoChannelsItem.tsx +++ b/apps/meteor/client/views/omnichannel/contactInfo/tabs/ContactInfoChannels/ContactInfoChannelsItem.tsx @@ -10,6 +10,7 @@ import { useBlockChannel } from './useBlockChannel'; import { OmnichannelRoomIcon } from '../../../../../components/RoomIcon/OmnichannelRoomIcon'; import { useTimeFromNow } from '../../../../../hooks/useTimeFromNow'; import { useOutboundMessageModal } from '../../../components/outboundMessage/modals/OutboundMessageModal'; +import { useVisitorInfo } from '../../../directory/hooks/useVisitorInfo'; import { useOmnichannelSource } from '../../../hooks/useOmnichannelSource'; type ContactInfoChannelsItemProps = Serialized & { @@ -29,6 +30,19 @@ const ContactInfoChannelsItem = ({ const { getSourceLabel, getSourceName } = useOmnichannelSource(); const getTimeFromNow = useTimeFromNow(true); + const { data: visitorData } = useVisitorInfo(visitor.visitorId); + + const channelLabel = useMemo(() => { + const phone = getSourceLabel(details); + const externalId = details?.id ? visitorData?.externalIds?.find((e) => e.appId === details.id) : undefined; + const username = externalId?.metadata?.username; + + if (typeof username === 'string' && phone) { + return `${username} - ${phone}`; + } + return typeof username === 'string' ? username : phone; + }, [visitorData?.externalIds, details, getSourceLabel]); + const [showButton, setShowButton] = useState(false); const handleBlockContact = useBlockChannel({ association: visitor, blocked }); const outboundMessageModal = useOutboundMessageModal(); @@ -94,7 +108,7 @@ const ContactInfoChannelsItem = ({ )} - {getSourceLabel(details)} + {channelLabel} {showButton && } diff --git a/apps/meteor/client/views/omnichannel/directory/components/ContactField.tsx b/apps/meteor/client/views/omnichannel/directory/components/ContactField.tsx index 2edf32062f209..132da320e08a9 100644 --- a/apps/meteor/client/views/omnichannel/directory/components/ContactField.tsx +++ b/apps/meteor/client/views/omnichannel/directory/components/ContactField.tsx @@ -26,7 +26,6 @@ const ContactField = ({ contact, room }: ContactFieldProps) => { const getVisitorInfo = useEndpoint('GET', '/v1/livechat/visitors.info'); const { data, isPending, isError } = useQuery({ queryKey: ['/v1/livechat/visitors.info', contact._id], - queryFn: () => getVisitorInfo({ visitorId: contact._id }), }); @@ -39,17 +38,19 @@ const ContactField = ({ contact, room }: ContactFieldProps) => { } const { - visitor: { username, name }, + visitor: { username, name, phone }, } = data; const displayName = name || username; + const phoneNumber = phone?.[0]?.phoneNumber; + const contactIdentifier = [...new Set([username, phoneNumber])].filter(Boolean).join(' ยท '); return ( - } /> + } /> ); diff --git a/apps/meteor/tests/data/apps/app-packages/README.md b/apps/meteor/tests/data/apps/app-packages/README.md index bdb3f214bafbe..5af84e4ab31c8 100644 --- a/apps/meteor/tests/data/apps/app-packages/README.md +++ b/apps/meteor/tests/data/apps/app-packages/README.md @@ -135,6 +135,153 @@ export class TestEndpoint extends ApiEndpoint { +#### External ID Test (resolveVisitor API) + +File name: `external-id-test_0.0.1.zip` + +An app that tests the `ILivechatCreator.resolveVisitor()` and `ILivechatUpdater.updateVisitorExternalId()` APIs for resolving and updating livechat visitors by external identifiers. This is used to test the WhatsApp BSUID (Business Scoped User ID) support and progressive visitor enrichment. + +**Endpoints:** + +1. `POST /api/apps/public/:appId/resolve-visitor` - Resolve visitor by externalId with phone/email fallback +2. `POST /api/apps/public/:appId/update-external-id` - Update visitor's externalId for this app + +**Request body (resolve-visitor):** +```json +{ + "externalId": { "entityId": "bsuid-123", "metadata": { "username": "@johndoe" } }, + "phone": "+1234567890" +} +``` + +**Request body (update-external-id):** +```json +{ + "visitorId": "visitor-123", + "externalId": { "entityId": "bsuid-123", "metadata": { "username": "@johndoe" } } +} +``` + +**Response:** +- Returns the visitor if found/updated +- Returns `{ visitor: null }` if not found + +
+App source code + +**ExternalIdTestApp.ts** +```typescript +import { + IAppAccessors, + IConfigurationExtend, + ILogger, +} from '@rocket.chat/apps-engine/definition/accessors'; +import { App } from '@rocket.chat/apps-engine/definition/App'; +import { IAppInfo } from '@rocket.chat/apps-engine/definition/metadata'; +import { ApiSecurity, ApiVisibility } from '@rocket.chat/apps-engine/definition/api'; +import { ResolveVisitorEndpoint } from './ResolveVisitorEndpoint'; +import { UpdateExternalIdEndpoint } from './UpdateExternalIdEndpoint'; + +export class ExternalIdTestApp extends App { + constructor(info: IAppInfo, logger: ILogger, accessors: IAppAccessors) { + super(info, logger, accessors); + } + + public override async extendConfiguration(configuration: IConfigurationExtend): Promise { + await configuration.api.provideApi({ + visibility: ApiVisibility.PUBLIC, + security: ApiSecurity.UNSECURE, + endpoints: [new ResolveVisitorEndpoint(this), new UpdateExternalIdEndpoint(this)], + }); + } +} +``` + +**ResolveVisitorEndpoint.ts** +```typescript +import { + HttpStatusCode, + IHttp, + IModify, + IPersistence, + IRead, +} from '@rocket.chat/apps-engine/definition/accessors'; +import { ApiEndpoint, IApiEndpointInfo, IApiRequest, IApiResponse } from '@rocket.chat/apps-engine/definition/api'; + +export class ResolveVisitorEndpoint extends ApiEndpoint { + public override path = 'resolve-visitor'; + + public async post( + request: IApiRequest, + _endpoint: IApiEndpointInfo, + _read: IRead, + modify: IModify, + _http: IHttp, + _persistence: IPersistence, + ): Promise { + const { externalId, phone, email } = request.content; + + let contactData: { phone: string } | { email: string } | undefined; + + if (phone) { + contactData = { phone }; + } else if (email) { + contactData = { email }; + } + + const visitor = await modify.getCreator().getLivechatCreator().resolveVisitor(externalId, contactData); + + return { + status: HttpStatusCode.OK, + content: { visitor: visitor || null }, + }; + } +} +``` + +**UpdateExternalIdEndpoint.ts** +```typescript +import { + HttpStatusCode, + IHttp, + IModify, + IPersistence, + IRead, +} from '@rocket.chat/apps-engine/definition/accessors'; +import { ApiEndpoint, IApiEndpointInfo, IApiRequest, IApiResponse } from '@rocket.chat/apps-engine/definition/api'; + +export class UpdateExternalIdEndpoint extends ApiEndpoint { + public override path = 'update-external-id'; + + public async post( + request: IApiRequest, + _endpoint: IApiEndpointInfo, + _read: IRead, + modify: IModify, + _http: IHttp, + _persistence: IPersistence, + ): Promise { + const { visitorId, externalId } = request.content; + + if (!visitorId || !externalId) { + return { + status: HttpStatusCode.BAD_REQUEST, + content: { error: 'visitorId and externalId are required' }, + }; + } + + const visitor = await modify.getUpdater().getLivechatUpdater().updateVisitorExternalId(visitorId, externalId); + + return { + status: HttpStatusCode.OK, + content: { visitor: visitor || null }, + }; + } +} +``` + +
+ #### Nested Requests simulation File name: `nested-requests_0.0.1.zip` diff --git a/apps/meteor/tests/data/apps/app-packages/external-id-test_0.0.1.zip b/apps/meteor/tests/data/apps/app-packages/external-id-test_0.0.1.zip new file mode 100644 index 0000000000000..870c14e63b418 Binary files /dev/null and b/apps/meteor/tests/data/apps/app-packages/external-id-test_0.0.1.zip differ diff --git a/apps/meteor/tests/data/apps/app-packages/index.ts b/apps/meteor/tests/data/apps/app-packages/index.ts index 4fa2e5d719f76..580e7700ffeac 100644 --- a/apps/meteor/tests/data/apps/app-packages/index.ts +++ b/apps/meteor/tests/data/apps/app-packages/index.ts @@ -7,3 +7,5 @@ export const appAPIParameterTest = path.resolve(__dirname, './api-parameter-test export const appCausingNestedRequests = path.resolve(__dirname, './nested-requests_0.0.1.zip'); export const appUpdateStatusTest = path.resolve(__dirname, './update-status-test_0.0.1.zip'); + +export const appExternalIdTest = path.resolve(__dirname, './external-id-test_0.0.1.zip'); diff --git a/apps/meteor/tests/end-to-end/apps/app-resolve-visitor.ts b/apps/meteor/tests/end-to-end/apps/app-resolve-visitor.ts new file mode 100644 index 0000000000000..ef558045e3a7b --- /dev/null +++ b/apps/meteor/tests/end-to-end/apps/app-resolve-visitor.ts @@ -0,0 +1,239 @@ +import type { App, ILivechatVisitor } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import { after, before, describe, it } from 'mocha'; +import { MongoClient } from 'mongodb'; + +import { getCredentials, request, credentials } from '../../data/api-data'; +import { appExternalIdTest } from '../../data/apps/app-packages'; +import { apps } from '../../data/apps/apps-data'; +import { cleanupApps } from '../../data/apps/helper'; +import { createVisitor, createVisitorWithCustomData, createAgent, makeAgentAvailable } from '../../data/livechat/rooms'; +import { updateSetting } from '../../data/permissions.helper'; +import { IS_EE, URL_MONGODB } from '../../e2e/config/constants'; + +(IS_EE ? describe : describe.skip)('Apps - resolveVisitor API', () => { + let app: App; + + before((done) => getCredentials(done)); + + before(async () => { + await updateSetting('Livechat_enabled', true); + await createAgent(); + await makeAgentAvailable(); + + const installResponse = await request.post(apps()).set(credentials).attach('app', appExternalIdTest); + if (!installResponse.body.success) { + throw new Error(`Failed to install test app: ${installResponse.body.error}`); + } + + app = installResponse.body.app; + }); + + after(async () => { + await cleanupApps(); + }); + + const callResolveVisitor = async ( + externalId: { entityId: string; metadata?: Record }, + contactData?: { phone?: string; email?: string }, + ) => { + return request + .post(apps(`/public/${app.id}/resolve-visitor`)) + .set(credentials) + .send({ externalId, phone: contactData?.phone, email: contactData?.email }); + }; + + const callUpdateExternalId = async (visitorId: string, externalId: { entityId: string; metadata?: Record }) => { + return request + .post(apps(`/public/${app.id}/update-external-id`)) + .set(credentials) + .send({ visitorId, externalId }); + }; + + describe('externalId lookup', () => { + it('should return null when externalId does not exist', async () => { + const response = await callResolveVisitor({ entityId: 'nonexistent-id' }); + + expect(response.status).to.equal(200); + expect(response.body.visitor).to.be.null; + }); + + it('should find visitor directly by externalId', async () => { + const phone = `+1${Date.now()}`; + const visitor = await createVisitor(undefined, undefined, undefined, phone); + + const externalId = { entityId: `id-${Date.now()}`, metadata: { username: '@user' } }; + await callResolveVisitor(externalId, { phone }); + + const response = await callResolveVisitor(externalId); + + expect(response.status).to.equal(200); + expect(response.body.visitor.id).to.equal(visitor._id); + const appExternalId = response.body.visitor.externalIds.find((e: { appId: string }) => e.appId === app.id); + expect(appExternalId.entityId).to.equal(externalId.entityId); + expect(appExternalId.metadata.username).to.equal(externalId.metadata.username); + }); + + it('should not update externalId when found by lookup', async () => { + const phone = `+2${Date.now()}`; + await createVisitor(undefined, undefined, undefined, phone); + + const externalId = { entityId: `id-${Date.now()}`, metadata: { username: '@original' } }; + await callResolveVisitor(externalId, { phone }); + + // resolveVisitor is for resolving/enriching visitors, not for updating existing data. + // When found by externalId, it returns the visitor as-is without modifications. + // To update visitor data, apps should use other methods like ILivechatUpdater. + const response = await callResolveVisitor({ entityId: externalId.entityId, metadata: { username: '@changed' } }); + + const appExternalId = response.body.visitor.externalIds.find((e: { appId: string }) => e.appId === app.id); + expect(appExternalId.metadata.username).to.equal('@original'); + }); + }); + + describe('phone fallback', () => { + it('should find visitor by phone and save externalId', async () => { + const phone = `+3${Date.now()}`; + const visitor = await createVisitor(undefined, undefined, undefined, phone); + + const externalId = { entityId: `id-${Date.now()}`, metadata: { username: '@user' } }; + const response = await callResolveVisitor(externalId, { phone }); + + expect(response.status).to.equal(200); + expect(response.body.visitor.id).to.equal(visitor._id); + const appExternalId = response.body.visitor.externalIds.find((e: { appId: string }) => e.appId === app.id); + expect(appExternalId.entityId).to.equal(externalId.entityId); + expect(appExternalId.metadata.username).to.equal(externalId.metadata.username); + }); + + it('should overwrite externalId when called with different entityId', async () => { + const phone = `+4${Date.now()}`; + await createVisitor(undefined, undefined, undefined, phone); + + await callResolveVisitor({ entityId: `first-${Date.now()}`, metadata: { username: '@first' } }, { phone }); + + const newExternalId = { entityId: `second-${Date.now()}`, metadata: { username: '@second' } }; + const response = await callResolveVisitor(newExternalId, { phone }); + + const appExternalId = response.body.visitor.externalIds.find((e: { appId: string }) => e.appId === app.id); + expect(appExternalId.entityId).to.equal(newExternalId.entityId); + expect(appExternalId.metadata.username).to.equal('@second'); + }); + + it('should return null when phone not found', async () => { + const response = await callResolveVisitor({ entityId: `id-${Date.now()}` }, { phone: '+0000000000' }); + + expect(response.status).to.equal(200); + expect(response.body.visitor).to.be.null; + }); + + it('should not use phone fallback when phone is empty', async () => { + const response = await callResolveVisitor({ entityId: `id-${Date.now()}` }, { phone: '' }); + + expect(response.status).to.equal(200); + expect(response.body.visitor).to.be.null; + }); + }); + + describe('email fallback', () => { + it('should find visitor by email and save externalId', async () => { + const email = `test-${Date.now()}@example.com`; + const visitor = await createVisitor(undefined, undefined, email); + + const externalId = { entityId: `id-email-${Date.now()}`, metadata: { username: '@emailuser' } }; + const response = await callResolveVisitor(externalId, { email }); + + expect(response.status).to.equal(200); + expect(response.body.visitor.id).to.equal(visitor._id); + const appExternalId = response.body.visitor.externalIds.find((e: { appId: string }) => e.appId === app.id); + expect(appExternalId.entityId).to.equal(externalId.entityId); + expect(appExternalId.metadata.username).to.equal(externalId.metadata.username); + }); + + it('should overwrite externalId when called with different entityId via email', async () => { + const email = `test-${Date.now()}@example.com`; + await createVisitor(undefined, undefined, email); + + await callResolveVisitor({ entityId: `first-email-${Date.now()}`, metadata: { username: '@first' } }, { email }); + + const newExternalId = { entityId: `second-email-${Date.now()}`, metadata: { username: '@second' } }; + const response = await callResolveVisitor(newExternalId, { email }); + + const appExternalId = response.body.visitor.externalIds.find((e: { appId: string }) => e.appId === app.id); + expect(appExternalId.entityId).to.equal(newExternalId.entityId); + expect(appExternalId.metadata.username).to.equal('@second'); + }); + + it('should return null when email not found', async () => { + const response = await callResolveVisitor({ entityId: `id-${Date.now()}` }, { email: 'notfound@example.com' }); + + expect(response.status).to.equal(200); + expect(response.body.visitor).to.be.null; + }); + + it('should not use email fallback when email is empty', async () => { + const response = await callResolveVisitor({ entityId: `id-${Date.now()}` }, { email: '' }); + + expect(response.status).to.equal(200); + expect(response.body.visitor).to.be.null; + }); + }); + + describe('cross-appId lookup', () => { + // Direct MongoDB to inject externalId with a different appId, avoiding the need to install a second test app + const addExternalIdToVisitor = async (visitorId: string, externalId: { appId: string; entityId: string }) => { + const connection = await MongoClient.connect(URL_MONGODB); + try { + await connection + .db() + .collection('rocketchat_livechat_visitor') + .updateOne({ _id: visitorId }, { $push: { externalIds: externalId } }); + } finally { + await connection.close(); + } + }; + + it('should find visitor by entityId even when externalId was saved by different appId', async () => { + // Create visitor without phone to ensure we're testing entityId lookup only + const visitor = await createVisitorWithCustomData({ ignorePhone: true }); + + const oldAppId = 'old-app-version-id-12345'; + const entityId = `cross-app-${Date.now()}`; + await addExternalIdToVisitor(visitor._id, { appId: oldAppId, entityId }); + + // App searches by entityId only - should find visitor even though appId is different + const response = await callResolveVisitor({ entityId }); + + expect(response.status).to.equal(200); + expect(response.body.visitor.id).to.equal(visitor._id); + const oldExternalId = response.body.visitor.externalIds.find((e: { appId: string }) => e.appId === oldAppId); + expect(oldExternalId.entityId).to.equal(entityId); + }); + + it('should allow app to add its own externalId using updateVisitorExternalId', async () => { + const visitor = await createVisitorWithCustomData({ ignorePhone: true }); + + const oldAppId = 'old-app-version-id-67890'; + const entityId = `cross-app-update-${Date.now()}`; + await addExternalIdToVisitor(visitor._id, { appId: oldAppId, entityId }); + + const lookupResponse = await callResolveVisitor({ entityId }); + expect(lookupResponse.body.visitor.id).to.equal(visitor._id); + + const newExternalId = { entityId, metadata: { username: '@newversion' } }; + const updateResponse = await callUpdateExternalId(visitor._id, newExternalId); + + expect(updateResponse.status).to.equal(200); + expect(updateResponse.body.visitor.id).to.equal(visitor._id); + + const oldEntry = updateResponse.body.visitor.externalIds.find((e: { appId: string }) => e.appId === oldAppId); + const newEntry = updateResponse.body.visitor.externalIds.find((e: { appId: string }) => e.appId === app.id); + + expect(oldEntry).to.exist; + expect(oldEntry.entityId).to.equal(entityId); + expect(newEntry).to.exist; + expect(newEntry.entityId).to.equal(entityId); + expect(newEntry.metadata.username).to.equal('@newversion'); + }); + }); +}); diff --git a/ee/packages/pdf-worker/src/strategies/ChatTranscript.ts b/ee/packages/pdf-worker/src/strategies/ChatTranscript.ts index cb481690a59dd..665abe69caf75 100644 --- a/ee/packages/pdf-worker/src/strategies/ChatTranscript.ts +++ b/ee/packages/pdf-worker/src/strategies/ChatTranscript.ts @@ -41,6 +41,10 @@ export class ChatTranscript implements IStrategy { ts: moment(requestData.visitor.lastAgent.ts).tz(timezone).format(timeAndDateFormat), } : undefined, + externalIds: requestData.visitor.externalIds?.map((ext) => ({ + ...ext, + metadata: ext.metadata ? Object.fromEntries(Object.entries(ext.metadata).map(([key]) => [key, null])) : undefined, + })), livechatData: requestData.visitor.livechatData ? Object.fromEntries(Object.entries(requestData.visitor.livechatData).map(([key]) => [key, null])) : undefined, diff --git a/packages/apps-engine/src/definition/accessors/ILivechatCreator.ts b/packages/apps-engine/src/definition/accessors/ILivechatCreator.ts index c1843f30b22eb..ef30b4fbb3f85 100644 --- a/packages/apps-engine/src/definition/accessors/ILivechatCreator.ts +++ b/packages/apps-engine/src/definition/accessors/ILivechatCreator.ts @@ -1,4 +1,4 @@ -import type { ILivechatRoom, IVisitor } from '../livechat'; +import type { ILivechatRoom, IVisitor, IVisitorExternalIdentifier, ResolveVisitorContactData } from '../livechat'; import type { IUser } from '../users'; export interface IExtraRoomParams { @@ -9,6 +9,14 @@ export interface IExtraRoomParams { } export interface ILivechatCreator { + /** + * Resolves a visitor by external identifier (e.g., WhatsApp BSUID) with contact data fallback. + * If found by contact data (phone or email) but not by externalId, enriches the visitor record with the externalId. + * @param externalId The external identifier containing entityId and optional metadata (e.g., `{ entityId: 'bsuid-123', metadata: { username: '@user' } }`) + * @param contactData Optional contact data for fallback lookup. Use `{ phone: '+1234567890' }` or `{ email: 'user@example.com' }` + * @returns The visitor if found, undefined otherwise + */ + resolveVisitor(externalId: Omit, contactData?: ResolveVisitorContactData): Promise; /** * Creates a room to connect the `visitor` to an `agent`. * diff --git a/packages/apps-engine/src/definition/accessors/ILivechatUpdater.ts b/packages/apps-engine/src/definition/accessors/ILivechatUpdater.ts index c0ee2cc145bb7..7d2dbbb2aa2f0 100644 --- a/packages/apps-engine/src/definition/accessors/ILivechatUpdater.ts +++ b/packages/apps-engine/src/definition/accessors/ILivechatUpdater.ts @@ -1,4 +1,4 @@ -import type { ILivechatTransferData, IVisitor } from '../livechat'; +import type { ILivechatTransferData, IVisitor, IVisitorExternalIdentifier } from '../livechat'; import type { IRoom } from '../rooms'; import type { IUser } from '../users'; @@ -30,4 +30,19 @@ export interface ILivechatUpdater { * @returns Promise to whether success or not */ setCustomFields(token: IVisitor['token'], key: string, value: string, overwrite: boolean): Promise; + + /** + * Updates or adds an external identifier for a visitor. + * The appId is automatically set to the calling app's ID. + * If an externalId with the same appId already exists, it will be replaced. + * + * @param visitorId The visitor's ID + * @param externalId The external identifier containing entityId and optional metadata + * + * @returns Promise resolving to the updated visitor, or undefined if not found + */ + updateVisitorExternalId( + visitorId: string, + externalId: Omit, + ): Promise; } diff --git a/packages/apps-engine/src/definition/livechat/IVisitor.ts b/packages/apps-engine/src/definition/livechat/IVisitor.ts index dc04777b8e803..ee49ac6361f42 100644 --- a/packages/apps-engine/src/definition/livechat/IVisitor.ts +++ b/packages/apps-engine/src/definition/livechat/IVisitor.ts @@ -1,6 +1,14 @@ import type { IVisitorEmail } from './IVisitorEmail'; import type { IVisitorPhone } from './IVisitorPhone'; +export interface IVisitorExternalIdentifier { + appId: string; + entityId: string; + metadata?: Record; +} + +export type ResolveVisitorContactData = { phone: string } | { email: string }; + export interface IVisitor { id?: string; token: string; @@ -14,4 +22,5 @@ export interface IVisitor { activity?: string[]; customFields?: { [key: string]: any }; livechatData?: { [key: string]: any }; + externalIds?: IVisitorExternalIdentifier[]; } diff --git a/packages/apps-engine/src/definition/livechat/index.ts b/packages/apps-engine/src/definition/livechat/index.ts index a34151da53317..b2a183b0897bf 100644 --- a/packages/apps-engine/src/definition/livechat/index.ts +++ b/packages/apps-engine/src/definition/livechat/index.ts @@ -16,11 +16,12 @@ import { IPostLivechatRoomSaved } from './IPostLivechatRoomSaved'; import { IPostLivechatRoomStarted } from './IPostLivechatRoomStarted'; import { IPostLivechatRoomTransferred } from './IPostLivechatRoomTransferred'; import { IPreLivechatRoomCreatePrevent } from './IPreLivechatRoomCreatePrevent'; -import { IVisitor } from './IVisitor'; +import { IVisitorExternalIdentifier, IVisitor, ResolveVisitorContactData } from './IVisitor'; import { IVisitorEmail } from './IVisitorEmail'; import { IVisitorPhone } from './IVisitorPhone'; export { + IVisitorExternalIdentifier, ILivechatEventContext, ILivechatMessage, ILivechatRoom, @@ -43,4 +44,5 @@ export { LivechatTransferEventType, IPostLivechatDepartmentRemoved, IPostLivechatDepartmentDisabled, + ResolveVisitorContactData, }; diff --git a/packages/apps-engine/src/server/accessors/LivechatCreator.ts b/packages/apps-engine/src/server/accessors/LivechatCreator.ts index a15e9e822efe9..d03376d7185b0 100644 --- a/packages/apps-engine/src/server/accessors/LivechatCreator.ts +++ b/packages/apps-engine/src/server/accessors/LivechatCreator.ts @@ -3,7 +3,7 @@ import { randomBytes } from 'crypto'; import type { ILivechatCreator } from '../../definition/accessors'; import type { IExtraRoomParams } from '../../definition/accessors/ILivechatCreator'; import type { ILivechatRoom } from '../../definition/livechat/ILivechatRoom'; -import type { IVisitor } from '../../definition/livechat/IVisitor'; +import type { IVisitorExternalIdentifier, IVisitor, ResolveVisitorContactData } from '../../definition/livechat/IVisitor'; import type { IUser } from '../../definition/users'; import type { AppBridges } from '../bridges'; @@ -13,6 +13,10 @@ export class LivechatCreator implements ILivechatCreator { private readonly appId: string, ) {} + public resolveVisitor(externalId: IVisitorExternalIdentifier, contactData?: ResolveVisitorContactData): Promise { + return this.bridges.getLivechatBridge().doResolveVisitor(externalId, contactData, this.appId); + } + public createRoom(visitor: IVisitor, agent: IUser, extraParams?: IExtraRoomParams): Promise { return this.bridges.getLivechatBridge().doCreateRoom(visitor, agent, this.appId, extraParams); } diff --git a/packages/apps-engine/src/server/accessors/LivechatUpdater.ts b/packages/apps-engine/src/server/accessors/LivechatUpdater.ts index c5ee9626b1915..e7aa686084185 100644 --- a/packages/apps-engine/src/server/accessors/LivechatUpdater.ts +++ b/packages/apps-engine/src/server/accessors/LivechatUpdater.ts @@ -1,5 +1,5 @@ import type { ILivechatUpdater } from '../../definition/accessors'; -import type { ILivechatRoom, ILivechatTransferData, IVisitor } from '../../definition/livechat'; +import type { ILivechatRoom, ILivechatTransferData, IVisitor, IVisitorExternalIdentifier } from '../../definition/livechat'; import type { IUser } from '../../definition/users'; import type { AppBridges } from '../bridges'; @@ -23,4 +23,11 @@ export class LivechatUpdater implements ILivechatUpdater { .doSetCustomFields({ token, key, value, overwrite }, this.appId) .then((result) => result > 0); } + + public updateVisitorExternalId( + visitorId: string, + externalId: Omit, + ): Promise { + return this.bridges.getLivechatBridge().doUpdateVisitorExternalId(visitorId, externalId, this.appId); + } } diff --git a/packages/apps-engine/src/server/bridges/LivechatBridge.ts b/packages/apps-engine/src/server/bridges/LivechatBridge.ts index 24e0383f3e191..88a7575898049 100644 --- a/packages/apps-engine/src/server/bridges/LivechatBridge.ts +++ b/packages/apps-engine/src/server/bridges/LivechatBridge.ts @@ -1,6 +1,14 @@ import { BaseBridge } from './BaseBridge'; import type { IExtraRoomParams } from '../../definition/accessors/ILivechatCreator'; -import type { IDepartment, ILivechatMessage, ILivechatRoom, ILivechatTransferData, IVisitor } from '../../definition/livechat'; +import type { + IDepartment, + IVisitorExternalIdentifier, + ILivechatMessage, + ILivechatRoom, + ILivechatTransferData, + IVisitor, + ResolveVisitorContactData, +} from '../../definition/livechat'; import type { IMessage } from '../../definition/messages'; import type { IUser } from '../../definition/users'; import { PermissionDeniedError } from '../errors/PermissionDeniedError'; @@ -95,12 +103,32 @@ export abstract class LivechatBridge extends BaseBridge { } } + public async doResolveVisitor( + externalId: Omit, + contactData: ResolveVisitorContactData | undefined, + appId: string, + ): Promise { + if (this.hasWritePermission(appId, 'livechat-visitor')) { + return this.resolveVisitor(externalId, contactData, appId); + } + } + public async doTransferVisitor(visitor: IVisitor, transferData: ILivechatTransferData, appId: string): Promise { if (this.hasWritePermission(appId, 'livechat-visitor')) { return this.transferVisitor(visitor, transferData, appId); } } + public async doUpdateVisitorExternalId( + visitorId: string, + externalId: Omit, + appId: string, + ): Promise { + if (this.hasWritePermission(appId, 'livechat-visitor')) { + return this.updateVisitorExternalId(visitorId, externalId, appId); + } + } + public async doCreateRoom(visitor: IVisitor, agent: IUser, appId: string, extraParams?: IExtraRoomParams): Promise { if (this.hasWritePermission(appId, 'livechat-room')) { return this.createRoom(visitor, agent, appId, extraParams); @@ -195,8 +223,20 @@ export abstract class LivechatBridge extends BaseBridge { protected abstract findVisitorByPhoneNumber(phoneNumber: string, appId: string): Promise; + protected abstract resolveVisitor( + externalId: Omit, + contactData: ResolveVisitorContactData | undefined, + appId: string, + ): Promise; + protected abstract transferVisitor(visitor: IVisitor, transferData: ILivechatTransferData, appId: string): Promise; + protected abstract updateVisitorExternalId( + visitorId: string, + externalId: Omit, + appId: string, + ): Promise; + protected abstract createRoom(visitor: IVisitor, agent: IUser, appId: string, extraParams?: IExtraRoomParams): Promise; protected abstract closeRoom(room: ILivechatRoom, comment: string, closer: IUser | undefined, appId: string): Promise; diff --git a/packages/apps-engine/tests/test-data/bridges/livechatBridge.ts b/packages/apps-engine/tests/test-data/bridges/livechatBridge.ts index 630c7633ba362..16ef3330a4d5b 100644 --- a/packages/apps-engine/tests/test-data/bridges/livechatBridge.ts +++ b/packages/apps-engine/tests/test-data/bridges/livechatBridge.ts @@ -1,5 +1,13 @@ import type { IExtraRoomParams } from '../../../src/definition/accessors/ILivechatCreator'; -import type { IDepartment, ILivechatMessage, ILivechatRoom, ILivechatTransferData, IVisitor } from '../../../src/definition/livechat'; +import type { + IDepartment, + IVisitorExternalIdentifier, + ILivechatMessage, + ILivechatRoom, + ILivechatTransferData, + IVisitor, + ResolveVisitorContactData, +} from '../../../src/definition/livechat'; import type { IMessage } from '../../../src/definition/messages'; import type { IUser } from '../../../src/definition/users'; import { LivechatBridge } from '../../../src/server/bridges/LivechatBridge'; @@ -62,6 +70,22 @@ export class TestLivechatBridge extends LivechatBridge { throw new Error('Method not implemented'); } + public resolveVisitor( + externalId: IVisitorExternalIdentifier, + contactData: ResolveVisitorContactData | undefined, + appId: string, + ): Promise { + throw new Error('Method not implemented'); + } + + public updateVisitorExternalId( + visitorId: string, + externalId: Omit, + appId: string, + ): Promise { + throw new Error('Method not implemented'); + } + public createRoom(visitor: IVisitor, agent: IUser, appId: string, extraParams?: IExtraRoomParams): Promise { throw new Error('Method not implemented'); } diff --git a/packages/core-typings/src/ILivechatVisitor.ts b/packages/core-typings/src/ILivechatVisitor.ts index 9a033fee33e4d..2519fabd1e85b 100644 --- a/packages/core-typings/src/ILivechatVisitor.ts +++ b/packages/core-typings/src/ILivechatVisitor.ts @@ -14,6 +14,12 @@ export interface IVisitorEmail { address: string; } +export interface IVisitorExternalIdentifier { + appId: string; + entityId: string; + metadata?: Record; +} + export interface ILivechatVisitor extends IRocketChatRecord { username: string; ts: Date; @@ -26,6 +32,7 @@ export interface ILivechatVisitor extends IRocketChatRecord { ip?: string; host?: string; visitorEmails?: IVisitorEmail[]; + externalIds?: IVisitorExternalIdentifier[]; status?: UserStatus; lastAgent?: { username: string; diff --git a/packages/model-typings/src/models/ILivechatVisitorsModel.ts b/packages/model-typings/src/models/ILivechatVisitorsModel.ts index 0e39813ebce7c..8b9dbeae1c88c 100644 --- a/packages/model-typings/src/models/ILivechatVisitorsModel.ts +++ b/packages/model-typings/src/models/ILivechatVisitorsModel.ts @@ -1,4 +1,4 @@ -import type { ILivechatVisitor } from '@rocket.chat/core-typings'; +import type { IVisitorExternalIdentifier, ILivechatVisitor } from '@rocket.chat/core-typings'; import type { AggregationCursor, FindCursor, @@ -50,6 +50,16 @@ export interface ILivechatVisitorsModel extends IBaseModel { findOneVisitorByPhone(phone: string): Promise; + findOneVisitorByPhoneOrEmailAndAddExternalId( + contactData: { phone: string } | { email: string }, + appId: string, + externalId: Omit, + ): Promise; + + findOneByExternalId(entityId: string): Promise; + + updateExternalIdById(_id: string, appId: string, externalId: Omit): Promise; + removeDepartmentById(_id: string): Promise; getNextVisitorUsername(): Promise; diff --git a/packages/models/package.json b/packages/models/package.json index 4380f6bb9be11..55928754395d0 100644 --- a/packages/models/package.json +++ b/packages/models/package.json @@ -18,6 +18,7 @@ "unit": "jest" }, "dependencies": { + "@rocket.chat/core-typings": "workspace:^", "@rocket.chat/model-typings": "workspace:~", "@rocket.chat/random": "workspace:^", "@rocket.chat/rest-typings": "workspace:^", diff --git a/packages/models/src/models/LivechatVisitors.ts b/packages/models/src/models/LivechatVisitors.ts index bf15dc4c650dc..197835d30d9bd 100644 --- a/packages/models/src/models/LivechatVisitors.ts +++ b/packages/models/src/models/LivechatVisitors.ts @@ -1,4 +1,4 @@ -import type { ILivechatVisitor, RocketChatRecordDeleted } from '@rocket.chat/core-typings'; +import type { IVisitorExternalIdentifier, ILivechatVisitor, RocketChatRecordDeleted } from '@rocket.chat/core-typings'; import type { FindPaginated, ILivechatVisitorsModel } from '@rocket.chat/model-typings'; import { escapeRegExp } from '@rocket.chat/string-helpers'; import type { @@ -31,6 +31,7 @@ export class LivechatVisitorsRaw extends BaseRaw implements IL { key: { token: 1 } }, { key: { 'phone.phoneNumber': 1 }, sparse: true }, { key: { 'visitorEmails.address': 1 }, sparse: true }, + { key: { 'externalIds.entityId': 1 }, sparse: true }, { key: { name: 1 }, sparse: true }, { key: { username: 1 } }, { key: { 'contactMananger.username': 1 }, sparse: true }, @@ -49,6 +50,51 @@ export class LivechatVisitorsRaw extends BaseRaw implements IL return this.findOne(query); } + async findOneVisitorByPhoneOrEmailAndAddExternalId( + contactData: { phone: string } | { email: string }, + appId: string, + externalId: Omit, + ): Promise { + const query = + 'phone' in contactData ? { 'phone.phoneNumber': contactData.phone } : { 'visitorEmails.address': contactData.email.toLowerCase() }; + + const visitor = await this.findOne(query); + if (!visitor) { + return null; + } + + const filteredIds = visitor.externalIds?.filter((e) => e.appId !== appId) ?? []; + const newExternalIds = [...filteredIds, { appId, ...externalId }]; + + await this.updateOne({ _id: visitor._id }, { $set: { externalIds: newExternalIds } }); + + return { ...visitor, externalIds: newExternalIds }; + } + + findOneByExternalId(entityId: string): Promise { + return this.findOne({ + 'externalIds.entityId': entityId, + }); + } + + async updateExternalIdById( + _id: string, + appId: string, + externalId: Omit, + ): Promise { + const visitor = await this.findOne({ _id }); + if (!visitor) { + return null; + } + + const filteredIds = visitor.externalIds?.filter((e) => e.appId !== appId) ?? []; + const newExternalIds = [...filteredIds, { appId, ...externalId }]; + + await this.updateOne({ _id }, { $set: { externalIds: newExternalIds } }); + + return { ...visitor, externalIds: newExternalIds }; + } + async findOneGuestByEmailAddress(emailAddress: string): Promise { if (!emailAddress) { return null; diff --git a/packages/omni-core/src/visitor/create.ts b/packages/omni-core/src/visitor/create.ts index 8e91b7c49efa3..e4df7d70f305e 100644 --- a/packages/omni-core/src/visitor/create.ts +++ b/packages/omni-core/src/visitor/create.ts @@ -1,4 +1,4 @@ -import { type ILivechatVisitor, UserStatus } from '@rocket.chat/core-typings'; +import { type ILivechatVisitor, type IVisitorExternalIdentifier, UserStatus } from '@rocket.chat/core-typings'; import { Logger } from '@rocket.chat/logger'; import { LivechatContacts, LivechatDepartment, LivechatVisitors, Users } from '@rocket.chat/models'; import { makeFunction } from '@rocket.chat/patch-injection'; @@ -11,11 +11,12 @@ type RegisterGuestType = Partial => { if (!token) { @@ -29,6 +30,7 @@ export const registerGuest = makeFunction( status, ...(phone?.number && { phone: [{ phoneNumber: phone.number }] }), ...(name && { name }), + ...(externalIds?.length && { externalIds }), }; if (email) { diff --git a/yarn.lock b/yarn.lock index fdab5e3202e81..6a7e1271054a7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10387,6 +10387,7 @@ __metadata: version: 0.0.0-use.local resolution: "@rocket.chat/models@workspace:packages/models" dependencies: + "@rocket.chat/core-typings": "workspace:^" "@rocket.chat/jest-presets": "workspace:~" "@rocket.chat/model-typings": "workspace:~" "@rocket.chat/random": "workspace:^"