Skip to content

Commit

Permalink
feat: add contact channels (#33308)
Browse files Browse the repository at this point in the history
  • Loading branch information
tapiarafael authored Oct 14, 2024
1 parent 3c05136 commit 8f71f78
Show file tree
Hide file tree
Showing 12 changed files with 161 additions and 7 deletions.
1 change: 1 addition & 0 deletions apps/meteor/app/apps/server/bridges/livechat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ export class AppLivechatBridge extends LivechatBridge {
sidebarIcon: source.sidebarIcon,
defaultIcon: source.defaultIcon,
label: source.label,
destination: source.destination,
}),
},
},
Expand Down
2 changes: 2 additions & 0 deletions apps/meteor/app/apps/server/converters/visitors.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export class AppVisitorsConverter {
visitorEmails: 'visitorEmails',
livechatData: 'livechatData',
status: 'status',
contactId: 'contactId',
};

return transformMappedData(visitor, map);
Expand All @@ -54,6 +55,7 @@ export class AppVisitorsConverter {
phone: visitor.phone,
livechatData: visitor.livechatData,
status: visitor.status || 'online',
contactId: visitor.contactId,
...(visitor.visitorEmails && { visitorEmails: visitor.visitorEmails }),
...(visitor.department && { department: visitor.department }),
};
Expand Down
6 changes: 5 additions & 1 deletion apps/meteor/app/livechat/imports/server/rest/sms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,13 +121,17 @@ API.v1.addRoute('livechat/sms-incoming/:service', {
return API.v1.success(SMSService.error(new Error('Invalid visitor')));
}

const roomInfo = {
const roomInfo: {
source?: IOmnichannelRoom['source'];
[key: string]: unknown;
} = {
sms: {
from: sms.to,
},
source: {
type: OmnichannelSourceType.SMS,
alias: service,
destination: sms.to,
},
};

Expand Down
4 changes: 3 additions & 1 deletion apps/meteor/app/livechat/server/api/v1/room.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,9 @@ API.v1.addRoute(

const roomInfo = {
source: {
type: isWidget(this.request.headers) ? OmnichannelSourceType.WIDGET : OmnichannelSourceType.API,
...(isWidget(this.request.headers)
? { type: OmnichannelSourceType.WIDGET, destination: this.request.headers.host }
: { type: OmnichannelSourceType.API }),
},
};

Expand Down
30 changes: 30 additions & 0 deletions apps/meteor/app/livechat/server/lib/Contacts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type {
IOmnichannelRoom,
IUser,
} from '@rocket.chat/core-typings';
import type { InsertionModel } from '@rocket.chat/model-typings';
import {
LivechatVisitors,
Users,
Expand Down Expand Up @@ -183,6 +184,35 @@ export function isSingleContactEnabled(): boolean {
return process.env.TEST_MODE?.toUpperCase() === 'TRUE';
}

export async function createContactFromVisitor(visitor: ILivechatVisitor): Promise<string> {
if (visitor.contactId) {
throw new Error('error-contact-already-exists');
}

const contactData: InsertionModel<ILivechatContact> = {
name: visitor.name || visitor.username,
emails: visitor.visitorEmails?.map(({ address }) => address),
phones: visitor.phone?.map(({ phoneNumber }) => phoneNumber),
unknown: true,
channels: [],
customFields: visitor.livechatData,
createdAt: new Date(),
};

if (visitor.contactManager) {
const contactManagerId = await Users.findOneByUsername<Pick<IUser, '_id'>>(visitor.contactManager.username, { projection: { _id: 1 } });
if (contactManagerId) {
contactData.contactManager = contactManagerId._id;
}
}

const { insertedId: contactId } = await LivechatContacts.insertOne(contactData);

await LivechatVisitors.updateOne({ _id: visitor._id }, { $set: { contactId } });

return contactId;
}

export async function createContact(params: CreateContactParams): Promise<string> {
const { name, emails, phones, customFields: receivedCustomFields = {}, contactManager, channels, unknown } = params;

Expand Down
57 changes: 54 additions & 3 deletions apps/meteor/app/livechat/server/lib/LivechatTyped.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,11 @@ import type {
IOmnichannelAgent,
ILivechatDepartmentAgents,
LivechatDepartmentDTO,
OmnichannelSourceType,
ILivechatInquiryRecord,
ILivechatContact,
ILivechatContactChannel,
} from '@rocket.chat/core-typings';
import { ILivechatAgentStatus, UserStatus, isOmnichannelRoom } from '@rocket.chat/core-typings';
import { OmnichannelSourceType, ILivechatAgentStatus, UserStatus, isOmnichannelRoom } from '@rocket.chat/core-typings';
import { Logger, type MainLogger } from '@rocket.chat/logger';
import {
LivechatDepartment,
Expand All @@ -37,6 +38,7 @@ import {
ReadReceipts,
Rooms,
LivechatCustomField,
LivechatContacts,
} from '@rocket.chat/models';
import { serverFetch as fetch } from '@rocket.chat/server-fetch';
import { Match, check } from 'meteor/check';
Expand Down Expand Up @@ -71,7 +73,7 @@ import * as Mailer from '../../../mailer/server/api';
import { metrics } from '../../../metrics/server';
import { settings } from '../../../settings/server';
import { businessHourManager } from '../business-hour';
import { createContact, isSingleContactEnabled } from './Contacts';
import { createContact, createContactFromVisitor, isSingleContactEnabled } from './Contacts';
import { parseAgentCustomFields, updateDepartmentAgents, validateEmail, normalizeTransferredByData } from './Helper';
import { QueueManager } from './QueueManager';
import { RoutingManager } from './RoutingManager';
Expand Down Expand Up @@ -459,6 +461,55 @@ class LivechatClass {
extraData,
});

if (isSingleContactEnabled()) {
let { contactId } = visitor;

if (!contactId) {
const visitorContact = await LivechatVisitors.findOne<
Pick<ILivechatVisitor, 'name' | 'contactManager' | 'livechatData' | 'phone' | 'visitorEmails' | 'username' | 'contactId'>
>(visitor._id, {
projection: {
name: 1,
contactManager: 1,
livechatData: 1,
phone: 1,
visitorEmails: 1,
username: 1,
contactId: 1,
},
});

contactId = visitorContact?.contactId;
}

if (!contactId) {
// ensure that old visitors have a contact
contactId = await createContactFromVisitor(visitor);
}

const contact = await LivechatContacts.findOneById<Pick<ILivechatContact, '_id' | 'channels'>>(contactId, {
projection: { _id: 1, channels: 1 },
});

if (contact) {
const channel = contact.channels?.find(
(channel: ILivechatContactChannel) => channel.name === roomInfo.source?.type && channel.visitorId === visitor._id,
);

if (!channel) {
Livechat.logger.debug(`Adding channel for contact ${contact._id}`);

await LivechatContacts.addChannel(contact._id, {
name: roomInfo.source?.label || roomInfo.source?.type.toString() || OmnichannelSourceType.OTHER,
visitorId: visitor._id,
blocked: false,
verified: false,
details: roomInfo.source,
});
}
}
}

Livechat.logger.debug(`Room obtained for visitor ${visitor._id} -> ${room._id}`);

await Messages.setRoomIdByToken(visitor.token, room._id);
Expand Down
6 changes: 5 additions & 1 deletion apps/meteor/server/models/raw/LivechatContacts.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { ILivechatContact, RocketChatRecordDeleted } from '@rocket.chat/core-typings';
import type { ILivechatContact, ILivechatContactChannel, RocketChatRecordDeleted } from '@rocket.chat/core-typings';
import type { FindPaginated, ILivechatContactsModel } from '@rocket.chat/model-typings';
import { escapeRegExp } from '@rocket.chat/string-helpers';
import type { Collection, Db, RootFilterOperators, Filter, FindOptions, FindCursor, IndexDescription, UpdateResult } from 'mongodb';
Expand Down Expand Up @@ -61,6 +61,10 @@ export class LivechatContactsRaw extends BaseRaw<ILivechatContact> implements IL
);
}

async addChannel(contactId: string, channel: ILivechatContactChannel): Promise<void> {
await this.updateOne({ _id: contactId }, { $push: { channels: channel } });
}

updateLastChatById(contactId: string, lastChat: ILivechatContact['lastChat']): Promise<UpdateResult> {
return this.updateOne({ _id: contactId }, { $set: { lastChat } });
}
Expand Down
48 changes: 48 additions & 0 deletions apps/meteor/tests/end-to-end/api/livechat/contacts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -761,4 +761,52 @@ describe('LIVECHAT - contacts', () => {
});
});
});

