From 986e803f1b6b7fad943e529b60727af9159a23cb Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Tue, 10 Mar 2026 14:51:36 -0300 Subject: [PATCH 01/41] feat: add IVisitorExternalIdentifier type for external visitor IDs --- packages/core-typings/src/ILivechatVisitor.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/core-typings/src/ILivechatVisitor.ts b/packages/core-typings/src/ILivechatVisitor.ts index 9a033fee33e4d..bac6ba76d90ee 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 { + source: string; + userId: string; + username?: string; +} + 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; From 303ece0d3d53b5a6d3f1981c31c7c15735e28389 Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Tue, 10 Mar 2026 14:51:56 -0300 Subject: [PATCH 02/41] feat: add findOneByExternalId and addExternalId methods to LivechatVisitors --- .../src/models/ILivechatVisitorsModel.ts | 6 ++++- .../models/src/models/LivechatVisitors.ts | 22 ++++++++++++++++++- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/packages/model-typings/src/models/ILivechatVisitorsModel.ts b/packages/model-typings/src/models/ILivechatVisitorsModel.ts index 0e39813ebce7c..3b002a60b4a0f 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,10 @@ export interface ILivechatVisitorsModel extends IBaseModel { findOneVisitorByPhone(phone: string): Promise; + findOneByExternalId(source: string, externalUserId: string): Promise; + + addExternalId(_id: string, externalId: IVisitorExternalIdentifier): Promise; + removeDepartmentById(_id: string): Promise; getNextVisitorUsername(): Promise; diff --git a/packages/models/src/models/LivechatVisitors.ts b/packages/models/src/models/LivechatVisitors.ts index bf15dc4c650dc..f3744a8785a83 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.source': 1, 'externalIds.userId': 1 }, sparse: true }, { key: { name: 1 }, sparse: true }, { key: { username: 1 } }, { key: { 'contactMananger.username': 1 }, sparse: true }, @@ -49,6 +50,25 @@ export class LivechatVisitorsRaw extends BaseRaw implements IL return this.findOne(query); } + findOneByExternalId(source: string, externalUserId: string): Promise { + const query = { + 'externalIds.source': source, + 'externalIds.userId': externalUserId, + }; + + return this.findOne(query); + } + + addExternalId(_id: string, externalId: IVisitorExternalIdentifier): Promise { + return this.updateOne({ _id }, [ + { + $set: { + externalIds: { $setUnion: [{ $ifNull: ['$externalIds', []] }, [externalId]] }, + }, + }, + ]); + } + async findOneGuestByEmailAddress(emailAddress: string): Promise { if (!emailAddress) { return null; From 42034537bfd4cc20854768bb68bdb8dfab072418 Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Tue, 10 Mar 2026 14:52:22 -0300 Subject: [PATCH 03/41] feat: add resolveVisitor for external ID lookup with progressive enrichment --- .../app/livechat/server/lib/resolveVisitor.ts | 28 ++++ .../server/lib/resolveVisitor.spec.ts | 120 ++++++++++++++++++ packages/omni-core/src/visitor/create.ts | 6 +- 3 files changed, 152 insertions(+), 2 deletions(-) create mode 100644 apps/meteor/app/livechat/server/lib/resolveVisitor.ts create mode 100644 apps/meteor/tests/unit/app/livechat/server/lib/resolveVisitor.spec.ts 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..55a75ae55cc68 --- /dev/null +++ b/apps/meteor/app/livechat/server/lib/resolveVisitor.ts @@ -0,0 +1,28 @@ +import type { IVisitorExternalIdentifier, ILivechatVisitor } from '@rocket.chat/core-typings'; +import { LivechatVisitors } from '@rocket.chat/models'; + +type ResolveVisitorParams = { + externalId: IVisitorExternalIdentifier; + phone?: string; +}; + +export async function resolveVisitor({ externalId, phone }: ResolveVisitorParams): Promise { + const visitorByExternalId = await LivechatVisitors.findOneByExternalId(externalId.source, externalId.userId); + if (visitorByExternalId) { + return visitorByExternalId; + } + + if (phone) { + const visitorByPhone = await LivechatVisitors.findOneVisitorByPhone(phone); + if (visitorByPhone) { + // Enrich existing visitor with external ID (progressive enrichment) + await LivechatVisitors.addExternalId(visitorByPhone._id, externalId); + return { + ...visitorByPhone, + externalIds: [...(visitorByPhone.externalIds || []), externalId], + }; + } + } + + return null; +} diff --git a/apps/meteor/tests/unit/app/livechat/server/lib/resolveVisitor.spec.ts b/apps/meteor/tests/unit/app/livechat/server/lib/resolveVisitor.spec.ts new file mode 100644 index 0000000000000..f8753b45d0a77 --- /dev/null +++ b/apps/meteor/tests/unit/app/livechat/server/lib/resolveVisitor.spec.ts @@ -0,0 +1,120 @@ +import { expect } from 'chai'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +const modelsMock = { + LivechatVisitors: { + findOneByExternalId: sinon.stub(), + findOneVisitorByPhone: sinon.stub(), + addExternalId: sinon.stub(), + }, +}; + +const { resolveVisitor } = proxyquire.noCallThru().load('../../../../../../app/livechat/server/lib/resolveVisitor.ts', { + '@rocket.chat/models': modelsMock, +}); + +describe('resolveVisitor', () => { + beforeEach(() => { + modelsMock.LivechatVisitors.findOneByExternalId.reset(); + modelsMock.LivechatVisitors.findOneVisitorByPhone.reset(); + modelsMock.LivechatVisitors.addExternalId.reset(); + }); + + it('should return visitor when found by external ID without phone fallback', async () => { + const existingVisitor = { + _id: 'visitor-123', + token: 'token-123', + username: 'guest-1', + externalIds: [{ source: 'whatsapp', userId: 'bsuid-123' }], + }; + + modelsMock.LivechatVisitors.findOneByExternalId.resolves(existingVisitor); + + const result = await resolveVisitor({ + externalId: { source: 'whatsapp', userId: 'bsuid-123' }, + phone: '1234567890', + }); + + expect(result).to.deep.equal(existingVisitor); + expect(modelsMock.LivechatVisitors.findOneByExternalId.calledOnceWith('whatsapp', 'bsuid-123')).to.be.true; + expect(modelsMock.LivechatVisitors.findOneVisitorByPhone.called).to.be.false; + expect(modelsMock.LivechatVisitors.addExternalId.called).to.be.false; + }); + + it('should find by phone, enrich with external ID, and return visitor when not found by external ID', async () => { + const existingVisitor = { + _id: 'visitor-456', + token: 'token-456', + username: 'guest-2', + }; + const externalId = { source: 'whatsapp', userId: 'bsuid-456', username: '@johndoe' }; + + modelsMock.LivechatVisitors.findOneByExternalId.resolves(null); + modelsMock.LivechatVisitors.findOneVisitorByPhone.resolves(existingVisitor); + modelsMock.LivechatVisitors.addExternalId.resolves({ modifiedCount: 1 }); + + const result = await resolveVisitor({ externalId, phone: '9876543210' }); + + expect(result).to.deep.equal({ ...existingVisitor, externalIds: [externalId] }); + expect(modelsMock.LivechatVisitors.findOneByExternalId.calledOnce).to.be.true; + expect(modelsMock.LivechatVisitors.findOneVisitorByPhone.calledOnceWith('9876543210')).to.be.true; + expect(modelsMock.LivechatVisitors.addExternalId.calledOnceWith('visitor-456', externalId)).to.be.true; + }); + + it('should append to existing externalIds when visitor already has some', async () => { + const existingExternalId = { source: 'telegram', userId: 'tg-123' }; + const existingVisitor = { + _id: 'visitor-789', + token: 'token-789', + username: 'guest-3', + externalIds: [existingExternalId], + }; + const newExternalId = { source: 'whatsapp', userId: 'bsuid-789' }; + + modelsMock.LivechatVisitors.findOneByExternalId.resolves(null); + modelsMock.LivechatVisitors.findOneVisitorByPhone.resolves(existingVisitor); + modelsMock.LivechatVisitors.addExternalId.resolves({ modifiedCount: 1 }); + + const result = await resolveVisitor({ externalId: newExternalId, phone: '5555555555' }); + + expect(result).to.deep.equal({ ...existingVisitor, externalIds: [existingExternalId, newExternalId] }); + }); + + it('should return null when not found by external ID and no phone provided', async () => { + modelsMock.LivechatVisitors.findOneByExternalId.resolves(null); + + const result = await resolveVisitor({ externalId: { source: 'whatsapp', userId: 'bsuid-unknown' } }); + + expect(result).to.be.null; + expect(modelsMock.LivechatVisitors.findOneByExternalId.calledOnce).to.be.true; + expect(modelsMock.LivechatVisitors.findOneVisitorByPhone.called).to.be.false; + }); + + it('should return null when not found by external ID or phone', async () => { + modelsMock.LivechatVisitors.findOneByExternalId.resolves(null); + modelsMock.LivechatVisitors.findOneVisitorByPhone.resolves(null); + + const result = await resolveVisitor({ + externalId: { source: 'whatsapp', userId: 'bsuid-unknown' }, + phone: '0000000000', + }); + + expect(result).to.be.null; + expect(modelsMock.LivechatVisitors.findOneByExternalId.calledOnce).to.be.true; + expect(modelsMock.LivechatVisitors.findOneVisitorByPhone.calledOnce).to.be.true; + expect(modelsMock.LivechatVisitors.addExternalId.called).to.be.false; + }); + + it('should not attempt phone lookup when phone is empty string', async () => { + modelsMock.LivechatVisitors.findOneByExternalId.resolves(null); + + const result = await resolveVisitor({ + externalId: { source: 'whatsapp', userId: 'bsuid-123' }, + phone: '', + }); + + expect(result).to.be.null; + expect(modelsMock.LivechatVisitors.findOneVisitorByPhone.called).to.be.false; + }); +}); 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) { From f386bbf8d350e6e51ffd9caf64e7233152adfa58 Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Tue, 10 Mar 2026 14:53:14 -0300 Subject: [PATCH 04/41] feat: add resolveVisitor method to ILivechatCreator --- apps/meteor/app/apps/server/bridges/livechat.ts | 12 +++++++++++- apps/meteor/app/apps/server/converters/visitors.js | 2 ++ .../src/definition/accessors/ILivechatCreator.ts | 10 +++++++++- .../apps-engine/src/definition/livechat/IVisitor.ts | 7 +++++++ .../apps-engine/src/definition/livechat/index.ts | 3 ++- .../src/server/accessors/LivechatCreator.ts | 6 +++++- .../apps-engine/src/server/bridges/LivechatBridge.ts | 10 +++++++++- .../tests/test-data/bridges/livechatBridge.ts | 6 +++++- 8 files changed, 50 insertions(+), 6 deletions(-) diff --git a/apps/meteor/app/apps/server/bridges/livechat.ts b/apps/meteor/app/apps/server/bridges/livechat.ts index 6952ac6f5457c..87e699b31f342 100644 --- a/apps/meteor/app/apps/server/bridges/livechat.ts +++ b/apps/meteor/app/apps/server/bridges/livechat.ts @@ -1,6 +1,6 @@ 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 } 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'; @@ -12,6 +12,7 @@ import { registerGuest } from '@rocket.chat/omni-core'; import { deasyncPromise } from '../../../../server/deasync/deasync'; import { callbacks } from '../../../../server/lib/callbacks'; import { closeRoom } from '../../../livechat/server/lib/closeRoom'; +import { resolveVisitor } from '../../../livechat/server/lib/resolveVisitor'; import { setCustomFields } from '../../../livechat/server/lib/custom-fields'; import { getRoomMessages } from '../../../livechat/server/lib/getRoomMessages'; import type { ILivechatMessage } from '../../../livechat/server/lib/localTypes'; @@ -235,6 +236,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 }), + ...(visitor.externalIds?.length && { externalIds: visitor.externalIds }), }; const livechatVisitor = await registerGuest(registerData, { @@ -335,6 +337,14 @@ export class AppLivechatBridge extends LivechatBridge { .convertVisitor(await LivechatVisitors.findOneVisitorByPhone(phoneNumber)); } + protected async resolveVisitor(externalId: IVisitorExternalIdentifier, phone: string | undefined, appId: string): Promise { + this.orch.debugLog(`The App ${appId} is resolving a livechat visitor by external ID.`); + + const visitor = await resolveVisitor({ externalId, phone }); + + 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/packages/apps-engine/src/definition/accessors/ILivechatCreator.ts b/packages/apps-engine/src/definition/accessors/ILivechatCreator.ts index c1843f30b22eb..0a22fdc5143e3 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 } 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 phone fallback. + * If found by phone but not by externalId, enriches the visitor record with the externalId. + * @param externalId The external identifier containing source, userId, and optional username + * @param phone Optional phone number for fallback lookup + * @returns The visitor if found, undefined otherwise + */ + resolveVisitor(externalId: IVisitorExternalIdentifier, phone?: string): Promise; /** * Creates a room to connect the `visitor` to an `agent`. * diff --git a/packages/apps-engine/src/definition/livechat/IVisitor.ts b/packages/apps-engine/src/definition/livechat/IVisitor.ts index dc04777b8e803..87b3940b5eed8 100644 --- a/packages/apps-engine/src/definition/livechat/IVisitor.ts +++ b/packages/apps-engine/src/definition/livechat/IVisitor.ts @@ -1,6 +1,12 @@ import type { IVisitorEmail } from './IVisitorEmail'; import type { IVisitorPhone } from './IVisitorPhone'; +export interface IVisitorExternalIdentifier { + source: string; + userId: string; + username?: string; +} + export interface IVisitor { id?: string; token: string; @@ -14,4 +20,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..51672147fe2a0 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 } from './IVisitor'; import { IVisitorEmail } from './IVisitorEmail'; import { IVisitorPhone } from './IVisitorPhone'; export { + IVisitorExternalIdentifier, ILivechatEventContext, ILivechatMessage, ILivechatRoom, diff --git a/packages/apps-engine/src/server/accessors/LivechatCreator.ts b/packages/apps-engine/src/server/accessors/LivechatCreator.ts index a15e9e822efe9..1af55d01b0aeb 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 } 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, phone?: string): Promise { + return this.bridges.getLivechatBridge().doResolveVisitor(externalId, phone, 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/bridges/LivechatBridge.ts b/packages/apps-engine/src/server/bridges/LivechatBridge.ts index 24e0383f3e191..d404d1f0c373d 100644 --- a/packages/apps-engine/src/server/bridges/LivechatBridge.ts +++ b/packages/apps-engine/src/server/bridges/LivechatBridge.ts @@ -1,6 +1,6 @@ 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 } from '../../definition/livechat'; import type { IMessage } from '../../definition/messages'; import type { IUser } from '../../definition/users'; import { PermissionDeniedError } from '../errors/PermissionDeniedError'; @@ -95,6 +95,12 @@ export abstract class LivechatBridge extends BaseBridge { } } + public async doResolveVisitor(externalId: IVisitorExternalIdentifier, phone: string | undefined, appId: string): Promise { + if (this.hasReadPermission(appId, 'livechat-visitor')) { + return this.resolveVisitor(externalId, phone, appId); + } + } + public async doTransferVisitor(visitor: IVisitor, transferData: ILivechatTransferData, appId: string): Promise { if (this.hasWritePermission(appId, 'livechat-visitor')) { return this.transferVisitor(visitor, transferData, appId); @@ -195,6 +201,8 @@ export abstract class LivechatBridge extends BaseBridge { protected abstract findVisitorByPhoneNumber(phoneNumber: string, appId: string): Promise; + protected abstract resolveVisitor(externalId: IVisitorExternalIdentifier, phone: string | undefined, appId: string): Promise; + protected abstract transferVisitor(visitor: IVisitor, transferData: ILivechatTransferData, appId: string): Promise; protected abstract createRoom(visitor: IVisitor, agent: IUser, appId: string, extraParams?: IExtraRoomParams): 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..3b912bea47c4f 100644 --- a/packages/apps-engine/tests/test-data/bridges/livechatBridge.ts +++ b/packages/apps-engine/tests/test-data/bridges/livechatBridge.ts @@ -1,5 +1,5 @@ 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 } 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 +62,10 @@ export class TestLivechatBridge extends LivechatBridge { throw new Error('Method not implemented'); } + public resolveVisitor(externalId: IVisitorExternalIdentifier, phone: string | undefined, 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'); } From ca61b376dac01f5f0e678c8ad0a4b168644e9706 Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Tue, 10 Mar 2026 14:53:41 -0300 Subject: [PATCH 05/41] feat: display WhatsApp username alongside phone in contact info --- .../ContactInfoChannelsItem.tsx | 18 +++++++++++++++++- .../directory/components/ContactField.tsx | 7 ++++--- 2 files changed, 21 insertions(+), 4 deletions(-) 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..d7a09e9898a18 100644 --- a/apps/meteor/client/views/omnichannel/contactInfo/tabs/ContactInfoChannels/ContactInfoChannelsItem.tsx +++ b/apps/meteor/client/views/omnichannel/contactInfo/tabs/ContactInfoChannels/ContactInfoChannelsItem.tsx @@ -9,6 +9,7 @@ import { useTranslation } from 'react-i18next'; import { useBlockChannel } from './useBlockChannel'; import { OmnichannelRoomIcon } from '../../../../../components/RoomIcon/OmnichannelRoomIcon'; import { useTimeFromNow } from '../../../../../hooks/useTimeFromNow'; +import { useVisitorInfo } from '../../../directory/hooks/useVisitorInfo'; import { useOutboundMessageModal } from '../../../components/outboundMessage/modals/OutboundMessageModal'; import { useOmnichannelSource } from '../../../hooks/useOmnichannelSource'; @@ -29,6 +30,21 @@ 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 = visitorData?.externalIds?.find( + (id) => id.source === details?.type || id.source === details?.id || id.source === details?.defaultIcon, + ); + const username = externalId?.username; + + if (username && phone) { + return `${username} · ${phone}`; + } + return username || phone; + }, [visitorData?.externalIds, details, getSourceLabel]); + const [showButton, setShowButton] = useState(false); const handleBlockContact = useBlockChannel({ association: visitor, blocked }); const outboundMessageModal = useOutboundMessageModal(); @@ -94,7 +110,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..ae5ed5b92906d 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 shortName = username && phoneNumber && username !== phoneNumber ? `${username} · ${phoneNumber}` : username || phoneNumber; return ( - } /> + } /> ); From bb87ace55269a8fb4be11fe64696b0cd3784a403 Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Wed, 11 Mar 2026 08:41:41 -0300 Subject: [PATCH 06/41] eslint fix --- apps/meteor/app/apps/server/bridges/livechat.ts | 16 +++++++++++++--- .../ContactInfoChannelsItem.tsx | 2 +- .../tests/test-data/bridges/livechatBridge.ts | 9 ++++++++- 3 files changed, 22 insertions(+), 5 deletions(-) diff --git a/apps/meteor/app/apps/server/bridges/livechat.ts b/apps/meteor/app/apps/server/bridges/livechat.ts index 87e699b31f342..d56e43e94ebb1 100644 --- a/apps/meteor/app/apps/server/bridges/livechat.ts +++ b/apps/meteor/app/apps/server/bridges/livechat.ts @@ -1,6 +1,12 @@ import type { IAppServerOrchestrator, IAppsLivechatMessage, IAppsMessage } from '@rocket.chat/apps'; import type { IExtraRoomParams } from '@rocket.chat/apps-engine/definition/accessors/ILivechatCreator'; -import type { IVisitorExternalIdentifier, IVisitor, ILivechatRoom, ILivechatTransferData, IDepartment } from '@rocket.chat/apps-engine/definition/livechat'; +import type { + IVisitorExternalIdentifier, + IVisitor, + ILivechatRoom, + ILivechatTransferData, + IDepartment, +} 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'; @@ -12,11 +18,11 @@ import { registerGuest } from '@rocket.chat/omni-core'; import { deasyncPromise } from '../../../../server/deasync/deasync'; import { callbacks } from '../../../../server/lib/callbacks'; import { closeRoom } from '../../../livechat/server/lib/closeRoom'; -import { resolveVisitor } from '../../../livechat/server/lib/resolveVisitor'; 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'; @@ -337,7 +343,11 @@ export class AppLivechatBridge extends LivechatBridge { .convertVisitor(await LivechatVisitors.findOneVisitorByPhone(phoneNumber)); } - protected async resolveVisitor(externalId: IVisitorExternalIdentifier, phone: string | undefined, appId: string): Promise { + protected async resolveVisitor( + externalId: IVisitorExternalIdentifier, + phone: string | undefined, + appId: string, + ): Promise { this.orch.debugLog(`The App ${appId} is resolving a livechat visitor by external ID.`); const visitor = await resolveVisitor({ externalId, phone }); 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 d7a09e9898a18..9937db1c9cefa 100644 --- a/apps/meteor/client/views/omnichannel/contactInfo/tabs/ContactInfoChannels/ContactInfoChannelsItem.tsx +++ b/apps/meteor/client/views/omnichannel/contactInfo/tabs/ContactInfoChannels/ContactInfoChannelsItem.tsx @@ -9,8 +9,8 @@ import { useTranslation } from 'react-i18next'; import { useBlockChannel } from './useBlockChannel'; import { OmnichannelRoomIcon } from '../../../../../components/RoomIcon/OmnichannelRoomIcon'; import { useTimeFromNow } from '../../../../../hooks/useTimeFromNow'; -import { useVisitorInfo } from '../../../directory/hooks/useVisitorInfo'; import { useOutboundMessageModal } from '../../../components/outboundMessage/modals/OutboundMessageModal'; +import { useVisitorInfo } from '../../../directory/hooks/useVisitorInfo'; import { useOmnichannelSource } from '../../../hooks/useOmnichannelSource'; type ContactInfoChannelsItemProps = Serialized & { diff --git a/packages/apps-engine/tests/test-data/bridges/livechatBridge.ts b/packages/apps-engine/tests/test-data/bridges/livechatBridge.ts index 3b912bea47c4f..c660a5fb65975 100644 --- a/packages/apps-engine/tests/test-data/bridges/livechatBridge.ts +++ b/packages/apps-engine/tests/test-data/bridges/livechatBridge.ts @@ -1,5 +1,12 @@ import type { IExtraRoomParams } from '../../../src/definition/accessors/ILivechatCreator'; -import type { IDepartment, IVisitorExternalIdentifier, ILivechatMessage, ILivechatRoom, ILivechatTransferData, IVisitor } from '../../../src/definition/livechat'; +import type { + IDepartment, + IVisitorExternalIdentifier, + ILivechatMessage, + ILivechatRoom, + ILivechatTransferData, + IVisitor, +} 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'; From 0c75e6ba3b0e2aab70a02f364dced8613682a320 Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Wed, 11 Mar 2026 12:47:30 -0300 Subject: [PATCH 07/41] chore: document createVisitor deprecation --- apps/meteor/app/apps/server/bridges/livechat.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/meteor/app/apps/server/bridges/livechat.ts b/apps/meteor/app/apps/server/bridges/livechat.ts index d56e43e94ebb1..2f532fc882017 100644 --- a/apps/meteor/app/apps/server/bridges/livechat.ts +++ b/apps/meteor/app/apps/server/bridges/livechat.ts @@ -205,6 +205,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.`); From 61b45f51b71f29dfc3391239648408a2fa31c216 Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Wed, 11 Mar 2026 13:02:39 -0300 Subject: [PATCH 08/41] fix: change doResolveVisitor permission from read to write --- packages/apps-engine/src/server/bridges/LivechatBridge.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/apps-engine/src/server/bridges/LivechatBridge.ts b/packages/apps-engine/src/server/bridges/LivechatBridge.ts index d404d1f0c373d..0338d94ef0cf3 100644 --- a/packages/apps-engine/src/server/bridges/LivechatBridge.ts +++ b/packages/apps-engine/src/server/bridges/LivechatBridge.ts @@ -96,7 +96,7 @@ export abstract class LivechatBridge extends BaseBridge { } public async doResolveVisitor(externalId: IVisitorExternalIdentifier, phone: string | undefined, appId: string): Promise { - if (this.hasReadPermission(appId, 'livechat-visitor')) { + if (this.hasWritePermission(appId, 'livechat-visitor')) { return this.resolveVisitor(externalId, phone, appId); } } From 6b24b1f55402f114b2a876d039c6673d271caa22 Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Wed, 11 Mar 2026 13:28:53 -0300 Subject: [PATCH 09/41] fix: correct externalIds query and deduplication logic --- .../models/src/models/LivechatVisitors.ts | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/packages/models/src/models/LivechatVisitors.ts b/packages/models/src/models/LivechatVisitors.ts index f3744a8785a83..36fc765ba2914 100644 --- a/packages/models/src/models/LivechatVisitors.ts +++ b/packages/models/src/models/LivechatVisitors.ts @@ -51,19 +51,26 @@ export class LivechatVisitorsRaw extends BaseRaw implements IL } findOneByExternalId(source: string, externalUserId: string): Promise { - const query = { - 'externalIds.source': source, - 'externalIds.userId': externalUserId, - }; - - return this.findOne(query); + return this.findOne({ + externalIds: { $elemMatch: { source, userId: externalUserId } }, + }); } addExternalId(_id: string, externalId: IVisitorExternalIdentifier): Promise { return this.updateOne({ _id }, [ { $set: { - externalIds: { $setUnion: [{ $ifNull: ['$externalIds', []] }, [externalId]] }, + externalIds: { + $concatArrays: [ + { + $filter: { + input: { $ifNull: ['$externalIds', []] }, + cond: { $ne: ['$$this.source', externalId.source] }, + }, + }, + [externalId], + ], + }, }, }, ]); From b1faad27d2cd1966b0c4247f23bfb9359306ba51 Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Wed, 11 Mar 2026 22:58:10 -0300 Subject: [PATCH 10/41] refactor: change visitor externalIds from array to record structure --- .../app/apps/server/bridges/livechat.ts | 8 +++- .../app/livechat/server/lib/resolveVisitor.ts | 12 ++++-- .../ContactInfoChannelsItem.tsx | 6 +-- .../server/lib/resolveVisitor.spec.ts | 41 +++++++++++-------- .../src/definition/livechat/IVisitor.ts | 3 +- packages/core-typings/src/ILivechatVisitor.ts | 3 +- .../src/models/ILivechatVisitorsModel.ts | 2 +- .../models/src/models/LivechatVisitors.ts | 24 ++--------- packages/omni-core/src/visitor/create.ts | 4 +- 9 files changed, 50 insertions(+), 53 deletions(-) diff --git a/apps/meteor/app/apps/server/bridges/livechat.ts b/apps/meteor/app/apps/server/bridges/livechat.ts index 2f532fc882017..c315be0e45ab2 100644 --- a/apps/meteor/app/apps/server/bridges/livechat.ts +++ b/apps/meteor/app/apps/server/bridges/livechat.ts @@ -246,7 +246,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 }), - ...(visitor.externalIds?.length && { externalIds: visitor.externalIds }), + ...(visitor.externalIds && { externalIds: visitor.externalIds }), }; const livechatVisitor = await registerGuest(registerData, { @@ -354,7 +354,11 @@ export class AppLivechatBridge extends LivechatBridge { ): Promise { this.orch.debugLog(`The App ${appId} is resolving a livechat visitor by external ID.`); - const visitor = await resolveVisitor({ externalId, phone }); + const visitor = await resolveVisitor({ + source: appId, + externalId, + phone, + }); return this.orch.getConverters()?.get('visitors').convertVisitor(visitor); } diff --git a/apps/meteor/app/livechat/server/lib/resolveVisitor.ts b/apps/meteor/app/livechat/server/lib/resolveVisitor.ts index 55a75ae55cc68..5466a3acdec07 100644 --- a/apps/meteor/app/livechat/server/lib/resolveVisitor.ts +++ b/apps/meteor/app/livechat/server/lib/resolveVisitor.ts @@ -2,12 +2,13 @@ import type { IVisitorExternalIdentifier, ILivechatVisitor } from '@rocket.chat/ import { LivechatVisitors } from '@rocket.chat/models'; type ResolveVisitorParams = { + source: string; externalId: IVisitorExternalIdentifier; phone?: string; }; -export async function resolveVisitor({ externalId, phone }: ResolveVisitorParams): Promise { - const visitorByExternalId = await LivechatVisitors.findOneByExternalId(externalId.source, externalId.userId); +export async function resolveVisitor({ source, externalId, phone }: ResolveVisitorParams): Promise { + const visitorByExternalId = await LivechatVisitors.findOneByExternalId(source, externalId.userId); if (visitorByExternalId) { return visitorByExternalId; } @@ -16,10 +17,13 @@ export async function resolveVisitor({ externalId, phone }: ResolveVisitorParams const visitorByPhone = await LivechatVisitors.findOneVisitorByPhone(phone); if (visitorByPhone) { // Enrich existing visitor with external ID (progressive enrichment) - await LivechatVisitors.addExternalId(visitorByPhone._id, externalId); + await LivechatVisitors.addExternalId(visitorByPhone._id, source, externalId); return { ...visitorByPhone, - externalIds: [...(visitorByPhone.externalIds || []), externalId], + externalIds: { + ...(visitorByPhone.externalIds ?? {}), + [source]: externalId, + }, }; } } 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 9937db1c9cefa..dcd39af7ded1e 100644 --- a/apps/meteor/client/views/omnichannel/contactInfo/tabs/ContactInfoChannels/ContactInfoChannelsItem.tsx +++ b/apps/meteor/client/views/omnichannel/contactInfo/tabs/ContactInfoChannels/ContactInfoChannelsItem.tsx @@ -34,13 +34,11 @@ const ContactInfoChannelsItem = ({ const channelLabel = useMemo(() => { const phone = getSourceLabel(details); - const externalId = visitorData?.externalIds?.find( - (id) => id.source === details?.type || id.source === details?.id || id.source === details?.defaultIcon, - ); + const externalId = details?.id ? visitorData?.externalIds?.[details.id] : undefined; const username = externalId?.username; if (username && phone) { - return `${username} · ${phone}`; + return `${username} - ${phone}`; } return username || phone; }, [visitorData?.externalIds, details, getSourceLabel]); diff --git a/apps/meteor/tests/unit/app/livechat/server/lib/resolveVisitor.spec.ts b/apps/meteor/tests/unit/app/livechat/server/lib/resolveVisitor.spec.ts index f8753b45d0a77..47fb41acf85ae 100644 --- a/apps/meteor/tests/unit/app/livechat/server/lib/resolveVisitor.spec.ts +++ b/apps/meteor/tests/unit/app/livechat/server/lib/resolveVisitor.spec.ts @@ -14,6 +14,9 @@ const { resolveVisitor } = proxyquire.noCallThru().load('../../../../../../app/l '@rocket.chat/models': modelsMock, }); +// Mock app ID (UUID format as used by Rocket.Chat apps) +const appId = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'; + describe('resolveVisitor', () => { beforeEach(() => { modelsMock.LivechatVisitors.findOneByExternalId.reset(); @@ -26,18 +29,19 @@ describe('resolveVisitor', () => { _id: 'visitor-123', token: 'token-123', username: 'guest-1', - externalIds: [{ source: 'whatsapp', userId: 'bsuid-123' }], + externalIds: { [appId]: { userId: 'bsuid-123' } }, }; modelsMock.LivechatVisitors.findOneByExternalId.resolves(existingVisitor); const result = await resolveVisitor({ - externalId: { source: 'whatsapp', userId: 'bsuid-123' }, + source: appId, + externalId: { userId: 'bsuid-123' }, phone: '1234567890', }); expect(result).to.deep.equal(existingVisitor); - expect(modelsMock.LivechatVisitors.findOneByExternalId.calledOnceWith('whatsapp', 'bsuid-123')).to.be.true; + expect(modelsMock.LivechatVisitors.findOneByExternalId.calledOnceWith(appId, 'bsuid-123')).to.be.true; expect(modelsMock.LivechatVisitors.findOneVisitorByPhone.called).to.be.false; expect(modelsMock.LivechatVisitors.addExternalId.called).to.be.false; }); @@ -48,43 +52,46 @@ describe('resolveVisitor', () => { token: 'token-456', username: 'guest-2', }; - const externalId = { source: 'whatsapp', userId: 'bsuid-456', username: '@johndoe' }; + const externalId = { userId: 'bsuid-456', username: '@johndoe' }; modelsMock.LivechatVisitors.findOneByExternalId.resolves(null); modelsMock.LivechatVisitors.findOneVisitorByPhone.resolves(existingVisitor); modelsMock.LivechatVisitors.addExternalId.resolves({ modifiedCount: 1 }); - const result = await resolveVisitor({ externalId, phone: '9876543210' }); + const result = await resolveVisitor({ source: appId, externalId, phone: '9876543210' }); - expect(result).to.deep.equal({ ...existingVisitor, externalIds: [externalId] }); + expect(result).to.deep.equal({ ...existingVisitor, externalIds: { [appId]: externalId } }); expect(modelsMock.LivechatVisitors.findOneByExternalId.calledOnce).to.be.true; expect(modelsMock.LivechatVisitors.findOneVisitorByPhone.calledOnceWith('9876543210')).to.be.true; - expect(modelsMock.LivechatVisitors.addExternalId.calledOnceWith('visitor-456', externalId)).to.be.true; + expect(modelsMock.LivechatVisitors.addExternalId.calledOnceWith('visitor-456', appId, externalId)).to.be.true; }); - it('should append to existing externalIds when visitor already has some', async () => { - const existingExternalId = { source: 'telegram', userId: 'tg-123' }; + it('should update existing externalIds when visitor already has some', async () => { + const existingExternalId = { userId: 'bsuid-old' }; const existingVisitor = { _id: 'visitor-789', token: 'token-789', username: 'guest-3', - externalIds: [existingExternalId], + externalIds: { [appId]: existingExternalId }, }; - const newExternalId = { source: 'whatsapp', userId: 'bsuid-789' }; + const newExternalId = { userId: 'bsuid-789', username: '@newuser' }; modelsMock.LivechatVisitors.findOneByExternalId.resolves(null); modelsMock.LivechatVisitors.findOneVisitorByPhone.resolves(existingVisitor); modelsMock.LivechatVisitors.addExternalId.resolves({ modifiedCount: 1 }); - const result = await resolveVisitor({ externalId: newExternalId, phone: '5555555555' }); + const result = await resolveVisitor({ source: appId, externalId: newExternalId, phone: '5555555555' }); - expect(result).to.deep.equal({ ...existingVisitor, externalIds: [existingExternalId, newExternalId] }); + expect(result).to.deep.equal({ + ...existingVisitor, + externalIds: { [appId]: newExternalId }, + }); }); it('should return null when not found by external ID and no phone provided', async () => { modelsMock.LivechatVisitors.findOneByExternalId.resolves(null); - const result = await resolveVisitor({ externalId: { source: 'whatsapp', userId: 'bsuid-unknown' } }); + const result = await resolveVisitor({ source: appId, externalId: { userId: 'bsuid-unknown' } }); expect(result).to.be.null; expect(modelsMock.LivechatVisitors.findOneByExternalId.calledOnce).to.be.true; @@ -96,7 +103,8 @@ describe('resolveVisitor', () => { modelsMock.LivechatVisitors.findOneVisitorByPhone.resolves(null); const result = await resolveVisitor({ - externalId: { source: 'whatsapp', userId: 'bsuid-unknown' }, + source: appId, + externalId: { userId: 'bsuid-unknown' }, phone: '0000000000', }); @@ -110,7 +118,8 @@ describe('resolveVisitor', () => { modelsMock.LivechatVisitors.findOneByExternalId.resolves(null); const result = await resolveVisitor({ - externalId: { source: 'whatsapp', userId: 'bsuid-123' }, + source: appId, + externalId: { userId: 'bsuid-123' }, phone: '', }); diff --git a/packages/apps-engine/src/definition/livechat/IVisitor.ts b/packages/apps-engine/src/definition/livechat/IVisitor.ts index 87b3940b5eed8..19da0cdc69f87 100644 --- a/packages/apps-engine/src/definition/livechat/IVisitor.ts +++ b/packages/apps-engine/src/definition/livechat/IVisitor.ts @@ -2,7 +2,6 @@ import type { IVisitorEmail } from './IVisitorEmail'; import type { IVisitorPhone } from './IVisitorPhone'; export interface IVisitorExternalIdentifier { - source: string; userId: string; username?: string; } @@ -20,5 +19,5 @@ export interface IVisitor { activity?: string[]; customFields?: { [key: string]: any }; livechatData?: { [key: string]: any }; - externalIds?: IVisitorExternalIdentifier[]; + externalIds?: Record; } diff --git a/packages/core-typings/src/ILivechatVisitor.ts b/packages/core-typings/src/ILivechatVisitor.ts index bac6ba76d90ee..8eef06e7c7138 100644 --- a/packages/core-typings/src/ILivechatVisitor.ts +++ b/packages/core-typings/src/ILivechatVisitor.ts @@ -15,7 +15,6 @@ export interface IVisitorEmail { } export interface IVisitorExternalIdentifier { - source: string; userId: string; username?: string; } @@ -32,7 +31,7 @@ export interface ILivechatVisitor extends IRocketChatRecord { ip?: string; host?: string; visitorEmails?: IVisitorEmail[]; - externalIds?: IVisitorExternalIdentifier[]; + externalIds?: Record; status?: UserStatus; lastAgent?: { username: string; diff --git a/packages/model-typings/src/models/ILivechatVisitorsModel.ts b/packages/model-typings/src/models/ILivechatVisitorsModel.ts index 3b002a60b4a0f..607ea687ff439 100644 --- a/packages/model-typings/src/models/ILivechatVisitorsModel.ts +++ b/packages/model-typings/src/models/ILivechatVisitorsModel.ts @@ -52,7 +52,7 @@ export interface ILivechatVisitorsModel extends IBaseModel { findOneByExternalId(source: string, externalUserId: string): Promise; - addExternalId(_id: string, externalId: IVisitorExternalIdentifier): Promise; + addExternalId(_id: string, source: string, externalId: IVisitorExternalIdentifier): Promise; removeDepartmentById(_id: string): Promise; diff --git a/packages/models/src/models/LivechatVisitors.ts b/packages/models/src/models/LivechatVisitors.ts index 36fc765ba2914..93e86417838af 100644 --- a/packages/models/src/models/LivechatVisitors.ts +++ b/packages/models/src/models/LivechatVisitors.ts @@ -31,7 +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.source': 1, 'externalIds.userId': 1 }, sparse: true }, + { key: { 'externalIds.$**': 1 }, sparse: true }, { key: { name: 1 }, sparse: true }, { key: { username: 1 } }, { key: { 'contactMananger.username': 1 }, sparse: true }, @@ -52,28 +52,12 @@ export class LivechatVisitorsRaw extends BaseRaw implements IL findOneByExternalId(source: string, externalUserId: string): Promise { return this.findOne({ - externalIds: { $elemMatch: { source, userId: externalUserId } }, + [`externalIds.${source}.userId`]: externalUserId, }); } - addExternalId(_id: string, externalId: IVisitorExternalIdentifier): Promise { - return this.updateOne({ _id }, [ - { - $set: { - externalIds: { - $concatArrays: [ - { - $filter: { - input: { $ifNull: ['$externalIds', []] }, - cond: { $ne: ['$$this.source', externalId.source] }, - }, - }, - [externalId], - ], - }, - }, - }, - ]); + addExternalId(_id: string, source: string, externalId: IVisitorExternalIdentifier): Promise { + return this.updateOne({ _id }, { $set: { [`externalIds.${source}`]: externalId } }); } async findOneGuestByEmailAddress(emailAddress: string): Promise { diff --git a/packages/omni-core/src/visitor/create.ts b/packages/omni-core/src/visitor/create.ts index e4df7d70f305e..f21f2465b33a1 100644 --- a/packages/omni-core/src/visitor/create.ts +++ b/packages/omni-core/src/visitor/create.ts @@ -11,7 +11,7 @@ type RegisterGuestType = Partial; }; export const registerGuest = makeFunction( @@ -30,7 +30,7 @@ export const registerGuest = makeFunction( status, ...(phone?.number && { phone: [{ phoneNumber: phone.number }] }), ...(name && { name }), - ...(externalIds?.length && { externalIds }), + ...(externalIds && { externalIds }), }; if (email) { From 7d798567d0a8434ed08001f3784481183b22036d Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Wed, 11 Mar 2026 23:38:32 -0300 Subject: [PATCH 11/41] refactor: use findOneAndUpdate for phone lookup to save db roundtrip --- .../app/livechat/server/lib/resolveVisitor.ts | 12 +---- .../server/lib/resolveVisitor.spec.ts | 46 ++++++++----------- .../src/models/ILivechatVisitorsModel.ts | 8 +++- .../models/src/models/LivechatVisitors.ts | 16 +++++-- 4 files changed, 38 insertions(+), 44 deletions(-) diff --git a/apps/meteor/app/livechat/server/lib/resolveVisitor.ts b/apps/meteor/app/livechat/server/lib/resolveVisitor.ts index 5466a3acdec07..247883d894863 100644 --- a/apps/meteor/app/livechat/server/lib/resolveVisitor.ts +++ b/apps/meteor/app/livechat/server/lib/resolveVisitor.ts @@ -14,17 +14,9 @@ export async function resolveVisitor({ source, externalId, phone }: ResolveVisit } if (phone) { - const visitorByPhone = await LivechatVisitors.findOneVisitorByPhone(phone); + const visitorByPhone = await LivechatVisitors.findOneVisitorByPhoneAndAddExternalId(phone, source, externalId); if (visitorByPhone) { - // Enrich existing visitor with external ID (progressive enrichment) - await LivechatVisitors.addExternalId(visitorByPhone._id, source, externalId); - return { - ...visitorByPhone, - externalIds: { - ...(visitorByPhone.externalIds ?? {}), - [source]: externalId, - }, - }; + return visitorByPhone; } } diff --git a/apps/meteor/tests/unit/app/livechat/server/lib/resolveVisitor.spec.ts b/apps/meteor/tests/unit/app/livechat/server/lib/resolveVisitor.spec.ts index 47fb41acf85ae..33b9e001c0ebb 100644 --- a/apps/meteor/tests/unit/app/livechat/server/lib/resolveVisitor.spec.ts +++ b/apps/meteor/tests/unit/app/livechat/server/lib/resolveVisitor.spec.ts @@ -5,8 +5,7 @@ import sinon from 'sinon'; const modelsMock = { LivechatVisitors: { findOneByExternalId: sinon.stub(), - findOneVisitorByPhone: sinon.stub(), - addExternalId: sinon.stub(), + findOneVisitorByPhoneAndAddExternalId: sinon.stub(), }, }; @@ -20,8 +19,7 @@ const appId = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'; describe('resolveVisitor', () => { beforeEach(() => { modelsMock.LivechatVisitors.findOneByExternalId.reset(); - modelsMock.LivechatVisitors.findOneVisitorByPhone.reset(); - modelsMock.LivechatVisitors.addExternalId.reset(); + modelsMock.LivechatVisitors.findOneVisitorByPhoneAndAddExternalId.reset(); }); it('should return visitor when found by external ID without phone fallback', async () => { @@ -42,50 +40,43 @@ describe('resolveVisitor', () => { expect(result).to.deep.equal(existingVisitor); expect(modelsMock.LivechatVisitors.findOneByExternalId.calledOnceWith(appId, 'bsuid-123')).to.be.true; - expect(modelsMock.LivechatVisitors.findOneVisitorByPhone.called).to.be.false; - expect(modelsMock.LivechatVisitors.addExternalId.called).to.be.false; + expect(modelsMock.LivechatVisitors.findOneVisitorByPhoneAndAddExternalId.called).to.be.false; }); it('should find by phone, enrich with external ID, and return visitor when not found by external ID', async () => { - const existingVisitor = { + const externalId = { userId: 'bsuid-456', username: '@johndoe' }; + const updatedVisitor = { _id: 'visitor-456', token: 'token-456', username: 'guest-2', + externalIds: { [appId]: externalId }, }; - const externalId = { userId: 'bsuid-456', username: '@johndoe' }; modelsMock.LivechatVisitors.findOneByExternalId.resolves(null); - modelsMock.LivechatVisitors.findOneVisitorByPhone.resolves(existingVisitor); - modelsMock.LivechatVisitors.addExternalId.resolves({ modifiedCount: 1 }); + modelsMock.LivechatVisitors.findOneVisitorByPhoneAndAddExternalId.resolves(updatedVisitor); const result = await resolveVisitor({ source: appId, externalId, phone: '9876543210' }); - expect(result).to.deep.equal({ ...existingVisitor, externalIds: { [appId]: externalId } }); + expect(result).to.deep.equal(updatedVisitor); expect(modelsMock.LivechatVisitors.findOneByExternalId.calledOnce).to.be.true; - expect(modelsMock.LivechatVisitors.findOneVisitorByPhone.calledOnceWith('9876543210')).to.be.true; - expect(modelsMock.LivechatVisitors.addExternalId.calledOnceWith('visitor-456', appId, externalId)).to.be.true; + expect(modelsMock.LivechatVisitors.findOneVisitorByPhoneAndAddExternalId.calledOnceWith('9876543210', appId, externalId)).to.be.true; }); it('should update existing externalIds when visitor already has some', async () => { - const existingExternalId = { userId: 'bsuid-old' }; - const existingVisitor = { + const newExternalId = { userId: 'bsuid-789', username: '@newuser' }; + const updatedVisitor = { _id: 'visitor-789', token: 'token-789', username: 'guest-3', - externalIds: { [appId]: existingExternalId }, + externalIds: { [appId]: newExternalId }, }; - const newExternalId = { userId: 'bsuid-789', username: '@newuser' }; modelsMock.LivechatVisitors.findOneByExternalId.resolves(null); - modelsMock.LivechatVisitors.findOneVisitorByPhone.resolves(existingVisitor); - modelsMock.LivechatVisitors.addExternalId.resolves({ modifiedCount: 1 }); + modelsMock.LivechatVisitors.findOneVisitorByPhoneAndAddExternalId.resolves(updatedVisitor); const result = await resolveVisitor({ source: appId, externalId: newExternalId, phone: '5555555555' }); - expect(result).to.deep.equal({ - ...existingVisitor, - externalIds: { [appId]: newExternalId }, - }); + expect(result).to.deep.equal(updatedVisitor); }); it('should return null when not found by external ID and no phone provided', async () => { @@ -95,12 +86,12 @@ describe('resolveVisitor', () => { expect(result).to.be.null; expect(modelsMock.LivechatVisitors.findOneByExternalId.calledOnce).to.be.true; - expect(modelsMock.LivechatVisitors.findOneVisitorByPhone.called).to.be.false; + expect(modelsMock.LivechatVisitors.findOneVisitorByPhoneAndAddExternalId.called).to.be.false; }); it('should return null when not found by external ID or phone', async () => { modelsMock.LivechatVisitors.findOneByExternalId.resolves(null); - modelsMock.LivechatVisitors.findOneVisitorByPhone.resolves(null); + modelsMock.LivechatVisitors.findOneVisitorByPhoneAndAddExternalId.resolves(null); const result = await resolveVisitor({ source: appId, @@ -110,8 +101,7 @@ describe('resolveVisitor', () => { expect(result).to.be.null; expect(modelsMock.LivechatVisitors.findOneByExternalId.calledOnce).to.be.true; - expect(modelsMock.LivechatVisitors.findOneVisitorByPhone.calledOnce).to.be.true; - expect(modelsMock.LivechatVisitors.addExternalId.called).to.be.false; + expect(modelsMock.LivechatVisitors.findOneVisitorByPhoneAndAddExternalId.calledOnce).to.be.true; }); it('should not attempt phone lookup when phone is empty string', async () => { @@ -124,6 +114,6 @@ describe('resolveVisitor', () => { }); expect(result).to.be.null; - expect(modelsMock.LivechatVisitors.findOneVisitorByPhone.called).to.be.false; + expect(modelsMock.LivechatVisitors.findOneVisitorByPhoneAndAddExternalId.called).to.be.false; }); }); diff --git a/packages/model-typings/src/models/ILivechatVisitorsModel.ts b/packages/model-typings/src/models/ILivechatVisitorsModel.ts index 607ea687ff439..891882aef8c99 100644 --- a/packages/model-typings/src/models/ILivechatVisitorsModel.ts +++ b/packages/model-typings/src/models/ILivechatVisitorsModel.ts @@ -50,9 +50,13 @@ export interface ILivechatVisitorsModel extends IBaseModel { findOneVisitorByPhone(phone: string): Promise; - findOneByExternalId(source: string, externalUserId: string): Promise; + findOneVisitorByPhoneAndAddExternalId( + phone: string, + source: string, + externalId: IVisitorExternalIdentifier, + ): Promise; - addExternalId(_id: string, source: string, externalId: IVisitorExternalIdentifier): Promise; + findOneByExternalId(source: string, externalUserId: string): Promise; removeDepartmentById(_id: string): Promise; diff --git a/packages/models/src/models/LivechatVisitors.ts b/packages/models/src/models/LivechatVisitors.ts index 93e86417838af..2531a15883d7b 100644 --- a/packages/models/src/models/LivechatVisitors.ts +++ b/packages/models/src/models/LivechatVisitors.ts @@ -50,16 +50,24 @@ export class LivechatVisitorsRaw extends BaseRaw implements IL return this.findOne(query); } + findOneVisitorByPhoneAndAddExternalId( + phone: string, + source: string, + externalId: IVisitorExternalIdentifier, + ): Promise { + return this.findOneAndUpdate( + { 'phone.phoneNumber': phone }, + { $set: { [`externalIds.${source}`]: externalId } }, + { returnDocument: 'after' }, + ); + } + findOneByExternalId(source: string, externalUserId: string): Promise { return this.findOne({ [`externalIds.${source}.userId`]: externalUserId, }); } - addExternalId(_id: string, source: string, externalId: IVisitorExternalIdentifier): Promise { - return this.updateOne({ _id }, { $set: { [`externalIds.${source}`]: externalId } }); - } - async findOneGuestByEmailAddress(emailAddress: string): Promise { if (!emailAddress) { return null; From 99da74ea950dcad520587282a47dead46e74b795 Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Thu, 12 Mar 2026 00:00:53 -0300 Subject: [PATCH 12/41] add changeset --- .changeset/forty-dolphins-check.md | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 .changeset/forty-dolphins-check.md diff --git a/.changeset/forty-dolphins-check.md b/.changeset/forty-dolphins-check.md new file mode 100644 index 0000000000000..a45096a601199 --- /dev/null +++ b/.changeset/forty-dolphins-check.md @@ -0,0 +1,10 @@ +--- +'@rocket.chat/model-typings': patch +'@rocket.chat/core-typings': patch +'@rocket.chat/apps-engine': patch +'@rocket.chat/omni-core': patch +'@rocket.chat/models': patch +'@rocket.chat/meteor': patch +--- + +Adds externalIds field to livechat visitors for external platform identification. From 3196feae79715262dc8d53b5d9f93e5dfb498e34 Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Fri, 13 Mar 2026 09:29:36 -0300 Subject: [PATCH 13/41] fix: prevent externalIds overwrite in registerGuest --- packages/omni-core/src/visitor/create.spec.ts | 17 +++++++++++++++++ packages/omni-core/src/visitor/create.ts | 11 +++++++++-- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/packages/omni-core/src/visitor/create.spec.ts b/packages/omni-core/src/visitor/create.spec.ts index 2dee062386877..0dad18814f56b 100644 --- a/packages/omni-core/src/visitor/create.spec.ts +++ b/packages/omni-core/src/visitor/create.spec.ts @@ -765,4 +765,21 @@ describe('registerGuest', () => { ); }); }); + + describe('externalIds handling', () => { + it('should use dot notation for externalIds to allow merging with existing entries', async () => { + const token = 'test-token'; + const externalIds = { + whatsapp: { userId: 'wa-123', username: '@john' }, + telegram: { userId: 'tg-456' }, + }; + + await registerGuest({ token, externalIds }, { shouldConsiderIdleAgent: false }); + + const callArgs = updateOneByIdOrTokenSpy.mock.calls[0][0]; + expect(callArgs['externalIds.whatsapp']).toEqual({ userId: 'wa-123', username: '@john' }); + expect(callArgs['externalIds.telegram']).toEqual({ userId: 'tg-456' }); + expect(callArgs.externalIds).toBeUndefined(); + }); + }); }); diff --git a/packages/omni-core/src/visitor/create.ts b/packages/omni-core/src/visitor/create.ts index f21f2465b33a1..c9f8eff0b8481 100644 --- a/packages/omni-core/src/visitor/create.ts +++ b/packages/omni-core/src/visitor/create.ts @@ -25,14 +25,21 @@ export const registerGuest = makeFunction( logger.debug({ msg: 'New incoming conversation', id, token }); - const visitorDataToUpdate: Partial & { userAgent?: string; ip?: string; host?: string } = { + const visitorDataToUpdate: Partial & { userAgent?: string; ip?: string; host?: string } & Record = { token, status, ...(phone?.number && { phone: [{ phoneNumber: phone.number }] }), ...(name && { name }), - ...(externalIds && { externalIds }), }; + // Use dot notation for `externalIds` to merge with existing entries + // instead of overwriting. + if (externalIds) { + for (const [source, externalId] of Object.entries(externalIds)) { + visitorDataToUpdate[`externalIds.${source}`] = externalId; + } + } + if (email) { const visitorEmail = email.trim().toLowerCase(); validateEmail(visitorEmail); From 57ed43a32bd582552d969c622645138b6ca90765 Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Fri, 13 Mar 2026 09:30:01 -0300 Subject: [PATCH 14/41] fix: bump core-typings and apps-engine to minor for new externalIds API --- .changeset/forty-dolphins-check.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/forty-dolphins-check.md b/.changeset/forty-dolphins-check.md index a45096a601199..e0dbf75e5db9d 100644 --- a/.changeset/forty-dolphins-check.md +++ b/.changeset/forty-dolphins-check.md @@ -1,7 +1,7 @@ --- '@rocket.chat/model-typings': patch -'@rocket.chat/core-typings': patch -'@rocket.chat/apps-engine': patch +'@rocket.chat/core-typings': minor +'@rocket.chat/apps-engine': minor '@rocket.chat/omni-core': patch '@rocket.chat/models': patch '@rocket.chat/meteor': patch From ed5c0919321e7d7ee63dcd502fd2aa673b734c93 Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Mon, 16 Mar 2026 00:16:23 -0300 Subject: [PATCH 15/41] test: add E2E tests for resolveVisitor Apps-Engine API --- .../app-packages/external-id-test_0.0.1.zip | Bin 0 -> 12261 bytes .../tests/data/apps/app-packages/index.ts | 2 + .../end-to-end/apps/app-resolve-visitor.ts | 122 ++++++++++++++++++ 3 files changed, 124 insertions(+) create mode 100644 apps/meteor/tests/data/apps/app-packages/external-id-test_0.0.1.zip create mode 100644 apps/meteor/tests/end-to-end/apps/app-resolve-visitor.ts 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 0000000000000000000000000000000000000000..0ba5469d727c33a728f6850232742e0384928e85 GIT binary patch literal 12261 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 U!6E({hWvVEd_5{=y1zdCKNYq&(*OVf literal 0 HcmV?d00001 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..95d6c41d5b415 --- /dev/null +++ b/apps/meteor/tests/end-to-end/apps/app-resolve-visitor.ts @@ -0,0 +1,122 @@ +import type { App } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import { after, before, describe, it } from 'mocha'; + +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, createAgent, makeAgentAvailable } from '../../data/livechat/rooms'; +import { updateSetting } from '../../data/permissions.helper'; +import { IS_EE } 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(); + + await cleanupApps(); + + 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(() => cleanupApps()); + + const callResolveVisitor = async (externalId: { userId: string; username?: string }, phone?: string) => { + return request + .post(apps(`/public/${app.id}/resolve-visitor`)) + .set(credentials) + .send({ externalId, phone }); + }; + + describe('externalId lookup', () => { + it('should return null when externalId does not exist', async () => { + const response = await callResolveVisitor({ userId: '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 = { userId: `id-${Date.now()}`, 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); + expect(response.body.visitor.externalIds[app.id].userId).to.equal(externalId.userId); + expect(response.body.visitor.externalIds[app.id].username).to.equal(externalId.username); + }); + + it('should not update externalId when found by lookup', async () => { + const phone = `+2${Date.now()}`; + await createVisitor(undefined, undefined, undefined, phone); + + const externalId = { userId: `id-${Date.now()}`, 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({ userId: externalId.userId, username: '@changed' }); + + expect(response.body.visitor.externalIds[app.id].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 = { userId: `id-${Date.now()}`, username: '@user' }; + const response = await callResolveVisitor(externalId, phone); + + expect(response.status).to.equal(200); + expect(response.body.visitor.id).to.equal(visitor._id); + expect(response.body.visitor.externalIds[app.id].userId).to.equal(externalId.userId); + expect(response.body.visitor.externalIds[app.id].username).to.equal(externalId.username); + }); + + it('should overwrite externalId when called with different userId', async () => { + const phone = `+4${Date.now()}`; + await createVisitor(undefined, undefined, undefined, phone); + + await callResolveVisitor({ userId: `first-${Date.now()}`, username: '@first' }, phone); + + const newExternalId = { userId: `second-${Date.now()}`, username: '@second' }; + const response = await callResolveVisitor(newExternalId, phone); + + expect(response.body.visitor.externalIds[app.id].userId).to.equal(newExternalId.userId); + expect(response.body.visitor.externalIds[app.id].username).to.equal('@second'); + }); + + it('should return null when phone not found', async () => { + const response = await callResolveVisitor({ userId: `id-${Date.now()}` }, '+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({ userId: `id-${Date.now()}` }, ''); + + expect(response.status).to.equal(200); + expect(response.body.visitor).to.be.null; + }); + }); +}); From ffe7c1c2d8a87ab9a4874f97d1538a7a03f3f80b Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Wed, 18 Mar 2026 09:07:35 -0300 Subject: [PATCH 16/41] feat: add ResolveVisitorContactData for flexible visitor lookup fallback --- .../app/apps/server/bridges/livechat.ts | 18 ++--- .../app/livechat/server/lib/resolveVisitor.ts | 13 ++- .../app-packages/external-id-test_0.0.1.zip | Bin 12261 -> 6024 bytes .../end-to-end/apps/app-resolve-visitor.ts | 63 ++++++++++++--- .../server/lib/resolveVisitor.spec.ts | 75 +++++++++++++----- .../definition/accessors/ILivechatCreator.ts | 12 +-- .../src/definition/livechat/IVisitor.ts | 2 + .../src/definition/livechat/index.ts | 3 +- .../src/server/accessors/LivechatCreator.ts | 6 +- .../src/server/bridges/LivechatBridge.ts | 24 +++++- .../tests/test-data/bridges/livechatBridge.ts | 7 +- .../src/models/ILivechatVisitorsModel.ts | 4 +- .../models/src/models/LivechatVisitors.ts | 15 ++-- 13 files changed, 173 insertions(+), 69 deletions(-) diff --git a/apps/meteor/app/apps/server/bridges/livechat.ts b/apps/meteor/app/apps/server/bridges/livechat.ts index c315be0e45ab2..2b64a6de29618 100644 --- a/apps/meteor/app/apps/server/bridges/livechat.ts +++ b/apps/meteor/app/apps/server/bridges/livechat.ts @@ -6,6 +6,7 @@ import type { 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'; @@ -128,13 +129,12 @@ export class AppLivechatBridge extends LivechatBridge { type: OmnichannelSourceType.APP, id: appId, alias: this.orch.getManager()?.getOneById(appId)?.getName(), - ...(source && - source.type === 'app' && { - sidebarIcon: source.sidebarIcon, - defaultIcon: source.defaultIcon, - label: source.label, - destination: source.destination, - }), + ...(source?.type === 'app' && { + sidebarIcon: source.sidebarIcon, + defaultIcon: source.defaultIcon, + label: source.label, + destination: source.destination, + }), }, }, agent: agentRoom, @@ -349,7 +349,7 @@ export class AppLivechatBridge extends LivechatBridge { protected async resolveVisitor( externalId: IVisitorExternalIdentifier, - phone: string | undefined, + contactData: ResolveVisitorContactData | undefined, appId: string, ): Promise { this.orch.debugLog(`The App ${appId} is resolving a livechat visitor by external ID.`); @@ -357,7 +357,7 @@ export class AppLivechatBridge extends LivechatBridge { const visitor = await resolveVisitor({ source: appId, externalId, - phone, + contactData, }); return this.orch.getConverters()?.get('visitors').convertVisitor(visitor); diff --git a/apps/meteor/app/livechat/server/lib/resolveVisitor.ts b/apps/meteor/app/livechat/server/lib/resolveVisitor.ts index 247883d894863..e002c26e2a142 100644 --- a/apps/meteor/app/livechat/server/lib/resolveVisitor.ts +++ b/apps/meteor/app/livechat/server/lib/resolveVisitor.ts @@ -1,23 +1,22 @@ import type { IVisitorExternalIdentifier, ILivechatVisitor } from '@rocket.chat/core-typings'; import { LivechatVisitors } from '@rocket.chat/models'; +type ResolveVisitorContactData = { phone: string } | { email: string }; + type ResolveVisitorParams = { source: string; externalId: IVisitorExternalIdentifier; - phone?: string; + contactData?: ResolveVisitorContactData; }; -export async function resolveVisitor({ source, externalId, phone }: ResolveVisitorParams): Promise { +export async function resolveVisitor({ source, externalId, contactData }: ResolveVisitorParams): Promise { const visitorByExternalId = await LivechatVisitors.findOneByExternalId(source, externalId.userId); if (visitorByExternalId) { return visitorByExternalId; } - if (phone) { - const visitorByPhone = await LivechatVisitors.findOneVisitorByPhoneAndAddExternalId(phone, source, externalId); - if (visitorByPhone) { - return visitorByPhone; - } + if (contactData && (('phone' in contactData && contactData.phone) || ('email' in contactData && contactData.email))) { + return LivechatVisitors.findOneVisitorByPhoneOrEmailAndAddExternalId(contactData, source, externalId); } return null; 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 index 0ba5469d727c33a728f6850232742e0384928e85..4f6ac6bc1c1c60b086463c5f3e68040703903274 100644 GIT binary patch delta 5247 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 delta 11581 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%0DgBr+>t z>ZeJF+36nV@|trkkxOZ`iS9quuyQrRt?5&!NQUvE2f^jgZwl~b5<_&cBR$)p;j7cg zd89rA5kLuLZ`#vjU}(JfaPHO;dCYzBX+-xPP2A>hKovCHfE;ggR+;b=L|pP z>5r+;1FAT%gjDot#eR7aQ}Y_tl#?b522jt4iTE6_!5eyZ!|~Om6e7|1p5#4Ev3?5~ z8edkn2tV=3@#eq?c@)nwf0%>_y;B+KPdWXuH1##Ry)%~p1ZcQ@-pbiJY#!BfU1Tjh zm$(=(8IQH|acZdMq+sgWSh$@~dVE!Vio<2e!X^&v3}?({4O@g15_WHn?L0GuQm?Yom`1eAmnV7+}NRdD$Jg1EhA5>JM}99k+M0S_(v1| zOfKmF`T%2~YS+bsy^<=^L@eAr(*ymoCf^Fm!upZwP<^kijs$FMuS?VEZ&xNS$S3W`uy{}AdwvHy zFfz~{bZEr=yBpuWcK9M*;kCUeO2s)kF6}-~*l=Y6EDUHIO7?}J9T|N=lqC!JzWgPu zbg=Zc?6av48-ivY8vAa1N;tbc^b@cia+nz-P5#CBNz5z!voAEDuDZwf!j9F6gndI@ zmyg(@s=l(QEv}S{pm}rTNSUTwZ2v@w9^B6H$tS~?@y#uRkK2!b3PMcN>|PV7U|{oq z`9!3DSA>=V?GyDeih#xcMKmJ)B^q5_{xXf`Ru+ubE)Mo~Gkgk=%!vLw?|x!QH^I-x zfeT2<-@)VB)CsEw1j`6>31F1e-mj>KhqDK&y?$}2%V)bxy^!!y2!n{|r5OD#}pZ-~qO1Ztu$-iM{{Utjw6lK66 z1R(#rCpeK5iy7&k2I2qL_?u{e#SZ-Q#<=v;Z<@b)oDhGzi2sA1EehEF+aPS@==eWt z_J583L+Sp* cleanupApps()); - const callResolveVisitor = async (externalId: { userId: string; username?: string }, phone?: string) => { + const callResolveVisitor = async ( + externalId: { userId: string; username?: string }, + contactData?: { phone?: string; email?: string }, + ) => { return request .post(apps(`/public/${app.id}/resolve-visitor`)) .set(credentials) - .send({ externalId, phone }); + .send({ externalId, phone: contactData?.phone, email: contactData?.email }); }; describe('externalId lookup', () => { @@ -52,7 +55,7 @@ import { IS_EE } from '../../e2e/config/constants'; const visitor = await createVisitor(undefined, undefined, undefined, phone); const externalId = { userId: `id-${Date.now()}`, username: '@user' }; - await callResolveVisitor(externalId, phone); + await callResolveVisitor(externalId, { phone }); const response = await callResolveVisitor(externalId); @@ -67,7 +70,7 @@ import { IS_EE } from '../../e2e/config/constants'; await createVisitor(undefined, undefined, undefined, phone); const externalId = { userId: `id-${Date.now()}`, username: '@original' }; - await callResolveVisitor(externalId, phone); + 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. @@ -84,7 +87,7 @@ import { IS_EE } from '../../e2e/config/constants'; const visitor = await createVisitor(undefined, undefined, undefined, phone); const externalId = { userId: `id-${Date.now()}`, username: '@user' }; - const response = await callResolveVisitor(externalId, phone); + const response = await callResolveVisitor(externalId, { phone }); expect(response.status).to.equal(200); expect(response.body.visitor.id).to.equal(visitor._id); @@ -96,24 +99,66 @@ import { IS_EE } from '../../e2e/config/constants'; const phone = `+4${Date.now()}`; await createVisitor(undefined, undefined, undefined, phone); - await callResolveVisitor({ userId: `first-${Date.now()}`, username: '@first' }, phone); + await callResolveVisitor({ userId: `first-${Date.now()}`, username: '@first' }, { phone }); const newExternalId = { userId: `second-${Date.now()}`, username: '@second' }; - const response = await callResolveVisitor(newExternalId, phone); + const response = await callResolveVisitor(newExternalId, { phone }); expect(response.body.visitor.externalIds[app.id].userId).to.equal(newExternalId.userId); expect(response.body.visitor.externalIds[app.id].username).to.equal('@second'); }); it('should return null when phone not found', async () => { - const response = await callResolveVisitor({ userId: `id-${Date.now()}` }, '+0000000000'); + const response = await callResolveVisitor({ userId: `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({ userId: `id-${Date.now()}` }, ''); + const response = await callResolveVisitor({ userId: `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 = { userId: `id-email-${Date.now()}`, username: '@emailuser' }; + const response = await callResolveVisitor(externalId, { email }); + + expect(response.status).to.equal(200); + expect(response.body.visitor.id).to.equal(visitor._id); + expect(response.body.visitor.externalIds[app.id].userId).to.equal(externalId.userId); + expect(response.body.visitor.externalIds[app.id].username).to.equal(externalId.username); + }); + + it('should overwrite externalId when called with different userId via email', async () => { + const email = `test-${Date.now()}@example.com`; + await createVisitor(undefined, undefined, email); + + await callResolveVisitor({ userId: `first-email-${Date.now()}`, username: '@first' }, { email }); + + const newExternalId = { userId: `second-email-${Date.now()}`, username: '@second' }; + const response = await callResolveVisitor(newExternalId, { email }); + + expect(response.body.visitor.externalIds[app.id].userId).to.equal(newExternalId.userId); + expect(response.body.visitor.externalIds[app.id].username).to.equal('@second'); + }); + + it('should return null when email not found', async () => { + const response = await callResolveVisitor({ userId: `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({ userId: `id-${Date.now()}` }, { email: '' }); expect(response.status).to.equal(200); expect(response.body.visitor).to.be.null; diff --git a/apps/meteor/tests/unit/app/livechat/server/lib/resolveVisitor.spec.ts b/apps/meteor/tests/unit/app/livechat/server/lib/resolveVisitor.spec.ts index 33b9e001c0ebb..6ae58355d397f 100644 --- a/apps/meteor/tests/unit/app/livechat/server/lib/resolveVisitor.spec.ts +++ b/apps/meteor/tests/unit/app/livechat/server/lib/resolveVisitor.spec.ts @@ -5,7 +5,7 @@ import sinon from 'sinon'; const modelsMock = { LivechatVisitors: { findOneByExternalId: sinon.stub(), - findOneVisitorByPhoneAndAddExternalId: sinon.stub(), + findOneVisitorByPhoneOrEmailAndAddExternalId: sinon.stub(), }, }; @@ -19,10 +19,10 @@ const appId = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'; describe('resolveVisitor', () => { beforeEach(() => { modelsMock.LivechatVisitors.findOneByExternalId.reset(); - modelsMock.LivechatVisitors.findOneVisitorByPhoneAndAddExternalId.reset(); + modelsMock.LivechatVisitors.findOneVisitorByPhoneOrEmailAndAddExternalId.reset(); }); - it('should return visitor when found by external ID without phone fallback', async () => { + it('should return visitor when found by external ID without contact data fallback', async () => { const existingVisitor = { _id: 'visitor-123', token: 'token-123', @@ -35,16 +35,17 @@ describe('resolveVisitor', () => { const result = await resolveVisitor({ source: appId, externalId: { userId: 'bsuid-123' }, - phone: '1234567890', + contactData: { phone: '1234567890' }, }); expect(result).to.deep.equal(existingVisitor); expect(modelsMock.LivechatVisitors.findOneByExternalId.calledOnceWith(appId, 'bsuid-123')).to.be.true; - expect(modelsMock.LivechatVisitors.findOneVisitorByPhoneAndAddExternalId.called).to.be.false; + expect(modelsMock.LivechatVisitors.findOneVisitorByPhoneOrEmailAndAddExternalId.called).to.be.false; }); it('should find by phone, enrich with external ID, and return visitor when not found by external ID', async () => { const externalId = { userId: 'bsuid-456', username: '@johndoe' }; + const contactData = { phone: '9876543210' }; const updatedVisitor = { _id: 'visitor-456', token: 'token-456', @@ -53,17 +54,40 @@ describe('resolveVisitor', () => { }; modelsMock.LivechatVisitors.findOneByExternalId.resolves(null); - modelsMock.LivechatVisitors.findOneVisitorByPhoneAndAddExternalId.resolves(updatedVisitor); + modelsMock.LivechatVisitors.findOneVisitorByPhoneOrEmailAndAddExternalId.resolves(updatedVisitor); - const result = await resolveVisitor({ source: appId, externalId, phone: '9876543210' }); + const result = await resolveVisitor({ source: appId, externalId, contactData }); expect(result).to.deep.equal(updatedVisitor); expect(modelsMock.LivechatVisitors.findOneByExternalId.calledOnce).to.be.true; - expect(modelsMock.LivechatVisitors.findOneVisitorByPhoneAndAddExternalId.calledOnceWith('9876543210', appId, externalId)).to.be.true; + expect(modelsMock.LivechatVisitors.findOneVisitorByPhoneOrEmailAndAddExternalId.calledOnceWith(contactData, appId, externalId)).to.be + .true; + }); + + it('should find by email, enrich with external ID, and return visitor when not found by external ID', async () => { + const externalId = { userId: 'bsuid-email', username: '@emailuser' }; + const contactData = { email: 'test@example.com' }; + const updatedVisitor = { + _id: 'visitor-email', + token: 'token-email', + username: 'guest-email', + externalIds: { [appId]: externalId }, + }; + + modelsMock.LivechatVisitors.findOneByExternalId.resolves(null); + modelsMock.LivechatVisitors.findOneVisitorByPhoneOrEmailAndAddExternalId.resolves(updatedVisitor); + + const result = await resolveVisitor({ source: appId, externalId, contactData }); + + expect(result).to.deep.equal(updatedVisitor); + expect(modelsMock.LivechatVisitors.findOneByExternalId.calledOnce).to.be.true; + expect(modelsMock.LivechatVisitors.findOneVisitorByPhoneOrEmailAndAddExternalId.calledOnceWith(contactData, appId, externalId)).to.be + .true; }); it('should update existing externalIds when visitor already has some', async () => { const newExternalId = { userId: 'bsuid-789', username: '@newuser' }; + const contactData = { phone: '5555555555' }; const updatedVisitor = { _id: 'visitor-789', token: 'token-789', @@ -72,48 +96,61 @@ describe('resolveVisitor', () => { }; modelsMock.LivechatVisitors.findOneByExternalId.resolves(null); - modelsMock.LivechatVisitors.findOneVisitorByPhoneAndAddExternalId.resolves(updatedVisitor); + modelsMock.LivechatVisitors.findOneVisitorByPhoneOrEmailAndAddExternalId.resolves(updatedVisitor); - const result = await resolveVisitor({ source: appId, externalId: newExternalId, phone: '5555555555' }); + const result = await resolveVisitor({ source: appId, externalId: newExternalId, contactData }); expect(result).to.deep.equal(updatedVisitor); }); - it('should return null when not found by external ID and no phone provided', async () => { + it('should return null when not found by external ID and no contact data provided', async () => { modelsMock.LivechatVisitors.findOneByExternalId.resolves(null); const result = await resolveVisitor({ source: appId, externalId: { userId: 'bsuid-unknown' } }); expect(result).to.be.null; expect(modelsMock.LivechatVisitors.findOneByExternalId.calledOnce).to.be.true; - expect(modelsMock.LivechatVisitors.findOneVisitorByPhoneAndAddExternalId.called).to.be.false; + expect(modelsMock.LivechatVisitors.findOneVisitorByPhoneOrEmailAndAddExternalId.called).to.be.false; }); - it('should return null when not found by external ID or phone', async () => { + it('should return null when not found by external ID or contact data', async () => { modelsMock.LivechatVisitors.findOneByExternalId.resolves(null); - modelsMock.LivechatVisitors.findOneVisitorByPhoneAndAddExternalId.resolves(null); + modelsMock.LivechatVisitors.findOneVisitorByPhoneOrEmailAndAddExternalId.resolves(null); const result = await resolveVisitor({ source: appId, externalId: { userId: 'bsuid-unknown' }, - phone: '0000000000', + contactData: { phone: '0000000000' }, }); expect(result).to.be.null; expect(modelsMock.LivechatVisitors.findOneByExternalId.calledOnce).to.be.true; - expect(modelsMock.LivechatVisitors.findOneVisitorByPhoneAndAddExternalId.calledOnce).to.be.true; + expect(modelsMock.LivechatVisitors.findOneVisitorByPhoneOrEmailAndAddExternalId.calledOnce).to.be.true; + }); + + it('should not attempt lookup when phone is empty string', async () => { + modelsMock.LivechatVisitors.findOneByExternalId.resolves(null); + + const result = await resolveVisitor({ + source: appId, + externalId: { userId: 'bsuid-123' }, + contactData: { phone: '' }, + }); + + expect(result).to.be.null; + expect(modelsMock.LivechatVisitors.findOneVisitorByPhoneOrEmailAndAddExternalId.called).to.be.false; }); - it('should not attempt phone lookup when phone is empty string', async () => { + it('should not attempt lookup when email is empty string', async () => { modelsMock.LivechatVisitors.findOneByExternalId.resolves(null); const result = await resolveVisitor({ source: appId, externalId: { userId: 'bsuid-123' }, - phone: '', + contactData: { email: '' }, }); expect(result).to.be.null; - expect(modelsMock.LivechatVisitors.findOneVisitorByPhoneAndAddExternalId.called).to.be.false; + expect(modelsMock.LivechatVisitors.findOneVisitorByPhoneOrEmailAndAddExternalId.called).to.be.false; }); }); diff --git a/packages/apps-engine/src/definition/accessors/ILivechatCreator.ts b/packages/apps-engine/src/definition/accessors/ILivechatCreator.ts index 0a22fdc5143e3..69e5230ad6af2 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, IVisitorExternalIdentifier } from '../livechat'; +import type { ILivechatRoom, IVisitor, IVisitorExternalIdentifier, ResolveVisitorContactData } from '../livechat'; import type { IUser } from '../users'; export interface IExtraRoomParams { @@ -10,13 +10,13 @@ export interface IExtraRoomParams { export interface ILivechatCreator { /** - * Resolves a visitor by external identifier (e.g., WhatsApp BSUID) with phone fallback. - * If found by phone but not by externalId, enriches the visitor record with the externalId. - * @param externalId The external identifier containing source, userId, and optional username - * @param phone Optional phone number for fallback lookup + * 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 userId and optional username + * @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: IVisitorExternalIdentifier, phone?: string): Promise; + resolveVisitor(externalId: IVisitorExternalIdentifier, contactData?: ResolveVisitorContactData): Promise; /** * Creates a room to connect the `visitor` to an `agent`. * diff --git a/packages/apps-engine/src/definition/livechat/IVisitor.ts b/packages/apps-engine/src/definition/livechat/IVisitor.ts index 19da0cdc69f87..980e34aca3935 100644 --- a/packages/apps-engine/src/definition/livechat/IVisitor.ts +++ b/packages/apps-engine/src/definition/livechat/IVisitor.ts @@ -6,6 +6,8 @@ export interface IVisitorExternalIdentifier { username?: string; } +export type ResolveVisitorContactData = { phone: string } | { email: string }; + export interface IVisitor { id?: string; token: string; diff --git a/packages/apps-engine/src/definition/livechat/index.ts b/packages/apps-engine/src/definition/livechat/index.ts index 51672147fe2a0..b2a183b0897bf 100644 --- a/packages/apps-engine/src/definition/livechat/index.ts +++ b/packages/apps-engine/src/definition/livechat/index.ts @@ -16,7 +16,7 @@ import { IPostLivechatRoomSaved } from './IPostLivechatRoomSaved'; import { IPostLivechatRoomStarted } from './IPostLivechatRoomStarted'; import { IPostLivechatRoomTransferred } from './IPostLivechatRoomTransferred'; import { IPreLivechatRoomCreatePrevent } from './IPreLivechatRoomCreatePrevent'; -import { IVisitorExternalIdentifier, IVisitor } from './IVisitor'; +import { IVisitorExternalIdentifier, IVisitor, ResolveVisitorContactData } from './IVisitor'; import { IVisitorEmail } from './IVisitorEmail'; import { IVisitorPhone } from './IVisitorPhone'; @@ -44,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 1af55d01b0aeb..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 { IVisitorExternalIdentifier, 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,8 +13,8 @@ export class LivechatCreator implements ILivechatCreator { private readonly appId: string, ) {} - public resolveVisitor(externalId: IVisitorExternalIdentifier, phone?: string): Promise { - return this.bridges.getLivechatBridge().doResolveVisitor(externalId, phone, this.appId); + 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 { diff --git a/packages/apps-engine/src/server/bridges/LivechatBridge.ts b/packages/apps-engine/src/server/bridges/LivechatBridge.ts index 0338d94ef0cf3..21de1e6cbf8f6 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, IVisitorExternalIdentifier, 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,9 +103,13 @@ export abstract class LivechatBridge extends BaseBridge { } } - public async doResolveVisitor(externalId: IVisitorExternalIdentifier, phone: string | undefined, appId: string): Promise { + public async doResolveVisitor( + externalId: IVisitorExternalIdentifier, + contactData: ResolveVisitorContactData | undefined, + appId: string, + ): Promise { if (this.hasWritePermission(appId, 'livechat-visitor')) { - return this.resolveVisitor(externalId, phone, appId); + return this.resolveVisitor(externalId, contactData, appId); } } @@ -201,7 +213,11 @@ export abstract class LivechatBridge extends BaseBridge { protected abstract findVisitorByPhoneNumber(phoneNumber: string, appId: string): Promise; - protected abstract resolveVisitor(externalId: IVisitorExternalIdentifier, phone: string | undefined, appId: string): Promise; + protected abstract resolveVisitor( + externalId: IVisitorExternalIdentifier, + contactData: ResolveVisitorContactData | undefined, + appId: string, + ): Promise; protected abstract transferVisitor(visitor: IVisitor, transferData: ILivechatTransferData, 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 c660a5fb65975..41e847c200803 100644 --- a/packages/apps-engine/tests/test-data/bridges/livechatBridge.ts +++ b/packages/apps-engine/tests/test-data/bridges/livechatBridge.ts @@ -6,6 +6,7 @@ import type { ILivechatRoom, ILivechatTransferData, IVisitor, + ResolveVisitorContactData, } from '../../../src/definition/livechat'; import type { IMessage } from '../../../src/definition/messages'; import type { IUser } from '../../../src/definition/users'; @@ -69,7 +70,11 @@ export class TestLivechatBridge extends LivechatBridge { throw new Error('Method not implemented'); } - public resolveVisitor(externalId: IVisitorExternalIdentifier, phone: string | undefined, appId: string): Promise { + public resolveVisitor( + externalId: IVisitorExternalIdentifier, + contactData: ResolveVisitorContactData | undefined, + appId: string, + ): Promise { throw new Error('Method not implemented'); } diff --git a/packages/model-typings/src/models/ILivechatVisitorsModel.ts b/packages/model-typings/src/models/ILivechatVisitorsModel.ts index 891882aef8c99..938b3e114a418 100644 --- a/packages/model-typings/src/models/ILivechatVisitorsModel.ts +++ b/packages/model-typings/src/models/ILivechatVisitorsModel.ts @@ -50,8 +50,8 @@ export interface ILivechatVisitorsModel extends IBaseModel { findOneVisitorByPhone(phone: string): Promise; - findOneVisitorByPhoneAndAddExternalId( - phone: string, + findOneVisitorByPhoneOrEmailAndAddExternalId( + contactData: { phone: string } | { email: string }, source: string, externalId: IVisitorExternalIdentifier, ): Promise; diff --git a/packages/models/src/models/LivechatVisitors.ts b/packages/models/src/models/LivechatVisitors.ts index 2531a15883d7b..58cd0c0b5ff79 100644 --- a/packages/models/src/models/LivechatVisitors.ts +++ b/packages/models/src/models/LivechatVisitors.ts @@ -50,16 +50,15 @@ export class LivechatVisitorsRaw extends BaseRaw implements IL return this.findOne(query); } - findOneVisitorByPhoneAndAddExternalId( - phone: string, + findOneVisitorByPhoneOrEmailAndAddExternalId( + contactData: { phone: string } | { email: string }, source: string, externalId: IVisitorExternalIdentifier, ): Promise { - return this.findOneAndUpdate( - { 'phone.phoneNumber': phone }, - { $set: { [`externalIds.${source}`]: externalId } }, - { returnDocument: 'after' }, - ); + const query = + 'phone' in contactData ? { 'phone.phoneNumber': contactData.phone } : { 'visitorEmails.address': contactData.email.toLowerCase() }; + + return this.findOneAndUpdate(query, { $set: { [`externalIds.${source}`]: externalId } }, { returnDocument: 'after' }); } findOneByExternalId(source: string, externalUserId: string): Promise { @@ -277,7 +276,7 @@ export class LivechatVisitorsRaw extends BaseRaw implements IL if (!overwrite) { const user = await this.getVisitorByToken(token, { projection: { livechatData: 1 } }); - if (user?.livechatData && typeof user.livechatData[key] !== 'undefined') { + if (typeof user?.livechatData?.[key] !== 'undefined') { return true; } } From b51e47fb726ce034c78c16eea8c5e688dd006699 Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Wed, 18 Mar 2026 09:39:17 -0300 Subject: [PATCH 17/41] fix: narrow visitorDataToUpdate type for externalIds --- packages/omni-core/src/visitor/create.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/omni-core/src/visitor/create.ts b/packages/omni-core/src/visitor/create.ts index c9f8eff0b8481..5142bccca9703 100644 --- a/packages/omni-core/src/visitor/create.ts +++ b/packages/omni-core/src/visitor/create.ts @@ -25,7 +25,10 @@ export const registerGuest = makeFunction( logger.debug({ msg: 'New incoming conversation', id, token }); - const visitorDataToUpdate: Partial & { userAgent?: string; ip?: string; host?: string } & Record = { + const visitorDataToUpdate: Partial & { userAgent?: string; ip?: string; host?: string } & Record< + `externalIds.${string}`, + IVisitorExternalIdentifier + > = { token, status, ...(phone?.number && { phone: [{ phoneNumber: phone.number }] }), From b0c123cd3b3f4cc9d042882b7971519800a4241c Mon Sep 17 00:00:00 2001 From: Douglas Gubert Date: Wed, 18 Mar 2026 09:41:56 -0300 Subject: [PATCH 18/41] changeset all to minor --- .changeset/forty-dolphins-check.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.changeset/forty-dolphins-check.md b/.changeset/forty-dolphins-check.md index e0dbf75e5db9d..b008352c629cc 100644 --- a/.changeset/forty-dolphins-check.md +++ b/.changeset/forty-dolphins-check.md @@ -1,10 +1,10 @@ --- -'@rocket.chat/model-typings': patch +'@rocket.chat/model-typings': minor '@rocket.chat/core-typings': minor '@rocket.chat/apps-engine': minor -'@rocket.chat/omni-core': patch -'@rocket.chat/models': patch -'@rocket.chat/meteor': patch +'@rocket.chat/omni-core': minor +'@rocket.chat/models': minor +'@rocket.chat/meteor': minor --- Adds externalIds field to livechat visitors for external platform identification. From d65e2b5adc28428a9d52850ab0c4a4ba6cc0f18b Mon Sep 17 00:00:00 2001 From: Douglas Gubert Date: Wed, 18 Mar 2026 10:00:57 -0300 Subject: [PATCH 19/41] clarifying comment on why we're using dot notation --- packages/omni-core/src/visitor/create.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/omni-core/src/visitor/create.ts b/packages/omni-core/src/visitor/create.ts index 5142bccca9703..afcf0ce0ced5e 100644 --- a/packages/omni-core/src/visitor/create.ts +++ b/packages/omni-core/src/visitor/create.ts @@ -35,8 +35,7 @@ export const registerGuest = makeFunction( ...(name && { name }), }; - // Use dot notation for `externalIds` to merge with existing entries - // instead of overwriting. + // We "flatten" the `externalIds` from the parameters using dot notation so Mongo doesn't overwrite the whole property when updating the record if (externalIds) { for (const [source, externalId] of Object.entries(externalIds)) { visitorDataToUpdate[`externalIds.${source}`] = externalId; From b2633e3c6488fcfd3b316b259478cfe84828bb7c Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Wed, 18 Mar 2026 11:23:44 -0300 Subject: [PATCH 20/41] docs: add external-id-test app to test packages README --- .../tests/data/apps/app-packages/README.md | 102 ++++++++++++++++++ 1 file changed, 102 insertions(+) diff --git a/apps/meteor/tests/data/apps/app-packages/README.md b/apps/meteor/tests/data/apps/app-packages/README.md index bdb3f214bafbe..aa21168f61b99 100644 --- a/apps/meteor/tests/data/apps/app-packages/README.md +++ b/apps/meteor/tests/data/apps/app-packages/README.md @@ -135,6 +135,108 @@ 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()` API for resolving livechat visitors by external identifiers with phone/email fallback. This is used to test the WhatsApp BSUID (Business Scoped User ID) support and progressive visitor enrichment. + +**Endpoint:** `POST /api/apps/public/:appId/resolve-visitor` + +**Request body:** +```json +{ + "externalId": { "userId": "bsuid-123", "username": "@johndoe" }, + "phone": "+1234567890" +} +``` +or with email fallback: +```json +{ + "externalId": { "userId": "ext-456" }, + "email": "user@example.com" +} +``` + +**Response:** +- Returns the visitor if found (by externalId or contact data fallback) +- Enriches visitor with externalId when found by phone/email +- 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'; + +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)], + }); + } +} +``` + +**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 }, + }; + } +} +``` + +
+ #### Nested Requests simulation File name: `nested-requests_0.0.1.zip` From 5e2acdd89c94ea286ee68332f6af0df1875e1447 Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Wed, 18 Mar 2026 11:54:00 -0300 Subject: [PATCH 21/41] fix: correct external-id-test app package --- .../app-packages/external-id-test_0.0.1.zip | Bin 6024 -> 8697 bytes .../end-to-end/apps/app-resolve-visitor.ts | 6 +++--- 2 files changed, 3 insertions(+), 3 deletions(-) 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 index 4f6ac6bc1c1c60b086463c5f3e68040703903274..36a98ee3cfe6ae1418c94f357420ee15627653c5 100644 GIT binary patch delta 2933 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 delta 219 zcmezA+@UWN;LXe;!oa}6!4T+H6m#;R`r(N}(bBwYeR$XO^nN|ldv?*3EJkES8#`+F zm;&84v+>6=GN-I9+`LfgD>JkD=EBJ{<(4qJEG^vZATPqm0W)oKjDjIh`Q&~D2M~2p z!33mfvYnzdQ>4J;XhnIj)>sQ`lU|`t9$iTo0q+wu5;}4)) gONx{8^U^ZY^|Ffd^8&nC*+9~4K$r?rE(YQO0J7jdMgRZ+ 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 index cbb28e49593d8..17a00b218981c 100644 --- a/apps/meteor/tests/end-to-end/apps/app-resolve-visitor.ts +++ b/apps/meteor/tests/end-to-end/apps/app-resolve-visitor.ts @@ -20,8 +20,6 @@ import { IS_EE } from '../../e2e/config/constants'; await createAgent(); await makeAgentAvailable(); - await cleanupApps(); - 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}`); @@ -30,7 +28,9 @@ import { IS_EE } from '../../e2e/config/constants'; app = installResponse.body.app; }); - after(() => cleanupApps()); + after(async () => { + await cleanupApps(); + }); const callResolveVisitor = async ( externalId: { userId: string; username?: string }, From 25163ab4876ec7df292f59eeac1320fb11243519 Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Wed, 18 Mar 2026 13:55:19 -0300 Subject: [PATCH 22/41] chore: mongodb wildcard indexes are sparse by default --- packages/models/src/models/LivechatVisitors.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/models/src/models/LivechatVisitors.ts b/packages/models/src/models/LivechatVisitors.ts index 58cd0c0b5ff79..8473676363a8c 100644 --- a/packages/models/src/models/LivechatVisitors.ts +++ b/packages/models/src/models/LivechatVisitors.ts @@ -31,7 +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.$**': 1 }, sparse: true }, + { key: { 'externalIds.$**': 1 } }, { key: { name: 1 }, sparse: true }, { key: { username: 1 } }, { key: { 'contactMananger.username': 1 }, sparse: true }, From 9168409b465d9f6bcb979f37f83d2292becef039 Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Wed, 18 Mar 2026 20:03:46 -0300 Subject: [PATCH 23/41] feat: migrate externalIds to array format with compound index --- .../app/apps/server/bridges/livechat.ts | 7 ++- .../app/livechat/server/lib/resolveVisitor.ts | 2 +- .../ContactInfoChannelsItem.tsx | 2 +- .../end-to-end/apps/app-resolve-visitor.ts | 28 ++++++---- .../server/lib/resolveVisitor.spec.ts | 10 ++-- .../definition/accessors/ILivechatCreator.ts | 2 +- .../src/definition/livechat/IVisitor.ts | 3 +- .../src/server/bridges/LivechatBridge.ts | 4 +- packages/core-typings/src/ILivechatVisitor.ts | 3 +- .../src/models/ILivechatVisitorsModel.ts | 2 +- .../models/src/models/LivechatVisitors.ts | 53 +++++++++++++++++-- packages/omni-core/src/visitor/create.spec.ts | 14 +++-- packages/omni-core/src/visitor/create.ts | 15 ++---- 13 files changed, 95 insertions(+), 50 deletions(-) diff --git a/apps/meteor/app/apps/server/bridges/livechat.ts b/apps/meteor/app/apps/server/bridges/livechat.ts index 2b64a6de29618..21c275d396709 100644 --- a/apps/meteor/app/apps/server/bridges/livechat.ts +++ b/apps/meteor/app/apps/server/bridges/livechat.ts @@ -237,6 +237,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 source (appId) to each externalId entry + const externalIds = visitor.externalIds?.map((entry) => ({ ...entry, source: appId })); + const registerData = { department: visitor.department, username: visitor.username, @@ -246,7 +249,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 }), - ...(visitor.externalIds && { externalIds: visitor.externalIds }), + ...(externalIds?.length && { externalIds }), }; const livechatVisitor = await registerGuest(registerData, { @@ -348,7 +351,7 @@ export class AppLivechatBridge extends LivechatBridge { } protected async resolveVisitor( - externalId: IVisitorExternalIdentifier, + externalId: Omit, contactData: ResolveVisitorContactData | undefined, appId: string, ): Promise { diff --git a/apps/meteor/app/livechat/server/lib/resolveVisitor.ts b/apps/meteor/app/livechat/server/lib/resolveVisitor.ts index e002c26e2a142..36ca05774de14 100644 --- a/apps/meteor/app/livechat/server/lib/resolveVisitor.ts +++ b/apps/meteor/app/livechat/server/lib/resolveVisitor.ts @@ -5,7 +5,7 @@ type ResolveVisitorContactData = { phone: string } | { email: string }; type ResolveVisitorParams = { source: string; - externalId: IVisitorExternalIdentifier; + externalId: Omit; contactData?: ResolveVisitorContactData; }; 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 dcd39af7ded1e..f9e6179f7208e 100644 --- a/apps/meteor/client/views/omnichannel/contactInfo/tabs/ContactInfoChannels/ContactInfoChannelsItem.tsx +++ b/apps/meteor/client/views/omnichannel/contactInfo/tabs/ContactInfoChannels/ContactInfoChannelsItem.tsx @@ -34,7 +34,7 @@ const ContactInfoChannelsItem = ({ const channelLabel = useMemo(() => { const phone = getSourceLabel(details); - const externalId = details?.id ? visitorData?.externalIds?.[details.id] : undefined; + const externalId = details?.id ? visitorData?.externalIds?.find((e) => e.source === details.id) : undefined; const username = externalId?.username; if (username && phone) { 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 index 17a00b218981c..fc41b0006ed48 100644 --- a/apps/meteor/tests/end-to-end/apps/app-resolve-visitor.ts +++ b/apps/meteor/tests/end-to-end/apps/app-resolve-visitor.ts @@ -61,8 +61,9 @@ import { IS_EE } from '../../e2e/config/constants'; expect(response.status).to.equal(200); expect(response.body.visitor.id).to.equal(visitor._id); - expect(response.body.visitor.externalIds[app.id].userId).to.equal(externalId.userId); - expect(response.body.visitor.externalIds[app.id].username).to.equal(externalId.username); + const appExternalId = response.body.visitor.externalIds.find((e: { source: string }) => e.source === app.id); + expect(appExternalId.userId).to.equal(externalId.userId); + expect(appExternalId.username).to.equal(externalId.username); }); it('should not update externalId when found by lookup', async () => { @@ -77,7 +78,8 @@ import { IS_EE } from '../../e2e/config/constants'; // To update visitor data, apps should use other methods like ILivechatUpdater. const response = await callResolveVisitor({ userId: externalId.userId, username: '@changed' }); - expect(response.body.visitor.externalIds[app.id].username).to.equal('@original'); + const appExternalId = response.body.visitor.externalIds.find((e: { source: string }) => e.source === app.id); + expect(appExternalId.username).to.equal('@original'); }); }); @@ -91,8 +93,9 @@ import { IS_EE } from '../../e2e/config/constants'; expect(response.status).to.equal(200); expect(response.body.visitor.id).to.equal(visitor._id); - expect(response.body.visitor.externalIds[app.id].userId).to.equal(externalId.userId); - expect(response.body.visitor.externalIds[app.id].username).to.equal(externalId.username); + const appExternalId = response.body.visitor.externalIds.find((e: { source: string }) => e.source === app.id); + expect(appExternalId.userId).to.equal(externalId.userId); + expect(appExternalId.username).to.equal(externalId.username); }); it('should overwrite externalId when called with different userId', async () => { @@ -104,8 +107,9 @@ import { IS_EE } from '../../e2e/config/constants'; const newExternalId = { userId: `second-${Date.now()}`, username: '@second' }; const response = await callResolveVisitor(newExternalId, { phone }); - expect(response.body.visitor.externalIds[app.id].userId).to.equal(newExternalId.userId); - expect(response.body.visitor.externalIds[app.id].username).to.equal('@second'); + const appExternalId = response.body.visitor.externalIds.find((e: { source: string }) => e.source === app.id); + expect(appExternalId.userId).to.equal(newExternalId.userId); + expect(appExternalId.username).to.equal('@second'); }); it('should return null when phone not found', async () => { @@ -133,8 +137,9 @@ import { IS_EE } from '../../e2e/config/constants'; expect(response.status).to.equal(200); expect(response.body.visitor.id).to.equal(visitor._id); - expect(response.body.visitor.externalIds[app.id].userId).to.equal(externalId.userId); - expect(response.body.visitor.externalIds[app.id].username).to.equal(externalId.username); + const appExternalId = response.body.visitor.externalIds.find((e: { source: string }) => e.source === app.id); + expect(appExternalId.userId).to.equal(externalId.userId); + expect(appExternalId.username).to.equal(externalId.username); }); it('should overwrite externalId when called with different userId via email', async () => { @@ -146,8 +151,9 @@ import { IS_EE } from '../../e2e/config/constants'; const newExternalId = { userId: `second-email-${Date.now()}`, username: '@second' }; const response = await callResolveVisitor(newExternalId, { email }); - expect(response.body.visitor.externalIds[app.id].userId).to.equal(newExternalId.userId); - expect(response.body.visitor.externalIds[app.id].username).to.equal('@second'); + const appExternalId = response.body.visitor.externalIds.find((e: { source: string }) => e.source === app.id); + expect(appExternalId.userId).to.equal(newExternalId.userId); + expect(appExternalId.username).to.equal('@second'); }); it('should return null when email not found', async () => { diff --git a/apps/meteor/tests/unit/app/livechat/server/lib/resolveVisitor.spec.ts b/apps/meteor/tests/unit/app/livechat/server/lib/resolveVisitor.spec.ts index 6ae58355d397f..eeedabb841af0 100644 --- a/apps/meteor/tests/unit/app/livechat/server/lib/resolveVisitor.spec.ts +++ b/apps/meteor/tests/unit/app/livechat/server/lib/resolveVisitor.spec.ts @@ -14,7 +14,7 @@ const { resolveVisitor } = proxyquire.noCallThru().load('../../../../../../app/l }); // Mock app ID (UUID format as used by Rocket.Chat apps) -const appId = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'; +const appId = 'a1b2c3d4-e5f6-4890-8bcd-ef1234567890'; describe('resolveVisitor', () => { beforeEach(() => { @@ -27,7 +27,7 @@ describe('resolveVisitor', () => { _id: 'visitor-123', token: 'token-123', username: 'guest-1', - externalIds: { [appId]: { userId: 'bsuid-123' } }, + externalIds: [{ source: appId, userId: 'bsuid-123' }], }; modelsMock.LivechatVisitors.findOneByExternalId.resolves(existingVisitor); @@ -50,7 +50,7 @@ describe('resolveVisitor', () => { _id: 'visitor-456', token: 'token-456', username: 'guest-2', - externalIds: { [appId]: externalId }, + externalIds: [{ source: appId, ...externalId }], }; modelsMock.LivechatVisitors.findOneByExternalId.resolves(null); @@ -71,7 +71,7 @@ describe('resolveVisitor', () => { _id: 'visitor-email', token: 'token-email', username: 'guest-email', - externalIds: { [appId]: externalId }, + externalIds: [{ source: appId, ...externalId }], }; modelsMock.LivechatVisitors.findOneByExternalId.resolves(null); @@ -92,7 +92,7 @@ describe('resolveVisitor', () => { _id: 'visitor-789', token: 'token-789', username: 'guest-3', - externalIds: { [appId]: newExternalId }, + externalIds: [{ source: appId, ...newExternalId }], }; modelsMock.LivechatVisitors.findOneByExternalId.resolves(null); diff --git a/packages/apps-engine/src/definition/accessors/ILivechatCreator.ts b/packages/apps-engine/src/definition/accessors/ILivechatCreator.ts index 69e5230ad6af2..0efb7c4405f93 100644 --- a/packages/apps-engine/src/definition/accessors/ILivechatCreator.ts +++ b/packages/apps-engine/src/definition/accessors/ILivechatCreator.ts @@ -16,7 +16,7 @@ export interface ILivechatCreator { * @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: IVisitorExternalIdentifier, contactData?: ResolveVisitorContactData): Promise; + resolveVisitor(externalId: Omit, contactData?: ResolveVisitorContactData): Promise; /** * Creates a room to connect the `visitor` to an `agent`. * diff --git a/packages/apps-engine/src/definition/livechat/IVisitor.ts b/packages/apps-engine/src/definition/livechat/IVisitor.ts index 980e34aca3935..65d40472ec015 100644 --- a/packages/apps-engine/src/definition/livechat/IVisitor.ts +++ b/packages/apps-engine/src/definition/livechat/IVisitor.ts @@ -2,6 +2,7 @@ import type { IVisitorEmail } from './IVisitorEmail'; import type { IVisitorPhone } from './IVisitorPhone'; export interface IVisitorExternalIdentifier { + source?: string; userId: string; username?: string; } @@ -21,5 +22,5 @@ export interface IVisitor { activity?: string[]; customFields?: { [key: string]: any }; livechatData?: { [key: string]: any }; - externalIds?: Record; + externalIds?: IVisitorExternalIdentifier[]; } diff --git a/packages/apps-engine/src/server/bridges/LivechatBridge.ts b/packages/apps-engine/src/server/bridges/LivechatBridge.ts index 21de1e6cbf8f6..66bcabe56aa13 100644 --- a/packages/apps-engine/src/server/bridges/LivechatBridge.ts +++ b/packages/apps-engine/src/server/bridges/LivechatBridge.ts @@ -104,7 +104,7 @@ export abstract class LivechatBridge extends BaseBridge { } public async doResolveVisitor( - externalId: IVisitorExternalIdentifier, + externalId: Omit, contactData: ResolveVisitorContactData | undefined, appId: string, ): Promise { @@ -214,7 +214,7 @@ export abstract class LivechatBridge extends BaseBridge { protected abstract findVisitorByPhoneNumber(phoneNumber: string, appId: string): Promise; protected abstract resolveVisitor( - externalId: IVisitorExternalIdentifier, + externalId: Omit, contactData: ResolveVisitorContactData | undefined, appId: string, ): Promise; diff --git a/packages/core-typings/src/ILivechatVisitor.ts b/packages/core-typings/src/ILivechatVisitor.ts index 8eef06e7c7138..bac6ba76d90ee 100644 --- a/packages/core-typings/src/ILivechatVisitor.ts +++ b/packages/core-typings/src/ILivechatVisitor.ts @@ -15,6 +15,7 @@ export interface IVisitorEmail { } export interface IVisitorExternalIdentifier { + source: string; userId: string; username?: string; } @@ -31,7 +32,7 @@ export interface ILivechatVisitor extends IRocketChatRecord { ip?: string; host?: string; visitorEmails?: IVisitorEmail[]; - externalIds?: Record; + 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 938b3e114a418..8b14ce1ecb3a2 100644 --- a/packages/model-typings/src/models/ILivechatVisitorsModel.ts +++ b/packages/model-typings/src/models/ILivechatVisitorsModel.ts @@ -53,7 +53,7 @@ export interface ILivechatVisitorsModel extends IBaseModel { findOneVisitorByPhoneOrEmailAndAddExternalId( contactData: { phone: string } | { email: string }, source: string, - externalId: IVisitorExternalIdentifier, + externalId: Omit, ): Promise; findOneByExternalId(source: string, externalUserId: string): Promise; diff --git a/packages/models/src/models/LivechatVisitors.ts b/packages/models/src/models/LivechatVisitors.ts index 8473676363a8c..cfbc1e3cd2edd 100644 --- a/packages/models/src/models/LivechatVisitors.ts +++ b/packages/models/src/models/LivechatVisitors.ts @@ -31,7 +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.$**': 1 } }, + { key: { 'externalIds.source': 1, 'externalIds.userId': 1 } }, { key: { name: 1 }, sparse: true }, { key: { username: 1 } }, { key: { 'contactMananger.username': 1 }, sparse: true }, @@ -53,17 +53,35 @@ export class LivechatVisitorsRaw extends BaseRaw implements IL findOneVisitorByPhoneOrEmailAndAddExternalId( contactData: { phone: string } | { email: string }, source: string, - externalId: IVisitorExternalIdentifier, + externalId: Omit, ): Promise { const query = 'phone' in contactData ? { 'phone.phoneNumber': contactData.phone } : { 'visitorEmails.address': contactData.email.toLowerCase() }; - return this.findOneAndUpdate(query, { $set: { [`externalIds.${source}`]: externalId } }, { returnDocument: 'after' }); + // Use aggregation pipeline update to upsert into the array: + // 1. Filter out any existing entry with the same source + // 2. Add the new entry + return this.findOneAndUpdate( + query, + [ + { + $set: { + externalIds: { + $concatArrays: [ + { $filter: { input: { $ifNull: ['$externalIds', []] }, cond: { $ne: ['$$this.source', source] } } }, + [{ source, ...externalId }], + ], + }, + }, + }, + ], + { returnDocument: 'after' }, + ); } findOneByExternalId(source: string, externalUserId: string): Promise { return this.findOne({ - [`externalIds.${source}.userId`]: externalUserId, + externalIds: { $elemMatch: { source, userId: externalUserId } }, }); } @@ -322,6 +340,33 @@ export class LivechatVisitorsRaw extends BaseRaw implements IL update._id = new ObjectId().toHexString(); } + // If externalIds is present, use a pipeline update to merge with existing entries + // instead of overwriting the entire array + if (update.externalIds?.length) { + const { externalIds, ...restUpdate } = update; + const sourcesToReplace = externalIds.map((e) => e.source); + + return this.findOneAndUpdate( + query, + [ + { + $set: { + ...restUpdate, + externalIds: { + $concatArrays: [ + // Keep existing entries that are not being replaced + { $filter: { input: { $ifNull: ['$externalIds', []] }, cond: { $not: { $in: ['$$this.source', sourcesToReplace] } } } }, + // Add the new entries + externalIds, + ], + }, + }, + }, + ], + options, + ); + } + return this.findOneAndUpdate(query, { $set: update }, options); } diff --git a/packages/omni-core/src/visitor/create.spec.ts b/packages/omni-core/src/visitor/create.spec.ts index 0dad18814f56b..74c81982b8823 100644 --- a/packages/omni-core/src/visitor/create.spec.ts +++ b/packages/omni-core/src/visitor/create.spec.ts @@ -767,19 +767,17 @@ describe('registerGuest', () => { }); describe('externalIds handling', () => { - it('should use dot notation for externalIds to allow merging with existing entries', async () => { + it('should pass externalIds as array to allow merging with existing entries', async () => { const token = 'test-token'; - const externalIds = { - whatsapp: { userId: 'wa-123', username: '@john' }, - telegram: { userId: 'tg-456' }, - }; + const externalIds = [ + { source: 'a1b2c3d4-e5f6-4890-8bcd-ef1234567890', userId: 'wa-123', username: '@john' }, + { source: 'b2c3d4e5-f6a7-4901-9cde-f12345678901', userId: 'tg-456' }, + ]; await registerGuest({ token, externalIds }, { shouldConsiderIdleAgent: false }); const callArgs = updateOneByIdOrTokenSpy.mock.calls[0][0]; - expect(callArgs['externalIds.whatsapp']).toEqual({ userId: 'wa-123', username: '@john' }); - expect(callArgs['externalIds.telegram']).toEqual({ userId: 'tg-456' }); - expect(callArgs.externalIds).toBeUndefined(); + expect(callArgs.externalIds).toEqual(externalIds); }); }); }); diff --git a/packages/omni-core/src/visitor/create.ts b/packages/omni-core/src/visitor/create.ts index afcf0ce0ced5e..e4df7d70f305e 100644 --- a/packages/omni-core/src/visitor/create.ts +++ b/packages/omni-core/src/visitor/create.ts @@ -11,7 +11,7 @@ type RegisterGuestType = Partial; + externalIds?: IVisitorExternalIdentifier[]; }; export const registerGuest = makeFunction( @@ -25,23 +25,14 @@ export const registerGuest = makeFunction( logger.debug({ msg: 'New incoming conversation', id, token }); - const visitorDataToUpdate: Partial & { userAgent?: string; ip?: string; host?: string } & Record< - `externalIds.${string}`, - IVisitorExternalIdentifier - > = { + const visitorDataToUpdate: Partial & { userAgent?: string; ip?: string; host?: string } = { token, status, ...(phone?.number && { phone: [{ phoneNumber: phone.number }] }), ...(name && { name }), + ...(externalIds?.length && { externalIds }), }; - // We "flatten" the `externalIds` from the parameters using dot notation so Mongo doesn't overwrite the whole property when updating the record - if (externalIds) { - for (const [source, externalId] of Object.entries(externalIds)) { - visitorDataToUpdate[`externalIds.${source}`] = externalId; - } - } - if (email) { const visitorEmail = email.trim().toLowerCase(); validateEmail(visitorEmail); From 292fe8da1934d6dadade5c4c5a2e4b5d58a3d47d Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Wed, 18 Mar 2026 20:39:36 -0300 Subject: [PATCH 24/41] test: update zip test app --- .../app-packages/external-id-test_0.0.1.zip | Bin 8697 -> 10682 bytes 1 file changed, 0 insertions(+), 0 deletions(-) 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 index 36a98ee3cfe6ae1418c94f357420ee15627653c5..c2c40ee90dda3928ad12add8de33cb28d97f1f62 100644 GIT binary patch delta 6683 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 JM`!ta^*=2Mefj_Z delta 4678 zcmZu#1yCJLmwmXq6C8pD2o~Jk-FdicfZ#Gnu!r*q?hq_I2=0(Tf&>ljZowfyfFMi0 z-RypU)!wSEo|-w`b8mOwTYXNO_Kh^YhB5*oAqa$u3K9T&n2fuI^d;jPQDCZ8sbIoX zhA-n(RfH{(wRMGSsJMi`TC?96jA!y^@ApdtTzo{tArP_HuzoT(FJ0 zO{)8_08y2FZiVxulA!&HK}YH?fGFHgXWx}-cXTOpIRsa#T(5zKurFySCadmJWvMRE zi)p^|TS)wj6_??*N2RTV8#V+xaJ=b<4cXW=z&tG)*t`&)CEO`MpHH8+E26AH@3=Rl zE{@uT7nz#4MG0?uwW`t{Q<{J|30X%frctP&q~gCgmk>d85;E8;`0mdQung*1$xb9` zMU;@R>&;At9a;u&A54Galz7mHY2avLGY%)nj@F6hAnYv^FF3~`H`=Z0e;hzPPE;!a z@MuMxJTFtUE=fjmH+}1>-n-z>_Al{ePkF96QQiV5MoEcK=ua*9t8UDbw+*K4UYe3fgP;eTsdC5 z+QHI*-{O-P2>spr12A_X4sdi4b21&vgc|KP6Mmi;(TSvOu}DF^>UDtxdk2o#OLMYP zK5S1xGBhruG`{quNEY&)SBHMN)On9<9cDFSoF``D-5oq8xkr)^dBP`8mfw8zLJ1K? z1ts15Eb9b;M5dOEkw77(Y>2ifpb#BaSvnxuSA+@^8LCincIHk=ncLLV)S(fI1OlP2 zW1DJ%3YN~stn>0TXAnUV@3Na_&1+>>Z-Q3to&~hg-SL&(UjqAevsvwPHkn0kI5C}h z1VQ`3qedzgt8mP;cRrS=c*B8ob3bI3G;Am5W6WQb(XB}~lU+03_;D~9AYZ57Gyo!- z`TDB*g3%8>^O6UjW<1_nv@OsJW5j3`ry5igIM>T)CIm5*G!9n031GLL`Z0i%oI~0u z&1zDDX|J-fT(#z1mUH;2SbBMo%v5!s*gHw0bzUtmqshR-SiYDDebHj{)Jbr=wrJwi zrL3M(;Y{|Mxja9HH}!{ngQJy>WpW^#xg(bHVrGdAl3^xYJX-Zhj|39+@tZw;^xi}H z_Rcc; z{(#-?yM?lqF?yqnPO%q7NA&{SBOctSl{O-`Gj(S_?r@=k9(hJT4K#VCi&24N`m9^u z)U4fmky4wAY3lFz?WxPI_Oh2n?Lh$FM7Sa8h=~wl{uGw>=4fk7(hy%tAsz$W32ba3 zTUlo%TRCLU(YUpWx^l>z-!+6*`8&=jMRvsPrNiR9Y#V9ZyPw=Mvx^R@%z~x0NQMjN^52j5Dzds>*eojtpz4SP3({b6<b+n0A8lxU z2(Af*QKEP@e-S3WCIDg(b6G7Gxg5Pv)`D$D>6eY_7nzLcI-uqzV)R(L9)UiebsA;1 z4z;wSL)qKGvE;pTW+ojls3r*z41R8mhlNG4mDMphR{NaG1EYa@h)?WebCh7M{%jUz zJ%ikL?)xB?&=Fga%rC-oL|iAyG2iqVOgqw2A3^FZ2=;B&HPw;6FK?NUsR&eAO@)-YW#|9G z|6=u!Do;XG#VRMdkpH4r@i%=RmIc@`I?Y!?(w+a%`gh*1atl9~m!8qrR2kI|6_T~+ z_xmwY(s?MoT)_4Z+K-m0WM*CYh7;s(UQU~oO}u4TT3;lcxzg=wS=ytb?#!JYsQVJ( zZ_DD{3MXJIaP9hHci#BR=sTs9f<-1{t$8WTp!_DqCH2&9Y&fgB+)m^HB%5xDI0tmB@=N?c^@&U|v|E z<1Bs`yDf?CHi>kYX-{X9saJ82oswP(nbzxqyDO)3F0h}SdeIhu7!?Ht-kW+555?+Oh;uY_YFt@2ymLxjc+!%FFtyOevC;^6tNaLH&CmQ z^oAtAn(Fp6Ns$TnExOHBML%Lugh#FH+gyW3Hv-hi?ui<5nh5x03q%zkd?EXRA<`+? z^9l9k3=zwnlxeT>@4On$zuJ5V_R&CvrjPkk6a#We*i1PSVg9_qJ;Qn2!;U9t8CxZ; zn=e=ukt_9#pH23xey!O=f4F&!bbm||x&6SW9RR~hwrN~9ezg^O-C=VEwF-EtJu2C* z3BUi|RL!?u$F*mlyuP9uL4G-u?+VsO@WxOSF7WdNGka|*LsM9a5v#*Ir&vk_f}4`5 zzcbMN%vUsgCb%V#f%A;v;Ii1~Rlq#Gyc5mV8e(l-4VvQ+ zbei6j_78bm*NpXssq=sfgy8~p3(aHJwMfPGcu%>OA{_N61*pf(oU>rJsLH99Z~icgkqpK9s)Y5aq3vb{)_hi9g*5EWa&4H$*k=(eh6AI~`5pIEGXx|d4 zK=tY5YNI>(04v8Py18DCS3BW4qh{LYz9j44(gM|l9q32gsDAl)JJ0vz=gx@rCWta- zvBG_(6EM(xs*y!J3H$<+=zPCGoOyan%{f^b+L}-Qr4vaYZe+z#j6*}$1^g+MvTK5$ z#hL(v_+8XHlhS+e8_D?dlydO1M(q<-^I7%HS6^d?w8k(f3U$=K_S z&n#&_Pt+XAQqYUsQAT*2&vGMiJBkGd9i=>J&K06 zs5{Gbd7L`pWZY~+6&#By5A;NOX$! z0Dh)BD%Urb>zH?%=%8M&?-Y}PAOYEY9o&Qvpd3C$I=U5Il%5&mj|)O_W`Gc zehW)SNnfECqc%TXyf6<@HmNv7H8vL6v*t>m!hBo@_lJ$&jhUCKD3EY^WTKcRP7_v> z};v#hZ=xb83XZOra1{?R)+|pt_ z96VmmU)t?IN~zwn!+2bvG5If+eUm}f5A?1Jf{QaIkLnYTPxnU@R9pAU2qbh6$o{GQ z-+gjP7rKf|59I1Dx-JUf294>H`nw3$IS=vE#4>uNxJ)#-Lv6FgtR)p2RI-b7QqEz~ z-9fZU-hqWCZlGTLir&oiOdg(Yb=G%EUpEBk^qMV`GwH&tBJD#6lhGB|jZZu=WAD#G zxX%^dmT)zaT%x~5(x$U0o>Vyh84L+R2rI8NxBxX zb%70$BZq(-P%O~B>WM%>A|w|b*QyZsrTkkVU%BM;TWj}_ZQMX&Pkh`I!bmWWQHs6P z+rEYZG%tEN3m`7_Ql7y%0cO8yAZYx@Oe3TEx6~=E&^|mS$`n$Cd6^0td?{L z@pnl(FY)-!dTJ4uZhuY+Vm;{l+z!L3o&%iK1_Xb3I#v?{$y=LGErtr+-hnY5ecpj& z$OtQku?3~TxYegH2(^tfv`v!@=|LBbY>GJzrv{9%oe6#}VBw(P^}(QRyz2N8Qn!f) z5Oa5rg?eTvRx$6$;_(fC_M^*FqT4gCb|*`=McS%v9DBv91c_t9!)KAtsP)>3k6HH= z|1}HW@2oVguojBUSGWa1XBz0W%*kfmG*fJt_~uz(m1)g@Qrqolew3ouA{vs$@n$%e z0gH@sIJw&UpcVVFDw%?-yiVdVeuKsakXb+}JswTMm+5nsYQSF=g0Rcov}CBre`(Tv zE4=PJDzW);72UU7hpxSeT|wbJzUP`EZwhYg>&e*o3j`u$$%t5RaVi@=dSyfEHg3Bb z7T@nf<6GscqZ*!PW@ZIbieWyP0YI3>#>ntb#q+ft9Uzh%gC_y&c+B6ynir)Ao-CN75Fheam0W&Gq_L zt4T71p7c>f2jP=qHLw(x?tN(>*OuG$o%dVl-Q)I__@4xieEz&|G5p#KZ-+6wC#1SS{!Bx7e!0a(D@!NI=CNHx*V}x?DeqShew|w!SD$|Z8p2jt z$pAwW&u?^AopkA$qNr6;Fk?m;t3_H2g&o;ke z`A9M(zsH%Tdv4H{hEQDsX-*gxqBW z5Xw@Nz?%E#V9%>@9yAEN!wI4T&$uYEK`SbN;k14_S+5|L>h8aDpgGkj~13pKI=&7Zgb1u-0-bpQYW From cde168b2c1430ae4332a89f230ae8b86bed4a082 Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Thu, 19 Mar 2026 12:53:44 -0300 Subject: [PATCH 25/41] chore: add sparse prop to index creation --- packages/models/src/models/LivechatVisitors.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/models/src/models/LivechatVisitors.ts b/packages/models/src/models/LivechatVisitors.ts index cfbc1e3cd2edd..5aa1c621e4cba 100644 --- a/packages/models/src/models/LivechatVisitors.ts +++ b/packages/models/src/models/LivechatVisitors.ts @@ -31,7 +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.source': 1, 'externalIds.userId': 1 } }, + { key: { 'externalIds.source': 1, 'externalIds.userId': 1 }, sparse: true }, { key: { name: 1 }, sparse: true }, { key: { username: 1 } }, { key: { 'contactMananger.username': 1 }, sparse: true }, From a9553674832ba37664887348f2c51f438976f30b Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Thu, 19 Mar 2026 12:58:56 -0300 Subject: [PATCH 26/41] refactor: simplify externalIds handling in registerGuest --- .../models/src/models/LivechatVisitors.ts | 27 ------------------- packages/omni-core/src/visitor/create.spec.ts | 15 ----------- 2 files changed, 42 deletions(-) diff --git a/packages/models/src/models/LivechatVisitors.ts b/packages/models/src/models/LivechatVisitors.ts index 5aa1c621e4cba..684400193ff7e 100644 --- a/packages/models/src/models/LivechatVisitors.ts +++ b/packages/models/src/models/LivechatVisitors.ts @@ -340,33 +340,6 @@ export class LivechatVisitorsRaw extends BaseRaw implements IL update._id = new ObjectId().toHexString(); } - // If externalIds is present, use a pipeline update to merge with existing entries - // instead of overwriting the entire array - if (update.externalIds?.length) { - const { externalIds, ...restUpdate } = update; - const sourcesToReplace = externalIds.map((e) => e.source); - - return this.findOneAndUpdate( - query, - [ - { - $set: { - ...restUpdate, - externalIds: { - $concatArrays: [ - // Keep existing entries that are not being replaced - { $filter: { input: { $ifNull: ['$externalIds', []] }, cond: { $not: { $in: ['$$this.source', sourcesToReplace] } } } }, - // Add the new entries - externalIds, - ], - }, - }, - }, - ], - options, - ); - } - return this.findOneAndUpdate(query, { $set: update }, options); } diff --git a/packages/omni-core/src/visitor/create.spec.ts b/packages/omni-core/src/visitor/create.spec.ts index 74c81982b8823..2dee062386877 100644 --- a/packages/omni-core/src/visitor/create.spec.ts +++ b/packages/omni-core/src/visitor/create.spec.ts @@ -765,19 +765,4 @@ describe('registerGuest', () => { ); }); }); - - describe('externalIds handling', () => { - it('should pass externalIds as array to allow merging with existing entries', async () => { - const token = 'test-token'; - const externalIds = [ - { source: 'a1b2c3d4-e5f6-4890-8bcd-ef1234567890', userId: 'wa-123', username: '@john' }, - { source: 'b2c3d4e5-f6a7-4901-9cde-f12345678901', userId: 'tg-456' }, - ]; - - await registerGuest({ token, externalIds }, { shouldConsiderIdleAgent: false }); - - const callArgs = updateOneByIdOrTokenSpy.mock.calls[0][0]; - expect(callArgs.externalIds).toEqual(externalIds); - }); - }); }); From 28ba4ed5462eca3ed18c01abeadce4c5be9e8b9c Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Thu, 19 Mar 2026 13:04:45 -0300 Subject: [PATCH 27/41] chore: revert out-of-scope eslint auto-fix --- packages/models/src/models/LivechatVisitors.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/models/src/models/LivechatVisitors.ts b/packages/models/src/models/LivechatVisitors.ts index 684400193ff7e..06491fef9dc7b 100644 --- a/packages/models/src/models/LivechatVisitors.ts +++ b/packages/models/src/models/LivechatVisitors.ts @@ -294,7 +294,7 @@ export class LivechatVisitorsRaw extends BaseRaw implements IL if (!overwrite) { const user = await this.getVisitorByToken(token, { projection: { livechatData: 1 } }); - if (typeof user?.livechatData?.[key] !== 'undefined') { + if (user?.livechatData && typeof user.livechatData[key] !== 'undefined') { return true; } } From 34d647ca0611d621740143d6e571f1f443c062cd Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Tue, 7 Apr 2026 20:49:08 -0300 Subject: [PATCH 28/41] refactor: add entityId field to IVisitorExternalIdentifier --- .../app/livechat/server/lib/resolveVisitor.ts | 2 +- .../tests/data/apps/app-packages/README.md | 4 +- .../app-packages/external-id-test_0.0.1.zip | Bin 10682 -> 2669 bytes .../end-to-end/apps/app-resolve-visitor.ts | 44 +++++++++--------- .../server/lib/resolveVisitor.spec.ts | 18 +++---- .../definition/accessors/ILivechatCreator.ts | 2 +- .../src/definition/livechat/IVisitor.ts | 2 +- packages/core-typings/src/ILivechatVisitor.ts | 2 +- .../src/models/ILivechatVisitorsModel.ts | 2 +- .../models/src/models/LivechatVisitors.ts | 6 +-- 10 files changed, 41 insertions(+), 41 deletions(-) diff --git a/apps/meteor/app/livechat/server/lib/resolveVisitor.ts b/apps/meteor/app/livechat/server/lib/resolveVisitor.ts index 36ca05774de14..f3030d96aa8e2 100644 --- a/apps/meteor/app/livechat/server/lib/resolveVisitor.ts +++ b/apps/meteor/app/livechat/server/lib/resolveVisitor.ts @@ -10,7 +10,7 @@ type ResolveVisitorParams = { }; export async function resolveVisitor({ source, externalId, contactData }: ResolveVisitorParams): Promise { - const visitorByExternalId = await LivechatVisitors.findOneByExternalId(source, externalId.userId); + const visitorByExternalId = await LivechatVisitors.findOneByExternalId(source, externalId.entityId); if (visitorByExternalId) { return visitorByExternalId; } diff --git a/apps/meteor/tests/data/apps/app-packages/README.md b/apps/meteor/tests/data/apps/app-packages/README.md index aa21168f61b99..c3f0e7a84dc40 100644 --- a/apps/meteor/tests/data/apps/app-packages/README.md +++ b/apps/meteor/tests/data/apps/app-packages/README.md @@ -146,14 +146,14 @@ An app that tests the `ILivechatCreator.resolveVisitor()` API for resolving live **Request body:** ```json { - "externalId": { "userId": "bsuid-123", "username": "@johndoe" }, + "externalId": { "entityId": "bsuid-123", "username": "@johndoe" }, "phone": "+1234567890" } ``` or with email fallback: ```json { - "externalId": { "userId": "ext-456" }, + "externalId": { "entityId": "ext-456" }, "email": "user@example.com" } ``` 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 index c2c40ee90dda3928ad12add8de33cb28d97f1f62..0810feaddd4986ecec689ba0a808b2054094853e 100644 GIT binary patch literal 2669 zcmai$c{r47AIBeK29-hC;vIVcdC-pU@76E<4_&ga)zgamM1@Y@D(KT>fT zZJ;qL{j~l&f(6Xw_J_32Vcv`I2vf&D|WB&+`DX0ES_q<{mKUi z8QI(%qDiIUkl^;bLo99yqMAD8=Cx*Jkzq2oJa)T;D+ zaW4>}wGS+Zj)tr*dE^fXJ)ZPxv5%z=_1f2UIG61v=f<+7ij17#Wvu6C{mMDV=jY~D zypk@eL$w$4UAl~X0uG7AR0Y1kIU*4qCO12cbBWXum+FJw3nY;aEp(F0_ ziRqbz+5d>!3X>kyAM50%)095yFE-j3G5e*1=1wqFRo+5l@&)rlU5(sDr@fBa+)khk z4%=Cz1kpwr!;f73IAY3$bsgOrbX+)Bf4?i0a66r7NTeidn?*Jm$Sg>Z+A_{g93l2me5CM=aOYm_!`Muo)%hwM?PL z%E{tPi0?Ku|A7f{c3VG$CSGyp*4|ewqP=R-5*U@SJ$J0%yP}NavXc`ZhIq7O8+u%( z$;Hmwv55M8Tu<0(e4}JzOzW*1SJDP?hpjqSvr_FTcUiRGS&o%AR94wB1(-pm%9@Au zjMIfR7K&L#{qzw~W_5kE>B_vE6SPzws9V?;%<+9|#Dj^htDPhWgIi+^Ts+{`aQ>EI zGU;22rN9KQub)zE8E;9hm*$Q7z!p}RFl-dM;7RUeCcWyh)U%QuM)oE?4W(XZeEkz9 z(&O`ZRlk5pYi|7PW5bRe!wk0U6D_wPg?2N-Q_6_2B=RdA1!pLeCca#r5aaiMo$>b7P2LLmQ{*< zAOoq9tc_MSs`<0w{na`THY~HrY0c}Z_{p)Af@_EtdRbe^qx2LoXJ$nAzk*g8qpNfK6om3C5aRs9H zN6|bil(h5^Z$tT36MGBbYzL;%@Z+?WV#)&dcdR!+tk69nk#P6z<-g1&k?yLvji;lYgm&szpK zz=O?0;4zlRAgTg2Ulip7hsR86%Mx737t#DS|)(2ys@J-9VMBn#7)Bic@Si})$@86oedmo5g6&D^5p5Git+Z}cnd2x@)~ z(Us&&r>?|qh1AO&fn9a2w*OjqX@Fy7ewyzEA>ib+}ptUK{HV4^_Ub%n32hA~#jl{k4?2?$ycruZ}zL#2#!ryEZfd9 z!DpqOqrcG8&>TIEeK)3Jnej%a`kA$vejohn2Euh9;cF#>c3VNhc#tq0Buu4%9-kw) zeMd|K@dj;1OiVDe;8Yd3^kG@iJoefQWA`WdN8DMm3Yq2!X>@IkrbwLZzI$tfxbgwk zV0<@?JaiMcz*3URY79^pkkpl{RLFN*=A*{r{kYo>o?oW=2cJi_S+yowacyl8NqOyt z=r)bUVO5p2O~s?c;mwWGacRck9CQTcQ^o9LTR_BCcEfwM;Op{y{(!}q{q1sLqgt%B z87#DBSF+A--lwuEC~HZ2onB#2;&>l-nIt4dfDk+`=%3^lmZkd};h>3RFiKjxc4trg zGx!pc*=B$uHsn`-!PMEV=H=`7Ou`_y)XY*@53M9lqa0rn_f?H7DMY=GtVmg`nP|+Y{34$Z2VMFKP&%u0&Y|; z2mjy~=i$H0Z*I|z@?GGO`@K^^wSVZ-jl*s(kH6K{0px`3fdLz;1XNqEl3%>~<|f!U w8v|7Pu@^wa^k&&j;!yytt*ckaD^-QBBJ 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 ezb)T)kNn@FQeNs4N 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 index fc41b0006ed48..d9a686409ba2d 100644 --- a/apps/meteor/tests/end-to-end/apps/app-resolve-visitor.ts +++ b/apps/meteor/tests/end-to-end/apps/app-resolve-visitor.ts @@ -33,7 +33,7 @@ import { IS_EE } from '../../e2e/config/constants'; }); const callResolveVisitor = async ( - externalId: { userId: string; username?: string }, + externalId: { entityId: string; username?: string }, contactData?: { phone?: string; email?: string }, ) => { return request @@ -44,7 +44,7 @@ import { IS_EE } from '../../e2e/config/constants'; describe('externalId lookup', () => { it('should return null when externalId does not exist', async () => { - const response = await callResolveVisitor({ userId: 'nonexistent-id' }); + const response = await callResolveVisitor({ entityId: 'nonexistent-id' }); expect(response.status).to.equal(200); expect(response.body.visitor).to.be.null; @@ -54,7 +54,7 @@ import { IS_EE } from '../../e2e/config/constants'; const phone = `+1${Date.now()}`; const visitor = await createVisitor(undefined, undefined, undefined, phone); - const externalId = { userId: `id-${Date.now()}`, username: '@user' }; + const externalId = { entityId: `id-${Date.now()}`, username: '@user' }; await callResolveVisitor(externalId, { phone }); const response = await callResolveVisitor(externalId); @@ -62,7 +62,7 @@ import { IS_EE } from '../../e2e/config/constants'; expect(response.status).to.equal(200); expect(response.body.visitor.id).to.equal(visitor._id); const appExternalId = response.body.visitor.externalIds.find((e: { source: string }) => e.source === app.id); - expect(appExternalId.userId).to.equal(externalId.userId); + expect(appExternalId.entityId).to.equal(externalId.entityId); expect(appExternalId.username).to.equal(externalId.username); }); @@ -70,13 +70,13 @@ import { IS_EE } from '../../e2e/config/constants'; const phone = `+2${Date.now()}`; await createVisitor(undefined, undefined, undefined, phone); - const externalId = { userId: `id-${Date.now()}`, username: '@original' }; + const externalId = { entityId: `id-${Date.now()}`, 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({ userId: externalId.userId, username: '@changed' }); + const response = await callResolveVisitor({ entityId: externalId.entityId, username: '@changed' }); const appExternalId = response.body.visitor.externalIds.find((e: { source: string }) => e.source === app.id); expect(appExternalId.username).to.equal('@original'); @@ -88,39 +88,39 @@ import { IS_EE } from '../../e2e/config/constants'; const phone = `+3${Date.now()}`; const visitor = await createVisitor(undefined, undefined, undefined, phone); - const externalId = { userId: `id-${Date.now()}`, username: '@user' }; + const externalId = { entityId: `id-${Date.now()}`, 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: { source: string }) => e.source === app.id); - expect(appExternalId.userId).to.equal(externalId.userId); + expect(appExternalId.entityId).to.equal(externalId.entityId); expect(appExternalId.username).to.equal(externalId.username); }); - it('should overwrite externalId when called with different userId', async () => { + it('should overwrite externalId when called with different entityId', async () => { const phone = `+4${Date.now()}`; await createVisitor(undefined, undefined, undefined, phone); - await callResolveVisitor({ userId: `first-${Date.now()}`, username: '@first' }, { phone }); + await callResolveVisitor({ entityId: `first-${Date.now()}`, username: '@first' }, { phone }); - const newExternalId = { userId: `second-${Date.now()}`, username: '@second' }; + const newExternalId = { entityId: `second-${Date.now()}`, username: '@second' }; const response = await callResolveVisitor(newExternalId, { phone }); const appExternalId = response.body.visitor.externalIds.find((e: { source: string }) => e.source === app.id); - expect(appExternalId.userId).to.equal(newExternalId.userId); + expect(appExternalId.entityId).to.equal(newExternalId.entityId); expect(appExternalId.username).to.equal('@second'); }); it('should return null when phone not found', async () => { - const response = await callResolveVisitor({ userId: `id-${Date.now()}` }, { phone: '+0000000000' }); + 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({ userId: `id-${Date.now()}` }, { phone: '' }); + const response = await callResolveVisitor({ entityId: `id-${Date.now()}` }, { phone: '' }); expect(response.status).to.equal(200); expect(response.body.visitor).to.be.null; @@ -132,39 +132,39 @@ import { IS_EE } from '../../e2e/config/constants'; const email = `test-${Date.now()}@example.com`; const visitor = await createVisitor(undefined, undefined, email); - const externalId = { userId: `id-email-${Date.now()}`, username: '@emailuser' }; + const externalId = { entityId: `id-email-${Date.now()}`, 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: { source: string }) => e.source === app.id); - expect(appExternalId.userId).to.equal(externalId.userId); + expect(appExternalId.entityId).to.equal(externalId.entityId); expect(appExternalId.username).to.equal(externalId.username); }); - it('should overwrite externalId when called with different userId via email', async () => { + 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({ userId: `first-email-${Date.now()}`, username: '@first' }, { email }); + await callResolveVisitor({ entityId: `first-email-${Date.now()}`, username: '@first' }, { email }); - const newExternalId = { userId: `second-email-${Date.now()}`, username: '@second' }; + const newExternalId = { entityId: `second-email-${Date.now()}`, username: '@second' }; const response = await callResolveVisitor(newExternalId, { email }); const appExternalId = response.body.visitor.externalIds.find((e: { source: string }) => e.source === app.id); - expect(appExternalId.userId).to.equal(newExternalId.userId); + expect(appExternalId.entityId).to.equal(newExternalId.entityId); expect(appExternalId.username).to.equal('@second'); }); it('should return null when email not found', async () => { - const response = await callResolveVisitor({ userId: `id-${Date.now()}` }, { email: 'notfound@example.com' }); + 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({ userId: `id-${Date.now()}` }, { email: '' }); + const response = await callResolveVisitor({ entityId: `id-${Date.now()}` }, { email: '' }); expect(response.status).to.equal(200); expect(response.body.visitor).to.be.null; diff --git a/apps/meteor/tests/unit/app/livechat/server/lib/resolveVisitor.spec.ts b/apps/meteor/tests/unit/app/livechat/server/lib/resolveVisitor.spec.ts index eeedabb841af0..b972fa9fe3339 100644 --- a/apps/meteor/tests/unit/app/livechat/server/lib/resolveVisitor.spec.ts +++ b/apps/meteor/tests/unit/app/livechat/server/lib/resolveVisitor.spec.ts @@ -27,14 +27,14 @@ describe('resolveVisitor', () => { _id: 'visitor-123', token: 'token-123', username: 'guest-1', - externalIds: [{ source: appId, userId: 'bsuid-123' }], + externalIds: [{ source: appId, entityId: 'bsuid-123' }], }; modelsMock.LivechatVisitors.findOneByExternalId.resolves(existingVisitor); const result = await resolveVisitor({ source: appId, - externalId: { userId: 'bsuid-123' }, + externalId: { entityId: 'bsuid-123' }, contactData: { phone: '1234567890' }, }); @@ -44,7 +44,7 @@ describe('resolveVisitor', () => { }); it('should find by phone, enrich with external ID, and return visitor when not found by external ID', async () => { - const externalId = { userId: 'bsuid-456', username: '@johndoe' }; + const externalId = { entityId: 'bsuid-456', username: '@johndoe' }; const contactData = { phone: '9876543210' }; const updatedVisitor = { _id: 'visitor-456', @@ -65,7 +65,7 @@ describe('resolveVisitor', () => { }); it('should find by email, enrich with external ID, and return visitor when not found by external ID', async () => { - const externalId = { userId: 'bsuid-email', username: '@emailuser' }; + const externalId = { entityId: 'bsuid-email', username: '@emailuser' }; const contactData = { email: 'test@example.com' }; const updatedVisitor = { _id: 'visitor-email', @@ -86,7 +86,7 @@ describe('resolveVisitor', () => { }); it('should update existing externalIds when visitor already has some', async () => { - const newExternalId = { userId: 'bsuid-789', username: '@newuser' }; + const newExternalId = { entityId: 'bsuid-789', username: '@newuser' }; const contactData = { phone: '5555555555' }; const updatedVisitor = { _id: 'visitor-789', @@ -106,7 +106,7 @@ describe('resolveVisitor', () => { it('should return null when not found by external ID and no contact data provided', async () => { modelsMock.LivechatVisitors.findOneByExternalId.resolves(null); - const result = await resolveVisitor({ source: appId, externalId: { userId: 'bsuid-unknown' } }); + const result = await resolveVisitor({ source: appId, externalId: { entityId: 'bsuid-unknown' } }); expect(result).to.be.null; expect(modelsMock.LivechatVisitors.findOneByExternalId.calledOnce).to.be.true; @@ -119,7 +119,7 @@ describe('resolveVisitor', () => { const result = await resolveVisitor({ source: appId, - externalId: { userId: 'bsuid-unknown' }, + externalId: { entityId: 'bsuid-unknown' }, contactData: { phone: '0000000000' }, }); @@ -133,7 +133,7 @@ describe('resolveVisitor', () => { const result = await resolveVisitor({ source: appId, - externalId: { userId: 'bsuid-123' }, + externalId: { entityId: 'bsuid-123' }, contactData: { phone: '' }, }); @@ -146,7 +146,7 @@ describe('resolveVisitor', () => { const result = await resolveVisitor({ source: appId, - externalId: { userId: 'bsuid-123' }, + externalId: { entityId: 'bsuid-123' }, contactData: { email: '' }, }); diff --git a/packages/apps-engine/src/definition/accessors/ILivechatCreator.ts b/packages/apps-engine/src/definition/accessors/ILivechatCreator.ts index 0efb7c4405f93..0d68fb4e27ba5 100644 --- a/packages/apps-engine/src/definition/accessors/ILivechatCreator.ts +++ b/packages/apps-engine/src/definition/accessors/ILivechatCreator.ts @@ -12,7 +12,7 @@ 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 userId and optional username + * @param externalId The external identifier containing entityId and optional username * @param contactData Optional contact data for fallback lookup. Use `{ phone: '+1234567890' }` or `{ email: 'user@example.com' }` * @returns The visitor if found, undefined otherwise */ diff --git a/packages/apps-engine/src/definition/livechat/IVisitor.ts b/packages/apps-engine/src/definition/livechat/IVisitor.ts index 65d40472ec015..3a0cefb395163 100644 --- a/packages/apps-engine/src/definition/livechat/IVisitor.ts +++ b/packages/apps-engine/src/definition/livechat/IVisitor.ts @@ -3,7 +3,7 @@ import type { IVisitorPhone } from './IVisitorPhone'; export interface IVisitorExternalIdentifier { source?: string; - userId: string; + entityId: string; username?: string; } diff --git a/packages/core-typings/src/ILivechatVisitor.ts b/packages/core-typings/src/ILivechatVisitor.ts index bac6ba76d90ee..45747265aa6d4 100644 --- a/packages/core-typings/src/ILivechatVisitor.ts +++ b/packages/core-typings/src/ILivechatVisitor.ts @@ -16,7 +16,7 @@ export interface IVisitorEmail { export interface IVisitorExternalIdentifier { source: string; - userId: string; + entityId: string; username?: string; } diff --git a/packages/model-typings/src/models/ILivechatVisitorsModel.ts b/packages/model-typings/src/models/ILivechatVisitorsModel.ts index 8b14ce1ecb3a2..4da9bfcdefecf 100644 --- a/packages/model-typings/src/models/ILivechatVisitorsModel.ts +++ b/packages/model-typings/src/models/ILivechatVisitorsModel.ts @@ -56,7 +56,7 @@ export interface ILivechatVisitorsModel extends IBaseModel { externalId: Omit, ): Promise; - findOneByExternalId(source: string, externalUserId: string): Promise; + findOneByExternalId(source: string, externalEntityId: string): Promise; removeDepartmentById(_id: string): Promise; diff --git a/packages/models/src/models/LivechatVisitors.ts b/packages/models/src/models/LivechatVisitors.ts index 06491fef9dc7b..ac4dcd420f3d7 100644 --- a/packages/models/src/models/LivechatVisitors.ts +++ b/packages/models/src/models/LivechatVisitors.ts @@ -31,7 +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.source': 1, 'externalIds.userId': 1 }, sparse: true }, + { key: { 'externalIds.source': 1, 'externalIds.entityId': 1 }, sparse: true }, { key: { name: 1 }, sparse: true }, { key: { username: 1 } }, { key: { 'contactMananger.username': 1 }, sparse: true }, @@ -79,9 +79,9 @@ export class LivechatVisitorsRaw extends BaseRaw implements IL ); } - findOneByExternalId(source: string, externalUserId: string): Promise { + findOneByExternalId(source: string, externalEntityId: string): Promise { return this.findOne({ - externalIds: { $elemMatch: { source, userId: externalUserId } }, + externalIds: { $elemMatch: { source, entityId: externalEntityId } }, }); } From 29b7cf9e358124378363f08e706ce37cc05b4a0b Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Tue, 7 Apr 2026 21:05:13 -0300 Subject: [PATCH 29/41] refactor: add metadata field to IVisitorExternalIdentifier --- .../ContactInfoChannelsItem.tsx | 6 ++-- .../tests/data/apps/app-packages/README.md | 2 +- .../app-packages/external-id-test_0.0.1.zip | Bin 2669 -> 2600 bytes .../end-to-end/apps/app-resolve-visitor.ts | 32 +++++++++--------- .../server/lib/resolveVisitor.spec.ts | 6 ++-- .../definition/accessors/ILivechatCreator.ts | 2 +- .../src/definition/livechat/IVisitor.ts | 2 +- packages/core-typings/src/ILivechatVisitor.ts | 2 +- 8 files changed, 26 insertions(+), 26 deletions(-) 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 f9e6179f7208e..0b5cbc2478479 100644 --- a/apps/meteor/client/views/omnichannel/contactInfo/tabs/ContactInfoChannels/ContactInfoChannelsItem.tsx +++ b/apps/meteor/client/views/omnichannel/contactInfo/tabs/ContactInfoChannels/ContactInfoChannelsItem.tsx @@ -35,12 +35,12 @@ const ContactInfoChannelsItem = ({ const channelLabel = useMemo(() => { const phone = getSourceLabel(details); const externalId = details?.id ? visitorData?.externalIds?.find((e) => e.source === details.id) : undefined; - const username = externalId?.username; + const username = externalId?.metadata?.username; - if (username && phone) { + if (typeof username === 'string' && phone) { return `${username} - ${phone}`; } - return username || phone; + return typeof username === 'string' ? username : phone; }, [visitorData?.externalIds, details, getSourceLabel]); const [showButton, setShowButton] = useState(false); diff --git a/apps/meteor/tests/data/apps/app-packages/README.md b/apps/meteor/tests/data/apps/app-packages/README.md index c3f0e7a84dc40..dd06fcf7f9485 100644 --- a/apps/meteor/tests/data/apps/app-packages/README.md +++ b/apps/meteor/tests/data/apps/app-packages/README.md @@ -146,7 +146,7 @@ An app that tests the `ILivechatCreator.resolveVisitor()` API for resolving live **Request body:** ```json { - "externalId": { "entityId": "bsuid-123", "username": "@johndoe" }, + "externalId": { "entityId": "bsuid-123", "metadata": { "username": "@johndoe" } }, "phone": "+1234567890" } ``` 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 index 0810feaddd4986ecec689ba0a808b2054094853e..982dbc9de6458de2603a8aaa5868d2c3de4e192f 100644 GIT binary patch delta 1316 zcmaDWvOz?+#xgn@y9gW>G5i9FgOhBL2bf^caCHv=QfS4IW~7KV-9WsFQ`mrXv* zXn<9IGdt5&#`?p{+GAchE)5n1>Ujdh91JoHi3J6ES;hHzp&^_M%yK|u<VLaQf>Y<@>_4AvZKs}6 zr+KQxJxJsI(P;Ukx^Pva*6r>K4FROtS@G6lDbKE4}5Q7 zOIiA{z}1c`Do9LHB&+6_+@43ttB#vx)fCM&+FZm}xuUm*5sM z{d{LmR0r8lD&J7(b>aT6mAvAyGk8*;h4sAHx%DH<#z5WL*wRwE}qT&8Z~+TPi>ZU-8W5ks?<-`WVK^tn4H1tQ-2H?ve#_qnZ5;v zRt+#@d4M5XkeHmEn4XFVU1ea%s$hn0=mbaq!wM2@@BfOneCB%F(|z;g(YF_78@{^O za$x(r+ffQNOR}c@yuGxfce1pN{`u3#cP3;OuC98Jee9vO(}q>r7pDl;YkWF-gq@@1 zT;D{mkVW&(^G@J<@p$UqW!gTK=fCM~V0f~N@!mv(WpAJDOq?&lpYtx^?6frh^##dE8}|CH|8hkm>j-g@pyUw@VAEA~MB@a+rd zoRl|7t9y5J?uv?I(;o%>VL~M56Tm>58h*g;3oy`r0|Sj07-%KM$@zI{ndyj7(*TB; z7G|jBPW1O?HWWDe{i-OfEy` z3y#}W_Yz_r9)Bc#CavJj!+#3`C3>{CrTtf1mtK6c*jC}jynwU6{}yoU?0ph@d;RX( z@B9JYj7)OOu+pGC=IGSAbD6+2Si)hTh+ZZDLlsr|I785iipWtoazFg;sCjn znViV!D+e?d#az^+Klu!&sy!&7BkM!cff?@-AVV2|Nq}KV<6XAN23#VVplHW1gOv@W OjtvNZ0ToMdfOr6|cnDbl delta 1392 zcmZ1>@>YZ=z?+#xgn@y9gW=WUi9FgO$0uLSoCw6F72FJrEMFNJ7+6F$dY3UWy;?l^ zFrxuh`OWN1R~hRkEN+iEbnr!{C{WK6Am(6@VMr_}(90^$&kGIVWMGyD8Vkb6Mgwj3 zo#2~)Sb?YQ{ZCPi=Z+^tO0)AX?wfM^im$Do(gW3NlMXbLOkVZxJFD20qD_D8*5}ro zI`OF}Z<4; z^DZ{6;+4}o;eJx}Ax(9f`ct7p%|yNgA_GCwKl^sQE8 z+ADXkL(ggN_hWCn4rTphop&-_7s z%-R0Skm^0;0TJJDXeU*Rs&e865Jg%;PMU~kf##lLKU0HEqnOR%qbO%v8wZO`kq-Y3{8qd6xR!xtc5+UM5<-KU#NH`lLX{uvPa z_9L(5c{lY_9PT${es%3B$#Fh7?-2(__CixWnaa!gFJ{`d|CnQ|_h9m2AwIbcGk!Op ziz*PS(755Gx^m{$A9YXO%&l3HTdZt;-)={Ercp&?$IkrLn9e>fro)OJtX;`_BK~b? zy~X(G>(hPfE3JK4e0H4R?C~@8UVQOYz1~&Ve;uy;i=*EaEk0#fc3l6~nUj;kmZ)BK z{j<`z$d&PFP)o|Q>`St*=j?tuPc88Gf8BTgavr7p%>1!U@Tyzt!s%%}XKl^Q9|s3L zd!p|<=f3rxqk*1wH+cUsAX5J!U>J9Axf{*}4C7Q_81n+dxTH8aKQAq_J{=LtHNX(A z#SG=#iH_dPh61hce`|a7b-!7q{!wi$`$8p+d0rD|^jMjlY)MhlUHboS%Jy4V?@qW} zlYeh^%D1aJ@(WK?nFz?)DDTu*m;RftWI}2M`#ICX-zAlGg{tTL&P?)S<2o%it^Q8B z%w_kElpsSrD|e|jDc(~j6#8d4H!=3e1bp1_?!~#PX0D&d-Wt_SR^zK=@Qyb)uiW_9 z;_Cl5K`eV!rdq4>98l7C2$btNWV_+Yq^CF7x5+V06UeN4DqK0GqH&?k{!CLhxjF|0 ztAi3(CLHDcrMmB&y+iJ?b$dPs918ope4VvT(F<0lxkjGTudeZvjdh5=o~yg-(Jzg* zw=Fwn?E9kqXY }, contactData?: { phone?: string; email?: string }, ) => { return request @@ -54,7 +54,7 @@ import { IS_EE } from '../../e2e/config/constants'; const phone = `+1${Date.now()}`; const visitor = await createVisitor(undefined, undefined, undefined, phone); - const externalId = { entityId: `id-${Date.now()}`, username: '@user' }; + const externalId = { entityId: `id-${Date.now()}`, metadata: { username: '@user' } }; await callResolveVisitor(externalId, { phone }); const response = await callResolveVisitor(externalId); @@ -63,23 +63,23 @@ import { IS_EE } from '../../e2e/config/constants'; expect(response.body.visitor.id).to.equal(visitor._id); const appExternalId = response.body.visitor.externalIds.find((e: { source: string }) => e.source === app.id); expect(appExternalId.entityId).to.equal(externalId.entityId); - expect(appExternalId.username).to.equal(externalId.username); + 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()}`, username: '@original' }; + 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, username: '@changed' }); + const response = await callResolveVisitor({ entityId: externalId.entityId, metadata: { username: '@changed' } }); const appExternalId = response.body.visitor.externalIds.find((e: { source: string }) => e.source === app.id); - expect(appExternalId.username).to.equal('@original'); + expect(appExternalId.metadata.username).to.equal('@original'); }); }); @@ -88,28 +88,28 @@ import { IS_EE } from '../../e2e/config/constants'; const phone = `+3${Date.now()}`; const visitor = await createVisitor(undefined, undefined, undefined, phone); - const externalId = { entityId: `id-${Date.now()}`, username: '@user' }; + 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: { source: string }) => e.source === app.id); expect(appExternalId.entityId).to.equal(externalId.entityId); - expect(appExternalId.username).to.equal(externalId.username); + 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()}`, username: '@first' }, { phone }); + await callResolveVisitor({ entityId: `first-${Date.now()}`, metadata: { username: '@first' } }, { phone }); - const newExternalId = { entityId: `second-${Date.now()}`, username: '@second' }; + const newExternalId = { entityId: `second-${Date.now()}`, metadata: { username: '@second' } }; const response = await callResolveVisitor(newExternalId, { phone }); const appExternalId = response.body.visitor.externalIds.find((e: { source: string }) => e.source === app.id); expect(appExternalId.entityId).to.equal(newExternalId.entityId); - expect(appExternalId.username).to.equal('@second'); + expect(appExternalId.metadata.username).to.equal('@second'); }); it('should return null when phone not found', async () => { @@ -132,28 +132,28 @@ import { IS_EE } from '../../e2e/config/constants'; const email = `test-${Date.now()}@example.com`; const visitor = await createVisitor(undefined, undefined, email); - const externalId = { entityId: `id-email-${Date.now()}`, username: '@emailuser' }; + 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: { source: string }) => e.source === app.id); expect(appExternalId.entityId).to.equal(externalId.entityId); - expect(appExternalId.username).to.equal(externalId.username); + 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()}`, username: '@first' }, { email }); + await callResolveVisitor({ entityId: `first-email-${Date.now()}`, metadata: { username: '@first' } }, { email }); - const newExternalId = { entityId: `second-email-${Date.now()}`, username: '@second' }; + const newExternalId = { entityId: `second-email-${Date.now()}`, metadata: { username: '@second' } }; const response = await callResolveVisitor(newExternalId, { email }); const appExternalId = response.body.visitor.externalIds.find((e: { source: string }) => e.source === app.id); expect(appExternalId.entityId).to.equal(newExternalId.entityId); - expect(appExternalId.username).to.equal('@second'); + expect(appExternalId.metadata.username).to.equal('@second'); }); it('should return null when email not found', async () => { diff --git a/apps/meteor/tests/unit/app/livechat/server/lib/resolveVisitor.spec.ts b/apps/meteor/tests/unit/app/livechat/server/lib/resolveVisitor.spec.ts index b972fa9fe3339..0d5433073e8d2 100644 --- a/apps/meteor/tests/unit/app/livechat/server/lib/resolveVisitor.spec.ts +++ b/apps/meteor/tests/unit/app/livechat/server/lib/resolveVisitor.spec.ts @@ -44,7 +44,7 @@ describe('resolveVisitor', () => { }); it('should find by phone, enrich with external ID, and return visitor when not found by external ID', async () => { - const externalId = { entityId: 'bsuid-456', username: '@johndoe' }; + const externalId = { entityId: 'bsuid-456', metadata: { username: '@johndoe' } }; const contactData = { phone: '9876543210' }; const updatedVisitor = { _id: 'visitor-456', @@ -65,7 +65,7 @@ describe('resolveVisitor', () => { }); it('should find by email, enrich with external ID, and return visitor when not found by external ID', async () => { - const externalId = { entityId: 'bsuid-email', username: '@emailuser' }; + const externalId = { entityId: 'bsuid-email', metadata: { username: '@emailuser' } }; const contactData = { email: 'test@example.com' }; const updatedVisitor = { _id: 'visitor-email', @@ -86,7 +86,7 @@ describe('resolveVisitor', () => { }); it('should update existing externalIds when visitor already has some', async () => { - const newExternalId = { entityId: 'bsuid-789', username: '@newuser' }; + const newExternalId = { entityId: 'bsuid-789', metadata: { username: '@newuser' } }; const contactData = { phone: '5555555555' }; const updatedVisitor = { _id: 'visitor-789', diff --git a/packages/apps-engine/src/definition/accessors/ILivechatCreator.ts b/packages/apps-engine/src/definition/accessors/ILivechatCreator.ts index 0d68fb4e27ba5..6d2257a475b87 100644 --- a/packages/apps-engine/src/definition/accessors/ILivechatCreator.ts +++ b/packages/apps-engine/src/definition/accessors/ILivechatCreator.ts @@ -12,7 +12,7 @@ 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 username + * @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 */ diff --git a/packages/apps-engine/src/definition/livechat/IVisitor.ts b/packages/apps-engine/src/definition/livechat/IVisitor.ts index 3a0cefb395163..a55c583433172 100644 --- a/packages/apps-engine/src/definition/livechat/IVisitor.ts +++ b/packages/apps-engine/src/definition/livechat/IVisitor.ts @@ -4,7 +4,7 @@ import type { IVisitorPhone } from './IVisitorPhone'; export interface IVisitorExternalIdentifier { source?: string; entityId: string; - username?: string; + metadata?: Record; } export type ResolveVisitorContactData = { phone: string } | { email: string }; diff --git a/packages/core-typings/src/ILivechatVisitor.ts b/packages/core-typings/src/ILivechatVisitor.ts index 45747265aa6d4..02011d55d8c2d 100644 --- a/packages/core-typings/src/ILivechatVisitor.ts +++ b/packages/core-typings/src/ILivechatVisitor.ts @@ -17,7 +17,7 @@ export interface IVisitorEmail { export interface IVisitorExternalIdentifier { source: string; entityId: string; - username?: string; + metadata?: Record; } export interface ILivechatVisitor extends IRocketChatRecord { From 072831b4c589da0deca5851a17d8cd30ab5b03cd Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Tue, 7 Apr 2026 22:19:22 -0300 Subject: [PATCH 30/41] refactor: expose only updateExternalIds and findByEntityId methods --- .../app/apps/server/bridges/livechat.ts | 12 +++ .../app/livechat/server/lib/resolveVisitor.ts | 2 +- .../tests/data/apps/app-packages/README.md | 63 ++++++++++++-- .../app-packages/external-id-test_0.0.1.zip | Bin 2600 -> 2866 bytes .../end-to-end/apps/app-resolve-visitor.ts | 77 +++++++++++++++++- .../server/lib/resolveVisitor.spec.ts | 22 ++++- .../definition/accessors/ILivechatUpdater.ts | 17 +++- .../src/server/accessors/LivechatUpdater.ts | 9 +- .../src/server/bridges/LivechatBridge.ts | 16 ++++ .../src/models/ILivechatVisitorsModel.ts | 8 +- .../models/src/models/LivechatVisitors.ts | 32 +++++++- 11 files changed, 238 insertions(+), 20 deletions(-) diff --git a/apps/meteor/app/apps/server/bridges/livechat.ts b/apps/meteor/app/apps/server/bridges/livechat.ts index 21c275d396709..8ea7848a3b23b 100644 --- a/apps/meteor/app/apps/server/bridges/livechat.ts +++ b/apps/meteor/app/apps/server/bridges/livechat.ts @@ -366,6 +366,18 @@ export class AppLivechatBridge extends LivechatBridge { 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/livechat/server/lib/resolveVisitor.ts b/apps/meteor/app/livechat/server/lib/resolveVisitor.ts index f3030d96aa8e2..7c246d4c1bff5 100644 --- a/apps/meteor/app/livechat/server/lib/resolveVisitor.ts +++ b/apps/meteor/app/livechat/server/lib/resolveVisitor.ts @@ -10,7 +10,7 @@ type ResolveVisitorParams = { }; export async function resolveVisitor({ source, externalId, contactData }: ResolveVisitorParams): Promise { - const visitorByExternalId = await LivechatVisitors.findOneByExternalId(source, externalId.entityId); + const visitorByExternalId = await LivechatVisitors.findOneByExternalId(externalId.entityId); if (visitorByExternalId) { return visitorByExternalId; } diff --git a/apps/meteor/tests/data/apps/app-packages/README.md b/apps/meteor/tests/data/apps/app-packages/README.md index dd06fcf7f9485..5af84e4ab31c8 100644 --- a/apps/meteor/tests/data/apps/app-packages/README.md +++ b/apps/meteor/tests/data/apps/app-packages/README.md @@ -139,28 +139,31 @@ export class TestEndpoint extends ApiEndpoint { File name: `external-id-test_0.0.1.zip` -An app that tests the `ILivechatCreator.resolveVisitor()` API for resolving livechat visitors by external identifiers with phone/email fallback. This is used to test the WhatsApp BSUID (Business Scoped User ID) support and progressive visitor enrichment. +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. -**Endpoint:** `POST /api/apps/public/:appId/resolve-visitor` +**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:** +**Request body (resolve-visitor):** ```json { "externalId": { "entityId": "bsuid-123", "metadata": { "username": "@johndoe" } }, "phone": "+1234567890" } ``` -or with email fallback: + +**Request body (update-external-id):** ```json { - "externalId": { "entityId": "ext-456" }, - "email": "user@example.com" + "visitorId": "visitor-123", + "externalId": { "entityId": "bsuid-123", "metadata": { "username": "@johndoe" } } } ``` **Response:** -- Returns the visitor if found (by externalId or contact data fallback) -- Enriches visitor with externalId when found by phone/email +- Returns the visitor if found/updated - Returns `{ visitor: null }` if not found
@@ -177,6 +180,7 @@ 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) { @@ -187,7 +191,7 @@ export class ExternalIdTestApp extends App { await configuration.api.provideApi({ visibility: ApiVisibility.PUBLIC, security: ApiSecurity.UNSECURE, - endpoints: [new ResolveVisitorEndpoint(this)], + endpoints: [new ResolveVisitorEndpoint(this), new UpdateExternalIdEndpoint(this)], }); } } @@ -235,6 +239,47 @@ export class ResolveVisitorEndpoint extends ApiEndpoint { } ``` +**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 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 index 982dbc9de6458de2603a8aaa5868d2c3de4e192f..c5c43ce4bfbe04da5499d46f346a608e18e6cf84 100644 GIT binary patch literal 2866 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 O3OpPDh5Q^FU;hA&R+Rey literal 2600 zcmai$2{e>@AIG0z3}Vog7|Bv8TSy|x*dlAgL`1ok8O&g2ni=~@qD98OXR9p9&GyQA zT`5`0E+b1b*}6nXjc&Hr`{=&sq`YtU%=w=)&pFTYd7ktA{=VP;-^!d5x(fgR9-y<} z#)anO%Uw7K03bL4U>6_(7zI$VWP*#gDaHm%q3V-J%6N*c4KKiXB%OiVT)qK(00g=O zJ^}?HKr*`hOp~fm{gmRvOtgFr$tU8}gCMJf0}HD?2Vz z&TWq9rt3`Ph3N?e_h;^Y&X_prnIhZK+gMpGaI#d*UVAp39N*=xCtAfXl2;obT3N)w z4_yUZnTiQ1a;@etqK)r47RO?b)+Hr;RQ_?LUwq)@HwWL#-IH|U3~K8WCNA-n^`maB zzR=PJDsHAuYx?UOd#zX}eMat0YT7t)s+TneYdd%BwTz6Tu10=^XN{M_s&0jNs@cxS zh4V7|3i5o6YxP;eJMU{TxcB;~sZ%ndUMTM#rQCf4+c*2*6oFRfmWLG0Rfr22SuQMyqo`HRszU zHhaxG$zL6+{iVdU&ww1Hg@`TCoG9TK4#G*#!|T^qPJNt;rsu>32?lpBmE2#-(VfzK zXo3W+zO&7)UxVBRdaw@Kh5&6tV<|*$KkPXi1xF>4jR+VL5l5i@gLheHEn{D*3jHx< zM<);2yr?7jT!T8q>0Q|!_N?-w_RE2|;g+KUj=rDXq|+YpmU+t3OkBG=f{EFO0{213 zJ>gTc??2D)5kDtFC~iXaa?@!_ACO<#Yz!KPq(j)YMMqS|pwT75C0E=GJVj@{Eo^e) zY0ObO^OO+UMA2xmTL2`wTI8goN6QJm$;YA_?IX)9|zR8}g^A>u1cGSSOSqg2MwnF|Zxc2^h$zDJA-~ zOqs@-Lq#@+AD@xn(JAret|1PZc+H(Gg^_mA0kxZZtyCuE?^w^d=^4i6CMP@$#kc3` z<8RRR$1K?~e2{~%-ij~E4ck~Pj-Y4ANFSh zV-AVAYR|6NuJa0b_IBcy91?Su6`>_jL+Z5$Lchs+JNL$gL}^nAU9j+szgDLNfO8`f zlt~1Sud=EqGH@E;`k@?W`9Dym-X@H@Iy;Xpu}QofeRS!LC$Ix=jbf=+WiLn=DiCDv zuN{|a>~4JBGAM*aXTX!3KTww_=ckf-pj#r}0{S|jlYMLsd^rvDl^^t#@&( zAYu1WLf4RmaxeJ`p(FP?4d=?kD`~mv;rxZRswK9NU@c}mvS$CZ$ww^tWVApj+q6<| z0~3|;^vj%ZW!Gd(Y>?~>x3!90_1(yZ2}iPo(L z)8j1i&@0R=?^KPljEOZTR!9Ai@6o^)ak10VI!4Aj&NI9Gbf#ry*<4D;d^gA;fxfw2 zsE)+L0t&)>sIfz_?~Y=~`QmmLrTL0mu3pl92canRG7RsR=RP&J@LgZN z*4*I(L{9UJ1hJeijJ1yM>2N2#AN%(mYcaZFmHWoQ5)aA{MMrut+p2SQoy1ccJ$Z4Y z^!Jdz1g*>=9Fm;>?_?kb$_N0UjzC`6=D+`aPT>0cobW>oek6atd2JM z?FG0M-v{oN-|7&gZPwvWK6ZQV|E6sUkQXM;1#D>nkha+ZKcQ_8@z!rE0%_kz8YFH; m`X|KgGrpC`gaJQJJc!(!_-#lo@KykTfxi{d0WqG { let app: App; @@ -42,6 +43,13 @@ import { IS_EE } from '../../e2e/config/constants'; .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' }); @@ -170,4 +178,67 @@ import { IS_EE } from '../../e2e/config/constants'; expect(response.body.visitor).to.be.null; }); }); + + describe('cross-appId lookup (app reinstallation scenario)', () => { + const addExternalIdToVisitor = async (visitorId: string, externalId: { source: string; entityId: string }) => { + const connection = await MongoClient.connect(URL_MONGODB); + try { + await connection + .db() + .collection('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, { source: oldAppId, entityId }); + + // App searches by entityId only - should find visitor even though source is different + const response = await callResolveVisitor({ entityId }); + + expect(response.status).to.equal(200); + expect(response.body.visitor.id).to.equal(visitor._id); + // Verify the externalId belongs to the old app + const oldExternalId = response.body.visitor.externalIds.find((e: { source: string }) => e.source === oldAppId); + expect(oldExternalId.entityId).to.equal(entityId); + }); + + it('should allow app to add its own externalId using updateVisitorExternalId', async () => { + // Create visitor without phone - we'll use updateVisitorExternalId directly + const visitor = await createVisitorWithCustomData({ ignorePhone: true }); + + const oldAppId = 'old-app-version-id-67890'; + const entityId = `cross-app-update-${Date.now()}`; + await addExternalIdToVisitor(visitor._id, { source: oldAppId, entityId }); + + // First, verify app finds visitor by entityId (from old app) + const lookupResponse = await callResolveVisitor({ entityId }); + expect(lookupResponse.body.visitor.id).to.equal(visitor._id); + + // App detects source is different, uses updateVisitorExternalId to add its own entry + 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); + + // Verify both externalIds exist - old app's and new app's + const oldEntry = updateResponse.body.visitor.externalIds.find((e: { source: string }) => e.source === oldAppId); + const newEntry = updateResponse.body.visitor.externalIds.find((e: { source: string }) => e.source === 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/apps/meteor/tests/unit/app/livechat/server/lib/resolveVisitor.spec.ts b/apps/meteor/tests/unit/app/livechat/server/lib/resolveVisitor.spec.ts index 0d5433073e8d2..ce5533812f272 100644 --- a/apps/meteor/tests/unit/app/livechat/server/lib/resolveVisitor.spec.ts +++ b/apps/meteor/tests/unit/app/livechat/server/lib/resolveVisitor.spec.ts @@ -39,7 +39,7 @@ describe('resolveVisitor', () => { }); expect(result).to.deep.equal(existingVisitor); - expect(modelsMock.LivechatVisitors.findOneByExternalId.calledOnceWith(appId, 'bsuid-123')).to.be.true; + expect(modelsMock.LivechatVisitors.findOneByExternalId.calledOnceWith('bsuid-123')).to.be.true; expect(modelsMock.LivechatVisitors.findOneVisitorByPhoneOrEmailAndAddExternalId.called).to.be.false; }); @@ -103,6 +103,26 @@ describe('resolveVisitor', () => { expect(result).to.deep.equal(updatedVisitor); }); + it('should find visitor by entityId regardless of source (different app version)', async () => { + const differentAppId = 'different-app-id-from-old-version'; + const existingVisitor = { + _id: 'visitor-cross-app', + token: 'token-cross-app', + username: 'guest-cross', + externalIds: [{ source: differentAppId, entityId: 'bsuid-shared' }], + }; + + modelsMock.LivechatVisitors.findOneByExternalId.resolves(existingVisitor); + + const result = await resolveVisitor({ + source: appId, + externalId: { entityId: 'bsuid-shared' }, + }); + + expect(result).to.deep.equal(existingVisitor); + expect(modelsMock.LivechatVisitors.findOneByExternalId.calledOnceWith('bsuid-shared')).to.be.true; + }); + it('should return null when not found by external ID and no contact data provided', async () => { modelsMock.LivechatVisitors.findOneByExternalId.resolves(null); diff --git a/packages/apps-engine/src/definition/accessors/ILivechatUpdater.ts b/packages/apps-engine/src/definition/accessors/ILivechatUpdater.ts index c0ee2cc145bb7..c97db90cf61e9 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 source is automatically set to the calling app's ID. + * If an externalId with the same source 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/server/accessors/LivechatUpdater.ts b/packages/apps-engine/src/server/accessors/LivechatUpdater.ts index c5ee9626b1915..9877bab7b4839 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 66bcabe56aa13..3acc77f953105 100644 --- a/packages/apps-engine/src/server/bridges/LivechatBridge.ts +++ b/packages/apps-engine/src/server/bridges/LivechatBridge.ts @@ -119,6 +119,16 @@ export abstract class LivechatBridge extends BaseBridge { } } + 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); @@ -221,6 +231,12 @@ export abstract class LivechatBridge extends BaseBridge { 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/model-typings/src/models/ILivechatVisitorsModel.ts b/packages/model-typings/src/models/ILivechatVisitorsModel.ts index 4da9bfcdefecf..71ab44a11bed1 100644 --- a/packages/model-typings/src/models/ILivechatVisitorsModel.ts +++ b/packages/model-typings/src/models/ILivechatVisitorsModel.ts @@ -56,7 +56,13 @@ export interface ILivechatVisitorsModel extends IBaseModel { externalId: Omit, ): Promise; - findOneByExternalId(source: string, externalEntityId: string): Promise; + findOneByExternalId(entityId: string): Promise; + + updateExternalIdById( + _id: string, + source: string, + externalId: Omit, + ): Promise; removeDepartmentById(_id: string): Promise; diff --git a/packages/models/src/models/LivechatVisitors.ts b/packages/models/src/models/LivechatVisitors.ts index ac4dcd420f3d7..aeb431e60149a 100644 --- a/packages/models/src/models/LivechatVisitors.ts +++ b/packages/models/src/models/LivechatVisitors.ts @@ -31,7 +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.source': 1, 'externalIds.entityId': 1 }, sparse: true }, + { key: { 'externalIds.entityId': 1 }, sparse: true }, { key: { name: 1 }, sparse: true }, { key: { username: 1 } }, { key: { 'contactMananger.username': 1 }, sparse: true }, @@ -79,12 +79,38 @@ export class LivechatVisitorsRaw extends BaseRaw implements IL ); } - findOneByExternalId(source: string, externalEntityId: string): Promise { + findOneByExternalId(entityId: string): Promise { return this.findOne({ - externalIds: { $elemMatch: { source, entityId: externalEntityId } }, + 'externalIds.entityId': entityId, }); } + updateExternalIdById( + _id: string, + source: string, + externalId: Omit, + ): Promise { + // Use aggregation pipeline update to upsert into the array: + // 1. Filter out any existing entry with the same source + // 2. Add the new entry + return this.findOneAndUpdate( + { _id }, + [ + { + $set: { + externalIds: { + $concatArrays: [ + { $filter: { input: { $ifNull: ['$externalIds', []] }, cond: { $ne: ['$$this.source', source] } } }, + [{ source, ...externalId }], + ], + }, + }, + }, + ], + { returnDocument: 'after' }, + ); + } + async findOneGuestByEmailAddress(emailAddress: string): Promise { if (!emailAddress) { return null; From bb8aabaa8977940fa81bd972e6dbb34df3fa2584 Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Tue, 7 Apr 2026 22:29:24 -0300 Subject: [PATCH 31/41] refactor: rename externalId source field to appId --- .../app/apps/server/bridges/livechat.ts | 10 +++--- .../app/livechat/server/lib/resolveVisitor.ts | 8 ++--- .../ContactInfoChannelsItem.tsx | 2 +- .../end-to-end/apps/app-resolve-visitor.ts | 35 ++++++++----------- .../server/lib/resolveVisitor.spec.ts | 30 ++++++++-------- .../definition/accessors/ILivechatCreator.ts | 2 +- .../definition/accessors/ILivechatUpdater.ts | 6 ++-- .../src/definition/livechat/IVisitor.ts | 2 +- .../src/server/accessors/LivechatUpdater.ts | 2 +- .../src/server/bridges/LivechatBridge.ts | 8 ++--- .../tests/test-data/bridges/livechatBridge.ts | 8 +++++ packages/core-typings/src/ILivechatVisitor.ts | 2 +- .../src/models/ILivechatVisitorsModel.ts | 10 ++---- .../models/src/models/LivechatVisitors.ts | 20 +++++------ 14 files changed, 72 insertions(+), 73 deletions(-) diff --git a/apps/meteor/app/apps/server/bridges/livechat.ts b/apps/meteor/app/apps/server/bridges/livechat.ts index 8ea7848a3b23b..7afd729216a74 100644 --- a/apps/meteor/app/apps/server/bridges/livechat.ts +++ b/apps/meteor/app/apps/server/bridges/livechat.ts @@ -237,8 +237,8 @@ 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 source (appId) to each externalId entry - const externalIds = visitor.externalIds?.map((entry) => ({ ...entry, source: appId })); + // Add appId to each externalId entry + const externalIds = visitor.externalIds?.map((entry) => ({ ...entry, appId })); const registerData = { department: visitor.department, @@ -351,14 +351,14 @@ export class AppLivechatBridge extends LivechatBridge { } protected async resolveVisitor( - externalId: Omit, + 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({ - source: appId, + appId, externalId, contactData, }); @@ -368,7 +368,7 @@ export class AppLivechatBridge extends LivechatBridge { protected async updateVisitorExternalId( visitorId: string, - externalId: Omit, + externalId: Omit, appId: string, ): Promise { this.orch.debugLog(`The App ${appId} is updating externalId for visitor ${visitorId}.`); diff --git a/apps/meteor/app/livechat/server/lib/resolveVisitor.ts b/apps/meteor/app/livechat/server/lib/resolveVisitor.ts index 7c246d4c1bff5..7275f7b4c2e6b 100644 --- a/apps/meteor/app/livechat/server/lib/resolveVisitor.ts +++ b/apps/meteor/app/livechat/server/lib/resolveVisitor.ts @@ -4,19 +4,19 @@ import { LivechatVisitors } from '@rocket.chat/models'; type ResolveVisitorContactData = { phone: string } | { email: string }; type ResolveVisitorParams = { - source: string; - externalId: Omit; + appId: string; + externalId: Omit; contactData?: ResolveVisitorContactData; }; -export async function resolveVisitor({ source, externalId, contactData }: ResolveVisitorParams): Promise { +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, source, externalId); + 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 0b5cbc2478479..938c3583e177a 100644 --- a/apps/meteor/client/views/omnichannel/contactInfo/tabs/ContactInfoChannels/ContactInfoChannelsItem.tsx +++ b/apps/meteor/client/views/omnichannel/contactInfo/tabs/ContactInfoChannels/ContactInfoChannelsItem.tsx @@ -34,7 +34,7 @@ const ContactInfoChannelsItem = ({ const channelLabel = useMemo(() => { const phone = getSourceLabel(details); - const externalId = details?.id ? visitorData?.externalIds?.find((e) => e.source === details.id) : undefined; + const externalId = details?.id ? visitorData?.externalIds?.find((e) => e.appId === details.id) : undefined; const username = externalId?.metadata?.username; if (typeof username === 'string' && phone) { 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 index 757acd803cd44..379e496075072 100644 --- a/apps/meteor/tests/end-to-end/apps/app-resolve-visitor.ts +++ b/apps/meteor/tests/end-to-end/apps/app-resolve-visitor.ts @@ -69,7 +69,7 @@ import { IS_EE, URL_MONGODB } from '../../e2e/config/constants'; expect(response.status).to.equal(200); expect(response.body.visitor.id).to.equal(visitor._id); - const appExternalId = response.body.visitor.externalIds.find((e: { source: string }) => e.source === app.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); }); @@ -86,7 +86,7 @@ import { IS_EE, URL_MONGODB } from '../../e2e/config/constants'; // 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: { source: string }) => e.source === app.id); + const appExternalId = response.body.visitor.externalIds.find((e: { appId: string }) => e.appId === app.id); expect(appExternalId.metadata.username).to.equal('@original'); }); }); @@ -101,7 +101,7 @@ import { IS_EE, URL_MONGODB } from '../../e2e/config/constants'; expect(response.status).to.equal(200); expect(response.body.visitor.id).to.equal(visitor._id); - const appExternalId = response.body.visitor.externalIds.find((e: { source: string }) => e.source === app.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); }); @@ -115,7 +115,7 @@ import { IS_EE, URL_MONGODB } from '../../e2e/config/constants'; const newExternalId = { entityId: `second-${Date.now()}`, metadata: { username: '@second' } }; const response = await callResolveVisitor(newExternalId, { phone }); - const appExternalId = response.body.visitor.externalIds.find((e: { source: string }) => e.source === app.id); + 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'); }); @@ -145,7 +145,7 @@ import { IS_EE, URL_MONGODB } from '../../e2e/config/constants'; expect(response.status).to.equal(200); expect(response.body.visitor.id).to.equal(visitor._id); - const appExternalId = response.body.visitor.externalIds.find((e: { source: string }) => e.source === app.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); }); @@ -159,7 +159,7 @@ import { IS_EE, URL_MONGODB } from '../../e2e/config/constants'; const newExternalId = { entityId: `second-email-${Date.now()}`, metadata: { username: '@second' } }; const response = await callResolveVisitor(newExternalId, { email }); - const appExternalId = response.body.visitor.externalIds.find((e: { source: string }) => e.source === app.id); + 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'); }); @@ -179,8 +179,9 @@ import { IS_EE, URL_MONGODB } from '../../e2e/config/constants'; }); }); - describe('cross-appId lookup (app reinstallation scenario)', () => { - const addExternalIdToVisitor = async (visitorId: string, externalId: { source: string; entityId: string }) => { + 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 @@ -198,44 +199,38 @@ import { IS_EE, URL_MONGODB } from '../../e2e/config/constants'; const oldAppId = 'old-app-version-id-12345'; const entityId = `cross-app-${Date.now()}`; - await addExternalIdToVisitor(visitor._id, { source: oldAppId, entityId }); + await addExternalIdToVisitor(visitor._id, { appId: oldAppId, entityId }); - // App searches by entityId only - should find visitor even though source is different + // 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); - // Verify the externalId belongs to the old app - const oldExternalId = response.body.visitor.externalIds.find((e: { source: string }) => e.source === oldAppId); + 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 () => { - // Create visitor without phone - we'll use updateVisitorExternalId directly const visitor = await createVisitorWithCustomData({ ignorePhone: true }); const oldAppId = 'old-app-version-id-67890'; const entityId = `cross-app-update-${Date.now()}`; - await addExternalIdToVisitor(visitor._id, { source: oldAppId, entityId }); + await addExternalIdToVisitor(visitor._id, { appId: oldAppId, entityId }); - // First, verify app finds visitor by entityId (from old app) const lookupResponse = await callResolveVisitor({ entityId }); expect(lookupResponse.body.visitor.id).to.equal(visitor._id); - // App detects source is different, uses updateVisitorExternalId to add its own entry 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); - // Verify both externalIds exist - old app's and new app's - const oldEntry = updateResponse.body.visitor.externalIds.find((e: { source: string }) => e.source === oldAppId); - const newEntry = updateResponse.body.visitor.externalIds.find((e: { source: string }) => e.source === app.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/apps/meteor/tests/unit/app/livechat/server/lib/resolveVisitor.spec.ts b/apps/meteor/tests/unit/app/livechat/server/lib/resolveVisitor.spec.ts index ce5533812f272..34189d3ddaf5e 100644 --- a/apps/meteor/tests/unit/app/livechat/server/lib/resolveVisitor.spec.ts +++ b/apps/meteor/tests/unit/app/livechat/server/lib/resolveVisitor.spec.ts @@ -27,13 +27,13 @@ describe('resolveVisitor', () => { _id: 'visitor-123', token: 'token-123', username: 'guest-1', - externalIds: [{ source: appId, entityId: 'bsuid-123' }], + externalIds: [{ appId, entityId: 'bsuid-123' }], }; modelsMock.LivechatVisitors.findOneByExternalId.resolves(existingVisitor); const result = await resolveVisitor({ - source: appId, + appId, externalId: { entityId: 'bsuid-123' }, contactData: { phone: '1234567890' }, }); @@ -50,13 +50,13 @@ describe('resolveVisitor', () => { _id: 'visitor-456', token: 'token-456', username: 'guest-2', - externalIds: [{ source: appId, ...externalId }], + externalIds: [{ appId, ...externalId }], }; modelsMock.LivechatVisitors.findOneByExternalId.resolves(null); modelsMock.LivechatVisitors.findOneVisitorByPhoneOrEmailAndAddExternalId.resolves(updatedVisitor); - const result = await resolveVisitor({ source: appId, externalId, contactData }); + const result = await resolveVisitor({ appId, externalId, contactData }); expect(result).to.deep.equal(updatedVisitor); expect(modelsMock.LivechatVisitors.findOneByExternalId.calledOnce).to.be.true; @@ -71,13 +71,13 @@ describe('resolveVisitor', () => { _id: 'visitor-email', token: 'token-email', username: 'guest-email', - externalIds: [{ source: appId, ...externalId }], + externalIds: [{ appId, ...externalId }], }; modelsMock.LivechatVisitors.findOneByExternalId.resolves(null); modelsMock.LivechatVisitors.findOneVisitorByPhoneOrEmailAndAddExternalId.resolves(updatedVisitor); - const result = await resolveVisitor({ source: appId, externalId, contactData }); + const result = await resolveVisitor({ appId, externalId, contactData }); expect(result).to.deep.equal(updatedVisitor); expect(modelsMock.LivechatVisitors.findOneByExternalId.calledOnce).to.be.true; @@ -92,30 +92,30 @@ describe('resolveVisitor', () => { _id: 'visitor-789', token: 'token-789', username: 'guest-3', - externalIds: [{ source: appId, ...newExternalId }], + externalIds: [{ appId, ...newExternalId }], }; modelsMock.LivechatVisitors.findOneByExternalId.resolves(null); modelsMock.LivechatVisitors.findOneVisitorByPhoneOrEmailAndAddExternalId.resolves(updatedVisitor); - const result = await resolveVisitor({ source: appId, externalId: newExternalId, contactData }); + const result = await resolveVisitor({ appId, externalId: newExternalId, contactData }); expect(result).to.deep.equal(updatedVisitor); }); - it('should find visitor by entityId regardless of source (different app version)', async () => { + it('should find visitor by entityId regardless of appId (different app version)', async () => { const differentAppId = 'different-app-id-from-old-version'; const existingVisitor = { _id: 'visitor-cross-app', token: 'token-cross-app', username: 'guest-cross', - externalIds: [{ source: differentAppId, entityId: 'bsuid-shared' }], + externalIds: [{ appId: differentAppId, entityId: 'bsuid-shared' }], }; modelsMock.LivechatVisitors.findOneByExternalId.resolves(existingVisitor); const result = await resolveVisitor({ - source: appId, + appId, externalId: { entityId: 'bsuid-shared' }, }); @@ -126,7 +126,7 @@ describe('resolveVisitor', () => { it('should return null when not found by external ID and no contact data provided', async () => { modelsMock.LivechatVisitors.findOneByExternalId.resolves(null); - const result = await resolveVisitor({ source: appId, externalId: { entityId: 'bsuid-unknown' } }); + const result = await resolveVisitor({ appId, externalId: { entityId: 'bsuid-unknown' } }); expect(result).to.be.null; expect(modelsMock.LivechatVisitors.findOneByExternalId.calledOnce).to.be.true; @@ -138,7 +138,7 @@ describe('resolveVisitor', () => { modelsMock.LivechatVisitors.findOneVisitorByPhoneOrEmailAndAddExternalId.resolves(null); const result = await resolveVisitor({ - source: appId, + appId, externalId: { entityId: 'bsuid-unknown' }, contactData: { phone: '0000000000' }, }); @@ -152,7 +152,7 @@ describe('resolveVisitor', () => { modelsMock.LivechatVisitors.findOneByExternalId.resolves(null); const result = await resolveVisitor({ - source: appId, + appId, externalId: { entityId: 'bsuid-123' }, contactData: { phone: '' }, }); @@ -165,7 +165,7 @@ describe('resolveVisitor', () => { modelsMock.LivechatVisitors.findOneByExternalId.resolves(null); const result = await resolveVisitor({ - source: appId, + appId, externalId: { entityId: 'bsuid-123' }, contactData: { email: '' }, }); diff --git a/packages/apps-engine/src/definition/accessors/ILivechatCreator.ts b/packages/apps-engine/src/definition/accessors/ILivechatCreator.ts index 6d2257a475b87..ef30b4fbb3f85 100644 --- a/packages/apps-engine/src/definition/accessors/ILivechatCreator.ts +++ b/packages/apps-engine/src/definition/accessors/ILivechatCreator.ts @@ -16,7 +16,7 @@ export interface ILivechatCreator { * @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; + 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 c97db90cf61e9..7d2dbbb2aa2f0 100644 --- a/packages/apps-engine/src/definition/accessors/ILivechatUpdater.ts +++ b/packages/apps-engine/src/definition/accessors/ILivechatUpdater.ts @@ -33,8 +33,8 @@ export interface ILivechatUpdater { /** * Updates or adds an external identifier for a visitor. - * The source is automatically set to the calling app's ID. - * If an externalId with the same source already exists, it will be replaced. + * 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 @@ -43,6 +43,6 @@ export interface ILivechatUpdater { */ updateVisitorExternalId( visitorId: string, - externalId: Omit, + externalId: Omit, ): Promise; } diff --git a/packages/apps-engine/src/definition/livechat/IVisitor.ts b/packages/apps-engine/src/definition/livechat/IVisitor.ts index a55c583433172..caf2640395f97 100644 --- a/packages/apps-engine/src/definition/livechat/IVisitor.ts +++ b/packages/apps-engine/src/definition/livechat/IVisitor.ts @@ -2,7 +2,7 @@ import type { IVisitorEmail } from './IVisitorEmail'; import type { IVisitorPhone } from './IVisitorPhone'; export interface IVisitorExternalIdentifier { - source?: string; + appId?: string; entityId: string; metadata?: Record; } diff --git a/packages/apps-engine/src/server/accessors/LivechatUpdater.ts b/packages/apps-engine/src/server/accessors/LivechatUpdater.ts index 9877bab7b4839..e7aa686084185 100644 --- a/packages/apps-engine/src/server/accessors/LivechatUpdater.ts +++ b/packages/apps-engine/src/server/accessors/LivechatUpdater.ts @@ -26,7 +26,7 @@ export class LivechatUpdater implements ILivechatUpdater { public updateVisitorExternalId( visitorId: string, - externalId: Omit, + 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 3acc77f953105..88a7575898049 100644 --- a/packages/apps-engine/src/server/bridges/LivechatBridge.ts +++ b/packages/apps-engine/src/server/bridges/LivechatBridge.ts @@ -104,7 +104,7 @@ export abstract class LivechatBridge extends BaseBridge { } public async doResolveVisitor( - externalId: Omit, + externalId: Omit, contactData: ResolveVisitorContactData | undefined, appId: string, ): Promise { @@ -121,7 +121,7 @@ export abstract class LivechatBridge extends BaseBridge { public async doUpdateVisitorExternalId( visitorId: string, - externalId: Omit, + externalId: Omit, appId: string, ): Promise { if (this.hasWritePermission(appId, 'livechat-visitor')) { @@ -224,7 +224,7 @@ export abstract class LivechatBridge extends BaseBridge { protected abstract findVisitorByPhoneNumber(phoneNumber: string, appId: string): Promise; protected abstract resolveVisitor( - externalId: Omit, + externalId: Omit, contactData: ResolveVisitorContactData | undefined, appId: string, ): Promise; @@ -233,7 +233,7 @@ export abstract class LivechatBridge extends BaseBridge { protected abstract updateVisitorExternalId( visitorId: string, - externalId: Omit, + externalId: Omit, 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 41e847c200803..16ef3330a4d5b 100644 --- a/packages/apps-engine/tests/test-data/bridges/livechatBridge.ts +++ b/packages/apps-engine/tests/test-data/bridges/livechatBridge.ts @@ -78,6 +78,14 @@ export class TestLivechatBridge extends LivechatBridge { 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 02011d55d8c2d..2519fabd1e85b 100644 --- a/packages/core-typings/src/ILivechatVisitor.ts +++ b/packages/core-typings/src/ILivechatVisitor.ts @@ -15,7 +15,7 @@ export interface IVisitorEmail { } export interface IVisitorExternalIdentifier { - source: string; + appId: string; entityId: string; metadata?: Record; } diff --git a/packages/model-typings/src/models/ILivechatVisitorsModel.ts b/packages/model-typings/src/models/ILivechatVisitorsModel.ts index 71ab44a11bed1..8b9dbeae1c88c 100644 --- a/packages/model-typings/src/models/ILivechatVisitorsModel.ts +++ b/packages/model-typings/src/models/ILivechatVisitorsModel.ts @@ -52,17 +52,13 @@ export interface ILivechatVisitorsModel extends IBaseModel { findOneVisitorByPhoneOrEmailAndAddExternalId( contactData: { phone: string } | { email: string }, - source: string, - externalId: Omit, + appId: string, + externalId: Omit, ): Promise; findOneByExternalId(entityId: string): Promise; - updateExternalIdById( - _id: string, - source: string, - externalId: Omit, - ): Promise; + updateExternalIdById(_id: string, appId: string, externalId: Omit): Promise; removeDepartmentById(_id: string): Promise; diff --git a/packages/models/src/models/LivechatVisitors.ts b/packages/models/src/models/LivechatVisitors.ts index aeb431e60149a..7b5dadd9b29a2 100644 --- a/packages/models/src/models/LivechatVisitors.ts +++ b/packages/models/src/models/LivechatVisitors.ts @@ -52,14 +52,14 @@ export class LivechatVisitorsRaw extends BaseRaw implements IL findOneVisitorByPhoneOrEmailAndAddExternalId( contactData: { phone: string } | { email: string }, - source: string, - externalId: Omit, + appId: string, + externalId: Omit, ): Promise { const query = 'phone' in contactData ? { 'phone.phoneNumber': contactData.phone } : { 'visitorEmails.address': contactData.email.toLowerCase() }; // Use aggregation pipeline update to upsert into the array: - // 1. Filter out any existing entry with the same source + // 1. Filter out any existing entry with the same appId // 2. Add the new entry return this.findOneAndUpdate( query, @@ -68,8 +68,8 @@ export class LivechatVisitorsRaw extends BaseRaw implements IL $set: { externalIds: { $concatArrays: [ - { $filter: { input: { $ifNull: ['$externalIds', []] }, cond: { $ne: ['$$this.source', source] } } }, - [{ source, ...externalId }], + { $filter: { input: { $ifNull: ['$externalIds', []] }, cond: { $ne: ['$$this.appId', appId] } } }, + [{ appId, ...externalId }], ], }, }, @@ -87,11 +87,11 @@ export class LivechatVisitorsRaw extends BaseRaw implements IL updateExternalIdById( _id: string, - source: string, - externalId: Omit, + appId: string, + externalId: Omit, ): Promise { // Use aggregation pipeline update to upsert into the array: - // 1. Filter out any existing entry with the same source + // 1. Filter out any existing entry with the same appId // 2. Add the new entry return this.findOneAndUpdate( { _id }, @@ -100,8 +100,8 @@ export class LivechatVisitorsRaw extends BaseRaw implements IL $set: { externalIds: { $concatArrays: [ - { $filter: { input: { $ifNull: ['$externalIds', []] }, cond: { $ne: ['$$this.source', source] } } }, - [{ source, ...externalId }], + { $filter: { input: { $ifNull: ['$externalIds', []] }, cond: { $ne: ['$$this.appId', appId] } } }, + [{ appId, ...externalId }], ], }, }, From 7dbe67b432293f290c26acccf05440af61d5d927 Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Tue, 7 Apr 2026 23:06:29 -0300 Subject: [PATCH 32/41] fix: make appId required on IVisitorExternalIdentifier --- packages/apps-engine/src/definition/livechat/IVisitor.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/apps-engine/src/definition/livechat/IVisitor.ts b/packages/apps-engine/src/definition/livechat/IVisitor.ts index caf2640395f97..ee49ac6361f42 100644 --- a/packages/apps-engine/src/definition/livechat/IVisitor.ts +++ b/packages/apps-engine/src/definition/livechat/IVisitor.ts @@ -2,7 +2,7 @@ import type { IVisitorEmail } from './IVisitorEmail'; import type { IVisitorPhone } from './IVisitorPhone'; export interface IVisitorExternalIdentifier { - appId?: string; + appId: string; entityId: string; metadata?: Record; } From 74734cc8dbb9d6a67dcbcc3bf95f7e2fab6b6f83 Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Tue, 7 Apr 2026 23:07:14 -0300 Subject: [PATCH 33/41] fix: exclude externalIds from ChatTranscript PDF --- ee/packages/pdf-worker/src/strategies/ChatTranscript.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/ee/packages/pdf-worker/src/strategies/ChatTranscript.ts b/ee/packages/pdf-worker/src/strategies/ChatTranscript.ts index cb481690a59dd..48d39f7aa52f2 100644 --- a/ee/packages/pdf-worker/src/strategies/ChatTranscript.ts +++ b/ee/packages/pdf-worker/src/strategies/ChatTranscript.ts @@ -28,6 +28,7 @@ export class ChatTranscript implements IStrategy { visitor: requestData.visitor ? { ...requestData.visitor, + externalIds: undefined, ts: moment(requestData.visitor.ts).tz(timezone).format(timeAndDateFormat), lastChat: requestData.visitor.lastChat ? { From 54f3d2a6b9e74efbb865ac064acda8ba0bf76ec0 Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Tue, 7 Apr 2026 23:07:24 -0300 Subject: [PATCH 34/41] test: remove obsolete resolveVisitor tests --- .../server/lib/resolveVisitor.spec.ts | 176 ------------------ 1 file changed, 176 deletions(-) delete mode 100644 apps/meteor/tests/unit/app/livechat/server/lib/resolveVisitor.spec.ts diff --git a/apps/meteor/tests/unit/app/livechat/server/lib/resolveVisitor.spec.ts b/apps/meteor/tests/unit/app/livechat/server/lib/resolveVisitor.spec.ts deleted file mode 100644 index 34189d3ddaf5e..0000000000000 --- a/apps/meteor/tests/unit/app/livechat/server/lib/resolveVisitor.spec.ts +++ /dev/null @@ -1,176 +0,0 @@ -import { expect } from 'chai'; -import proxyquire from 'proxyquire'; -import sinon from 'sinon'; - -const modelsMock = { - LivechatVisitors: { - findOneByExternalId: sinon.stub(), - findOneVisitorByPhoneOrEmailAndAddExternalId: sinon.stub(), - }, -}; - -const { resolveVisitor } = proxyquire.noCallThru().load('../../../../../../app/livechat/server/lib/resolveVisitor.ts', { - '@rocket.chat/models': modelsMock, -}); - -// Mock app ID (UUID format as used by Rocket.Chat apps) -const appId = 'a1b2c3d4-e5f6-4890-8bcd-ef1234567890'; - -describe('resolveVisitor', () => { - beforeEach(() => { - modelsMock.LivechatVisitors.findOneByExternalId.reset(); - modelsMock.LivechatVisitors.findOneVisitorByPhoneOrEmailAndAddExternalId.reset(); - }); - - it('should return visitor when found by external ID without contact data fallback', async () => { - const existingVisitor = { - _id: 'visitor-123', - token: 'token-123', - username: 'guest-1', - externalIds: [{ appId, entityId: 'bsuid-123' }], - }; - - modelsMock.LivechatVisitors.findOneByExternalId.resolves(existingVisitor); - - const result = await resolveVisitor({ - appId, - externalId: { entityId: 'bsuid-123' }, - contactData: { phone: '1234567890' }, - }); - - expect(result).to.deep.equal(existingVisitor); - expect(modelsMock.LivechatVisitors.findOneByExternalId.calledOnceWith('bsuid-123')).to.be.true; - expect(modelsMock.LivechatVisitors.findOneVisitorByPhoneOrEmailAndAddExternalId.called).to.be.false; - }); - - it('should find by phone, enrich with external ID, and return visitor when not found by external ID', async () => { - const externalId = { entityId: 'bsuid-456', metadata: { username: '@johndoe' } }; - const contactData = { phone: '9876543210' }; - const updatedVisitor = { - _id: 'visitor-456', - token: 'token-456', - username: 'guest-2', - externalIds: [{ appId, ...externalId }], - }; - - modelsMock.LivechatVisitors.findOneByExternalId.resolves(null); - modelsMock.LivechatVisitors.findOneVisitorByPhoneOrEmailAndAddExternalId.resolves(updatedVisitor); - - const result = await resolveVisitor({ appId, externalId, contactData }); - - expect(result).to.deep.equal(updatedVisitor); - expect(modelsMock.LivechatVisitors.findOneByExternalId.calledOnce).to.be.true; - expect(modelsMock.LivechatVisitors.findOneVisitorByPhoneOrEmailAndAddExternalId.calledOnceWith(contactData, appId, externalId)).to.be - .true; - }); - - it('should find by email, enrich with external ID, and return visitor when not found by external ID', async () => { - const externalId = { entityId: 'bsuid-email', metadata: { username: '@emailuser' } }; - const contactData = { email: 'test@example.com' }; - const updatedVisitor = { - _id: 'visitor-email', - token: 'token-email', - username: 'guest-email', - externalIds: [{ appId, ...externalId }], - }; - - modelsMock.LivechatVisitors.findOneByExternalId.resolves(null); - modelsMock.LivechatVisitors.findOneVisitorByPhoneOrEmailAndAddExternalId.resolves(updatedVisitor); - - const result = await resolveVisitor({ appId, externalId, contactData }); - - expect(result).to.deep.equal(updatedVisitor); - expect(modelsMock.LivechatVisitors.findOneByExternalId.calledOnce).to.be.true; - expect(modelsMock.LivechatVisitors.findOneVisitorByPhoneOrEmailAndAddExternalId.calledOnceWith(contactData, appId, externalId)).to.be - .true; - }); - - it('should update existing externalIds when visitor already has some', async () => { - const newExternalId = { entityId: 'bsuid-789', metadata: { username: '@newuser' } }; - const contactData = { phone: '5555555555' }; - const updatedVisitor = { - _id: 'visitor-789', - token: 'token-789', - username: 'guest-3', - externalIds: [{ appId, ...newExternalId }], - }; - - modelsMock.LivechatVisitors.findOneByExternalId.resolves(null); - modelsMock.LivechatVisitors.findOneVisitorByPhoneOrEmailAndAddExternalId.resolves(updatedVisitor); - - const result = await resolveVisitor({ appId, externalId: newExternalId, contactData }); - - expect(result).to.deep.equal(updatedVisitor); - }); - - it('should find visitor by entityId regardless of appId (different app version)', async () => { - const differentAppId = 'different-app-id-from-old-version'; - const existingVisitor = { - _id: 'visitor-cross-app', - token: 'token-cross-app', - username: 'guest-cross', - externalIds: [{ appId: differentAppId, entityId: 'bsuid-shared' }], - }; - - modelsMock.LivechatVisitors.findOneByExternalId.resolves(existingVisitor); - - const result = await resolveVisitor({ - appId, - externalId: { entityId: 'bsuid-shared' }, - }); - - expect(result).to.deep.equal(existingVisitor); - expect(modelsMock.LivechatVisitors.findOneByExternalId.calledOnceWith('bsuid-shared')).to.be.true; - }); - - it('should return null when not found by external ID and no contact data provided', async () => { - modelsMock.LivechatVisitors.findOneByExternalId.resolves(null); - - const result = await resolveVisitor({ appId, externalId: { entityId: 'bsuid-unknown' } }); - - expect(result).to.be.null; - expect(modelsMock.LivechatVisitors.findOneByExternalId.calledOnce).to.be.true; - expect(modelsMock.LivechatVisitors.findOneVisitorByPhoneOrEmailAndAddExternalId.called).to.be.false; - }); - - it('should return null when not found by external ID or contact data', async () => { - modelsMock.LivechatVisitors.findOneByExternalId.resolves(null); - modelsMock.LivechatVisitors.findOneVisitorByPhoneOrEmailAndAddExternalId.resolves(null); - - const result = await resolveVisitor({ - appId, - externalId: { entityId: 'bsuid-unknown' }, - contactData: { phone: '0000000000' }, - }); - - expect(result).to.be.null; - expect(modelsMock.LivechatVisitors.findOneByExternalId.calledOnce).to.be.true; - expect(modelsMock.LivechatVisitors.findOneVisitorByPhoneOrEmailAndAddExternalId.calledOnce).to.be.true; - }); - - it('should not attempt lookup when phone is empty string', async () => { - modelsMock.LivechatVisitors.findOneByExternalId.resolves(null); - - const result = await resolveVisitor({ - appId, - externalId: { entityId: 'bsuid-123' }, - contactData: { phone: '' }, - }); - - expect(result).to.be.null; - expect(modelsMock.LivechatVisitors.findOneVisitorByPhoneOrEmailAndAddExternalId.called).to.be.false; - }); - - it('should not attempt lookup when email is empty string', async () => { - modelsMock.LivechatVisitors.findOneByExternalId.resolves(null); - - const result = await resolveVisitor({ - appId, - externalId: { entityId: 'bsuid-123' }, - contactData: { email: '' }, - }); - - expect(result).to.be.null; - expect(modelsMock.LivechatVisitors.findOneVisitorByPhoneOrEmailAndAddExternalId.called).to.be.false; - }); -}); From 8d2988902a463f9400e0c867a0708cc19c16e151 Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Tue, 7 Apr 2026 23:47:59 -0300 Subject: [PATCH 35/41] fix: ensure externalIds queries are DocumentDB compatible --- .../models/src/models/LivechatVisitors.ts | 64 +++++++------------ 1 file changed, 24 insertions(+), 40 deletions(-) diff --git a/packages/models/src/models/LivechatVisitors.ts b/packages/models/src/models/LivechatVisitors.ts index 7b5dadd9b29a2..197835d30d9bd 100644 --- a/packages/models/src/models/LivechatVisitors.ts +++ b/packages/models/src/models/LivechatVisitors.ts @@ -50,7 +50,7 @@ export class LivechatVisitorsRaw extends BaseRaw implements IL return this.findOne(query); } - findOneVisitorByPhoneOrEmailAndAddExternalId( + async findOneVisitorByPhoneOrEmailAndAddExternalId( contactData: { phone: string } | { email: string }, appId: string, externalId: Omit, @@ -58,25 +58,17 @@ export class LivechatVisitorsRaw extends BaseRaw implements IL const query = 'phone' in contactData ? { 'phone.phoneNumber': contactData.phone } : { 'visitorEmails.address': contactData.email.toLowerCase() }; - // Use aggregation pipeline update to upsert into the array: - // 1. Filter out any existing entry with the same appId - // 2. Add the new entry - return this.findOneAndUpdate( - query, - [ - { - $set: { - externalIds: { - $concatArrays: [ - { $filter: { input: { $ifNull: ['$externalIds', []] }, cond: { $ne: ['$$this.appId', appId] } } }, - [{ appId, ...externalId }], - ], - }, - }, - }, - ], - { returnDocument: 'after' }, - ); + 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 { @@ -85,30 +77,22 @@ export class LivechatVisitorsRaw extends BaseRaw implements IL }); } - updateExternalIdById( + async updateExternalIdById( _id: string, appId: string, externalId: Omit, ): Promise { - // Use aggregation pipeline update to upsert into the array: - // 1. Filter out any existing entry with the same appId - // 2. Add the new entry - return this.findOneAndUpdate( - { _id }, - [ - { - $set: { - externalIds: { - $concatArrays: [ - { $filter: { input: { $ifNull: ['$externalIds', []] }, cond: { $ne: ['$$this.appId', appId] } } }, - [{ appId, ...externalId }], - ], - }, - }, - }, - ], - { returnDocument: 'after' }, - ); + 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 { From a9f9ddd4260273bf61d1f378f1ea614732c97d61 Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Tue, 7 Apr 2026 23:58:59 -0300 Subject: [PATCH 36/41] chore: revert unrelated eslint auto-fix in livechat bridge --- apps/meteor/app/apps/server/bridges/livechat.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/apps/meteor/app/apps/server/bridges/livechat.ts b/apps/meteor/app/apps/server/bridges/livechat.ts index 7afd729216a74..ab0faf0b2c3b4 100644 --- a/apps/meteor/app/apps/server/bridges/livechat.ts +++ b/apps/meteor/app/apps/server/bridges/livechat.ts @@ -129,12 +129,13 @@ export class AppLivechatBridge extends LivechatBridge { type: OmnichannelSourceType.APP, id: appId, alias: this.orch.getManager()?.getOneById(appId)?.getName(), - ...(source?.type === 'app' && { - sidebarIcon: source.sidebarIcon, - defaultIcon: source.defaultIcon, - label: source.label, - destination: source.destination, - }), + ...(source && + source.type === 'app' && { + sidebarIcon: source.sidebarIcon, + defaultIcon: source.defaultIcon, + label: source.label, + destination: source.destination, + }), }, }, agent: agentRoom, From a684a2547b1f96be4c611bbad799cbb8f334e0f1 Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Wed, 8 Apr 2026 20:15:10 -0300 Subject: [PATCH 37/41] fix: add missing core-typings dependency --- packages/models/package.json | 1 + yarn.lock | 1 + 2 files changed, 2 insertions(+) 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/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:^" From 71acfcca0f42aea314348e020b7c689c16de5d70 Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Wed, 8 Apr 2026 21:24:46 -0300 Subject: [PATCH 38/41] fix: skip type check for unserializable externalIds.metadata --- ee/packages/pdf-worker/src/strategies/ChatTranscript.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ee/packages/pdf-worker/src/strategies/ChatTranscript.ts b/ee/packages/pdf-worker/src/strategies/ChatTranscript.ts index 48d39f7aa52f2..1a0a1961f132d 100644 --- a/ee/packages/pdf-worker/src/strategies/ChatTranscript.ts +++ b/ee/packages/pdf-worker/src/strategies/ChatTranscript.ts @@ -28,7 +28,6 @@ export class ChatTranscript implements IStrategy { visitor: requestData.visitor ? { ...requestData.visitor, - externalIds: undefined, ts: moment(requestData.visitor.ts).tz(timezone).format(timeAndDateFormat), lastChat: requestData.visitor.lastChat ? { @@ -58,6 +57,9 @@ export class ChatTranscript implements IStrategy { ...rest, ts: formattedTs, quotes: formattedQuotes, + // @ts-expect-error - Serialized transforms Record to Record + // because unknown is not a serializable primitive. This affects externalIds.metadata in the visitor. + // The field is not used in PDF rendering, so this type mismatch is safe to ignore. requestData: formattedRequestData, webRtcCallEndTs: formattedWebRtcCallEndTs, ...(isDivider && { divider: moment(ts).tz(timezone).format(dateFormat) }), From 1783c26a68bd8aa091c30b1ebfc2cf34bd7857a9 Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Wed, 8 Apr 2026 22:34:34 -0300 Subject: [PATCH 39/41] fix: resolveVisitor test app and MongoDB collection name --- .../app-packages/external-id-test_0.0.1.zip | Bin 2866 -> 2517 bytes .../end-to-end/apps/app-resolve-visitor.ts | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) 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 index c5c43ce4bfbe04da5499d46f346a608e18e6cf84..870c14e63b418b857c5459b32afab7e499f20add 100644 GIT binary patch literal 2517 zcma)8dpOg39R3+|E0=SS!qf?M6eg;(a3ni(r>V)XR+!61n`}ZxQO1feMCCq?IFzuG z&=Ektyqk4 z#6j065dg>&1pq}r4M2oYyaI{t{?<5@7nyP(AV7~mc0x%3qU0qGo+3O!A?pAUv3cPy zpHNZ{*EeUDd+svMFndKcAURlKRIn)P*lUat;TF?WHF1;5zfv~H$k~t1`5l=lrxbXM zA2WGr*yNYF1EFbJDE{3pt?3?y%PK%cRVTd>?bcL| zr%)RLOIT^o=QXBfcg?IUrcobU7?DAKp{H~2-oR#|ILkskHwpDTDTVf%!hRZ*Se0=cLs)d44>#at83&TfcoWX&4&a1x(28Opi^y?uU# zS}9fLt3|c)=x7ZiWo-}LD#rJf8JnDX_gYouh!U~1;b50U992hP`o0-uUdP=MKIc@t z3;IGV{)WPhv!3R@%9H+fsO%)_z>u@;<#6h7@ldH}h)Db$B@;KFCS#AzrY%Vcm7#jK zV)rxR(IZgiPtTK5xqjGJ^5ZH44s$wTE|*J#TWs-lRXO0r&9M4+lB{h}XPTSBES$T{ zaOM}4d?55z6Ovzr{9wbZCycPzQDubitne?^B*JV&-Zcet>wV4Sk-VAC*Y ztRo`#jMI5ZTsoeC6B)85$M^qGws*<3A`5hC9YW4@<&6N)V|NQs-B~gD;R|=dOv>6LM833! zd#Vxcsn8lWVgR5jw1!gv&Yj}*x#9o6IY>QbtLCr^r)w0bR&6uSGt=B*2DZ6taWR;o zQ+2PU-QTx&;lAN52v4^ZbxVS0d~Bxe+R4k3k+5e`6%WVxj}GdfH(}gjvYB!lL$inN z1xa{mE+KmE@immk-e|YJ34<4Eux;lFecGW~>-Vw*@ zSVRXm=3E5PsrhW%H;AQ7|3;HANcfv|E=q1~h7eF@9#4|#V&7UmPvjJMC*N=_`{=m6 zuiCtbK4i`RahuQeNTg5wsVv@i%|%j;sIr6)`7DU6R+7H6%|w;;)A4k%$?=g=Ev4P0 z=(jL-XJMa5wyPm=>u(E1_!()*EZF|an8w3scUyB)4T52`A8G0|^rhS9>2`0I0}#G9<3jMWXu)8;~MDA+SL+Gt{XTRzNQ)X zPeW6nev57-WbMANI}6oIfvlRPz02G07wkz>*SFX_rvG{$-iJ0l9{VU>-}Cxw<=+%SyNo^LxwX?>O(1=Dd67Ja*^mPhTP`tM#$-C(Z=t+WOhW zWekO9hq}|k6IeUpnQboH-p(WA=h7Q+hNHt=_Sp`O4Y_Zmp)16o)<1QMkM08Y?RNT6 zH8!$2*+Jl_5s5`(GLxQS;%IzS+!ItV+Pv%mwvTYl#AWdTa&l^TA0f3L@vi=Quz+8k zSl7TjnhrtVjkkJH8}v3J`_lD==;~*#@D->*RLpCw2@Ve`hkDG=CG{y(r6?~yDjyUV zH=;3Lu1GL#CNP9OVBMft7xd0S3}_Dw;T!%_8K95XC7c1ly{fhc3sHN zAanTW@t?pDFz^IML_DJ|FjBh z6kY@XgcbnuBUj%(&xy5*0r;=Q;43G5jr?UOtwrt>7WkKl`hW0W4z{)Mlfo4I*D?3s z1-|WuwFQ*Lfi=A$MEje*Sj)z@QvZdv%5Z*UtOT$|cp=)V@ZWF?7S;p+u<*GpWKT+R G_3KZq4j(!I literal 2866 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 O3OpPDh5Q^FU;hA&R+Rey 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 index 379e496075072..ef558045e3a7b 100644 --- a/apps/meteor/tests/end-to-end/apps/app-resolve-visitor.ts +++ b/apps/meteor/tests/end-to-end/apps/app-resolve-visitor.ts @@ -186,7 +186,7 @@ import { IS_EE, URL_MONGODB } from '../../e2e/config/constants'; try { await connection .db() - .collection('livechat_visitor') + .collection('rocketchat_livechat_visitor') .updateOne({ _id: visitorId }, { $push: { externalIds: externalId } }); } finally { await connection.close(); From 3b82c913f0565be4deabdc3fb723fed192fd10cd Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Mon, 13 Apr 2026 13:56:07 -0300 Subject: [PATCH 40/41] refactor: rename and simplify contact identifier in ContactField --- .../views/omnichannel/directory/components/ContactField.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/meteor/client/views/omnichannel/directory/components/ContactField.tsx b/apps/meteor/client/views/omnichannel/directory/components/ContactField.tsx index ae5ed5b92906d..132da320e08a9 100644 --- a/apps/meteor/client/views/omnichannel/directory/components/ContactField.tsx +++ b/apps/meteor/client/views/omnichannel/directory/components/ContactField.tsx @@ -43,14 +43,14 @@ const ContactField = ({ contact, room }: ContactFieldProps) => { const displayName = name || username; const phoneNumber = phone?.[0]?.phoneNumber; - const shortName = username && phoneNumber && username !== phoneNumber ? `${username} · ${phoneNumber}` : username || phoneNumber; + const contactIdentifier = [...new Set([username, phoneNumber])].filter(Boolean).join(' · '); return ( - } /> + } /> ); From 78df16bdd10de7e83eb2cfd35df5563ceb0f3b1e Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Mon, 13 Apr 2026 16:37:08 -0300 Subject: [PATCH 41/41] fix: transform externalIds.metadata to match Serialized type in PDF worker --- ee/packages/pdf-worker/src/strategies/ChatTranscript.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/ee/packages/pdf-worker/src/strategies/ChatTranscript.ts b/ee/packages/pdf-worker/src/strategies/ChatTranscript.ts index 1a0a1961f132d..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, @@ -57,9 +61,6 @@ export class ChatTranscript implements IStrategy { ...rest, ts: formattedTs, quotes: formattedQuotes, - // @ts-expect-error - Serialized transforms Record to Record - // because unknown is not a serializable primitive. This affects externalIds.metadata in the visitor. - // The field is not used in PDF rendering, so this type mismatch is safe to ignore. requestData: formattedRequestData, webRtcCallEndTs: formattedWebRtcCallEndTs, ...(isDivider && { divider: moment(ts).tz(timezone).format(dateFormat) }),