describe('Contact Channels', () => {
let visitor: ILivechatVisitor;

beforeEach(async () => {
visitor = await createVisitor();
});

afterEach(async () => {
await deleteVisitor(visitor.token);
});

it('should add a channel to a contact when creating a new room', async () => {
await request.get(api('livechat/room')).query({ token: visitor.token });

const res = await request.get(api(`omnichannel/contacts.get`)).set(credentials).query({ contactId: visitor.contactId });

expect(res.status).to.be.equal(200);
expect(res.body).to.have.property('success', true);
expect(res.body.contact.channels).to.be.an('array');
expect(res.body.contact.channels.length).to.be.equal(1);
expect(res.body.contact.channels[0].name).to.be.equal('api');
expect(res.body.contact.channels[0].verified).to.be.false;
expect(res.body.contact.channels[0].blocked).to.be.false;
expect(res.body.contact.channels[0].visitorId).to.be.equal(visitor._id);
});

it('should not add a channel if visitor already has one with same type', async () => {
const roomResult = await request.get(api('livechat/room')).query({ token: visitor.token });

const res = await request.get(api(`omnichannel/contacts.get`)).set(credentials).query({ contactId: visitor.contactId });

expect(res.status).to.be.equal(200);
expect(res.body).to.have.property('success', true);
expect(res.body.contact.channels).to.be.an('array');
expect(res.body.contact.channels.length).to.be.equal(1);

await closeOmnichannelRoom(roomResult.body.room._id);
await request.get(api('livechat/room')).query({ token: visitor.token });

const secondResponse = await request.get(api(`omnichannel/contacts.get`)).set(credentials).query({ contactId: visitor.contactId });

expect(secondResponse.status).to.be.equal(200);
expect(secondResponse.body).to.have.property('success', true);
expect(secondResponse.body.contact.channels).to.be.an('array');
expect(secondResponse.body.contact.channels.length).to.be.equal(1);
});
});
});
2 changes: 2 additions & 0 deletions packages/apps-engine/src/definition/livechat/ILivechatRoom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ interface IOmnichannelSourceApp {
label?: string;
sidebarIcon?: string;
defaultIcon?: string;
// The destination of the message (e.g widget host, email address, whatsapp number, etc)
destination?: string;
}
type OmnichannelSource =
| {
Expand Down
6 changes: 6 additions & 0 deletions packages/core-typings/src/ILivechatContact.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
import type { IRocketChatRecord } from './IRocketChatRecord';
import type { IOmnichannelSource } from './IRoom';

export interface ILivechatContactChannel {
name: string;
verified: boolean;
visitorId: string;
blocked: boolean;
field?: string;
value?: string;
verifiedAt?: Date;
details?: IOmnichannelSource;
}

export interface ILivechatContactConflictingField {
Expand Down
3 changes: 3 additions & 0 deletions packages/core-typings/src/IRoom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,8 @@ export interface IOmnichannelSource {
sidebarIcon?: string;
// The default sidebar icon
defaultIcon?: string;
// The destination of the message (e.g widget host, email address, whatsapp number, etc)
destination?: string;
}

export interface IOmnichannelSourceFromApp extends IOmnichannelSource {
Expand All @@ -189,6 +191,7 @@ export interface IOmnichannelSourceFromApp extends IOmnichannelSource {
sidebarIcon?: string;
defaultIcon?: string;
alias?: string;
destination?: string;
}

export interface IOmnichannelGenericRoom extends Omit<IRoom, 'default' | 'featured' | 'broadcast'> {
Expand Down
3 changes: 2 additions & 1 deletion packages/model-typings/src/models/ILivechatContactsModel.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import type { ILivechatContact } from '@rocket.chat/core-typings';
import type { ILivechatContact, ILivechatContactChannel } from '@rocket.chat/core-typings';
import type { FindCursor, FindOptions, UpdateResult } from 'mongodb';

import type { FindPaginated, IBaseModel } from './IBaseModel';

export interface ILivechatContactsModel extends IBaseModel<ILivechatContact> {
updateContact(contactId: string, data: Partial<ILivechatContact>): Promise<ILivechatContact>;
addChannel(contactId: string, channel: ILivechatContactChannel): Promise<void>;
findPaginatedContacts(searchText?: string, options?: FindOptions): FindPaginated<FindCursor<ILivechatContact>>;
updateLastChatById(contactId: string, lastChat: ILivechatContact['lastChat']): Promise<UpdateResult>;
}

0 comments on commit 8f71f78

Please sign in to comment.