diff --git a/.changeset/bright-humans-cross.md b/.changeset/bright-humans-cross.md new file mode 100644 index 000000000000..aa0c4c658994 --- /dev/null +++ b/.changeset/bright-humans-cross.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': minor +--- + +Federation actions like sending message in a federated DM, reacting in a federated chat, etc, will no longer work if the configuration is invalid. diff --git a/.changeset/cool-rocks-remember.md b/.changeset/cool-rocks-remember.md new file mode 100644 index 000000000000..97af36e94320 --- /dev/null +++ b/.changeset/cool-rocks-remember.md @@ -0,0 +1,6 @@ +--- +"@rocket.chat/meteor": patch +"@rocket.chat/model-typings": patch +--- + +Fixed login with third-party apps not working without the "Manage OAuth Apps" permission diff --git a/.changeset/giant-spiders-pay.md b/.changeset/giant-spiders-pay.md new file mode 100644 index 000000000000..1798cd2baaee --- /dev/null +++ b/.changeset/giant-spiders-pay.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Fixes an issue where the Announcement modal with long words was adding a horizontal scrollbar diff --git a/.changeset/kind-drinks-joke.md b/.changeset/kind-drinks-joke.md deleted file mode 100644 index b235f5556805..000000000000 --- a/.changeset/kind-drinks-joke.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@rocket.chat/meteor': patch ---- - -Fixed issue with asterisk-wrapped text not becoming bold when user enters profile custom status. diff --git a/.changeset/nasty-windows-smile.md b/.changeset/nasty-windows-smile.md new file mode 100644 index 000000000000..e80ec3db27a9 --- /dev/null +++ b/.changeset/nasty-windows-smile.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Allow apps to react/unreact to messages via bridge diff --git a/.changeset/ninety-hounds-exist.md b/.changeset/ninety-hounds-exist.md new file mode 100644 index 000000000000..99882de12018 --- /dev/null +++ b/.changeset/ninety-hounds-exist.md @@ -0,0 +1,7 @@ +--- +'@rocket.chat/rest-typings': patch +'@rocket.chat/meteor': patch +'@rocket.chat/i18n': patch +--- + +Fix: Show correct user info actions for non-members in channels. diff --git a/.changeset/purple-dolls-serve.md b/.changeset/purple-dolls-serve.md new file mode 100644 index 000000000000..fc44faa60a38 --- /dev/null +++ b/.changeset/purple-dolls-serve.md @@ -0,0 +1,7 @@ +--- +'@rocket.chat/web-ui-registration': patch +'@rocket.chat/i18n': patch +'@rocket.chat/meteor': patch +--- + +Fixes an issue where creating a new user with an invalid username (containing special characters) resulted in an error message, but the user was still created. The user creation process now properly aborts when an invalid username is provided. diff --git a/.changeset/rich-pillows-hang.md b/.changeset/rich-pillows-hang.md new file mode 100644 index 000000000000..b714a5e6acd9 --- /dev/null +++ b/.changeset/rich-pillows-hang.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Fixes the `expanded` prop being accidentally forwarded to `ContextualbarHeader` diff --git a/.changeset/rotten-camels-pretend.md b/.changeset/rotten-camels-pretend.md new file mode 100644 index 000000000000..5145bbaa5050 --- /dev/null +++ b/.changeset/rotten-camels-pretend.md @@ -0,0 +1,6 @@ +--- +"@rocket.chat/meteor": patch +"@rocket.chat/core-typings": patch +--- + +Fixed issue with system messages being counted as agents' first responses in livechat rooms (which caused the "best first response time" and "average first response time" metrics to be unreliable for all agents) diff --git a/.changeset/six-beers-fry.md b/.changeset/six-beers-fry.md new file mode 100644 index 000000000000..48409c2f8de5 --- /dev/null +++ b/.changeset/six-beers-fry.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': minor +--- + +New button added to validate Matrix Federation configuration. A new field inside admin settings will reflect the configuration status being either 'Valid' or 'Invalid'. diff --git a/.changeset/sixty-spoons-own.md b/.changeset/sixty-spoons-own.md new file mode 100644 index 000000000000..0b717c3965ef --- /dev/null +++ b/.changeset/sixty-spoons-own.md @@ -0,0 +1,9 @@ +--- +"@rocket.chat/meteor": minor +"@rocket.chat/core-typings": minor +"@rocket.chat/model-typings": minor +"@rocket.chat/models": minor +"@rocket.chat/rest-typings": minor +--- + +Introduced "create contacts" endpoint to omnichannel diff --git a/.changeset/smart-mice-attack.md b/.changeset/smart-mice-attack.md new file mode 100644 index 000000000000..3ca47060ce5c --- /dev/null +++ b/.changeset/smart-mice-attack.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Fixed an issue where teams were being created with no room associated with it. diff --git a/.changeset/strong-terms-love.md b/.changeset/strong-terms-love.md new file mode 100644 index 000000000000..2535a466eb8e --- /dev/null +++ b/.changeset/strong-terms-love.md @@ -0,0 +1,6 @@ +--- +"@rocket.chat/meteor": patch +"@rocket.chat/model-typings": patch +--- + +Fixed issue with livechat analytics in a given date range considering conversation data from the following day diff --git a/.changeset/stupid-fishes-relate.md b/.changeset/stupid-fishes-relate.md new file mode 100644 index 000000000000..82bfaa1cfd28 --- /dev/null +++ b/.changeset/stupid-fishes-relate.md @@ -0,0 +1,7 @@ +--- +'@rocket.chat/core-typings': minor +'@rocket.chat/i18n': minor +'@rocket.chat/meteor': minor +--- + +Added a new setting to enable/disable file encryption in an end to end encrypted room. diff --git a/.changeset/two-bikes-crash.md b/.changeset/two-bikes-crash.md new file mode 100644 index 000000000000..a120435e4a48 --- /dev/null +++ b/.changeset/two-bikes-crash.md @@ -0,0 +1,7 @@ +--- +'@rocket.chat/meteor': patch +--- + +Fixed an issue related to setting Accounts_ForgetUserSessionOnWindowClose, this setting was not working as expected. + +The new meteor 2.16 release introduced a new option to configure the Accounts package and choose between the local storage or session storage. They also changed how Meteor.\_localstorage works internally. Due to these changes in Meteor, our setting to use session storage wasn't working as expected. This PR fixes this issue and configures the Accounts package according to the workspace settings. diff --git a/.changeset/violet-radios-begin.md b/.changeset/violet-radios-begin.md new file mode 100644 index 000000000000..d11f23b47478 --- /dev/null +++ b/.changeset/violet-radios-begin.md @@ -0,0 +1,15 @@ +--- +'@rocket.chat/core-typings': minor +'@rocket.chat/i18n': minor +'@rocket.chat/meteor': minor +--- + +Fixed a bug related to uploading end to end encrypted file. + +E2EE files and uploads are uploaded as files of mime type `application/octet-stream` as we can't reveal the mime type of actual content since it is encrypted and has to be kept confidential. + +The server resolves the mime type of encrypted file as `application/octet-stream` but it wasn't playing nicely with existing settings related to whitelisted and blacklisted media types. + +E2EE files upload was getting blocked if `application/octet-stream` is not a whitelisted media type. + +Now this PR solves this issue by always accepting E2EE uploads even if `application/octet-stream` is not whitelisted but it will block the upload if `application/octet-stream` is black listed. diff --git a/.gitignore b/.gitignore index fcf2b8cd07c7..4e6e4bb29da9 100644 --- a/.gitignore +++ b/.gitignore @@ -50,3 +50,6 @@ yarn-error.log* *.sublime-workspace **/.vim/ + +data/ +registration.yaml diff --git a/apps/meteor/app/api/server/v1/federation.ts b/apps/meteor/app/api/server/v1/federation.ts index 7be5b1fc13fe..5f998546cf3e 100644 --- a/apps/meteor/app/api/server/v1/federation.ts +++ b/apps/meteor/app/api/server/v1/federation.ts @@ -22,3 +22,21 @@ API.v1.addRoute( }, }, ); + +API.v1.addRoute( + 'federation/configuration.verify', + { authRequired: true, permissionsRequired: ['view-privileged-setting'] }, + { + async get() { + const service = License.hasValidLicense() ? FederationEE : Federation; + + const status = await service.configurationStatus(); + + if (!status.externalReachability.ok || !status.appservice.ok) { + return API.v1.failure(status); + } + + return API.v1.success(status); + }, + }, +); diff --git a/apps/meteor/app/api/server/v1/oauthapps.ts b/apps/meteor/app/api/server/v1/oauthapps.ts index 034a73f54104..4113b945a4db 100644 --- a/apps/meteor/app/api/server/v1/oauthapps.ts +++ b/apps/meteor/app/api/server/v1/oauthapps.ts @@ -27,11 +27,12 @@ API.v1.addRoute( { authRequired: true, validateParams: isOauthAppsGetParams }, { async get() { - if (!(await hasPermissionAsync(this.userId, 'manage-oauth-apps'))) { - return API.v1.unauthorized(); - } + const isOAuthAppsManager = await hasPermissionAsync(this.userId, 'manage-oauth-apps'); - const oauthApp = await OAuthApps.findOneAuthAppByIdOrClientId(this.queryParams); + const oauthApp = await OAuthApps.findOneAuthAppByIdOrClientId( + this.queryParams, + !isOAuthAppsManager ? { projection: { clientSecret: 0 } } : {}, + ); if (!oauthApp) { return API.v1.failure('OAuth app not found.'); diff --git a/apps/meteor/app/api/server/v1/rooms.ts b/apps/meteor/app/api/server/v1/rooms.ts index e3296b98ef17..17ef75b74574 100644 --- a/apps/meteor/app/api/server/v1/rooms.ts +++ b/apps/meteor/app/api/server/v1/rooms.ts @@ -1,8 +1,14 @@ import { Media } from '@rocket.chat/core-services'; import type { IRoom, IUpload } from '@rocket.chat/core-typings'; -import { Messages, Rooms, Users, Uploads } from '@rocket.chat/models'; +import { Messages, Rooms, Users, Uploads, Subscriptions } from '@rocket.chat/models'; import type { Notifications } from '@rocket.chat/rest-typings'; -import { isGETRoomsNameExists, isRoomsImagesProps, isRoomsMuteUnmuteUserProps, isRoomsExportProps } from '@rocket.chat/rest-typings'; +import { + isGETRoomsNameExists, + isRoomsImagesProps, + isRoomsMuteUnmuteUserProps, + isRoomsExportProps, + isRoomsIsMemberProps, +} from '@rocket.chat/rest-typings'; import { Meteor } from 'meteor/meteor'; import { isTruthy } from '../../../../lib/isTruthy'; @@ -783,6 +789,36 @@ API.v1.addRoute( }, ); +API.v1.addRoute( + 'rooms.isMember', + { + authRequired: true, + validateParams: isRoomsIsMemberProps, + }, + { + async get() { + const { roomId, userId, username } = this.queryParams; + const [room, user] = await Promise.all([ + findRoomByIdOrName({ + params: { roomId }, + }) as Promise, + Users.findOneByIdOrUsername(userId || username), + ]); + + if (!user?._id) { + return API.v1.failure('error-user-not-found'); + } + + if (await canAccessRoomAsync(room, { _id: this.user._id })) { + return API.v1.success({ + isMember: (await Subscriptions.countByRoomIdAndUserId(room._id, user._id)) > 0, + }); + } + return API.v1.unauthorized(); + }, + }, +); + API.v1.addRoute( 'rooms.muteUser', { authRequired: true, validateParams: isRoomsMuteUnmuteUserProps }, diff --git a/apps/meteor/app/api/server/v1/subscriptions.ts b/apps/meteor/app/api/server/v1/subscriptions.ts index 9d81fe6bef65..b92d9ba572fd 100644 --- a/apps/meteor/app/api/server/v1/subscriptions.ts +++ b/apps/meteor/app/api/server/v1/subscriptions.ts @@ -82,6 +82,7 @@ API.v1.addRoute( async post() { const { readThreads = false } = this.bodyParams; const roomId = 'rid' in this.bodyParams ? this.bodyParams.rid : this.bodyParams.roomId; + await readMessages(roomId, this.userId, readThreads); return API.v1.success(); diff --git a/apps/meteor/app/api/server/v1/users.ts b/apps/meteor/app/api/server/v1/users.ts index 7ae585b89dfa..9c56ecac01cb 100644 --- a/apps/meteor/app/api/server/v1/users.ts +++ b/apps/meteor/app/api/server/v1/users.ts @@ -45,6 +45,7 @@ import { setUserAvatar } from '../../../lib/server/functions/setUserAvatar'; import { setUsernameWithValidation } from '../../../lib/server/functions/setUsername'; import { validateCustomFields } from '../../../lib/server/functions/validateCustomFields'; import { validateNameChars } from '../../../lib/server/functions/validateNameChars'; +import { validateUsername } from '../../../lib/server/functions/validateUsername'; import { notifyOnUserChange, notifyOnUserChangeAsync } from '../../../lib/server/lib/notifyListener'; import { generateAccessToken } from '../../../lib/server/methods/createToken'; import { settings } from '../../../settings/server'; @@ -651,6 +652,10 @@ API.v1.addRoute( return API.v1.failure('Name contains invalid characters'); } + if (!validateUsername(this.bodyParams.username)) { + return API.v1.failure(`The username provided is not valid`); + } + if (!(await checkUsernameAvailability(this.bodyParams.username))) { return API.v1.failure('Username is already in use'); } diff --git a/apps/meteor/app/apps/server/bridges/messages.ts b/apps/meteor/app/apps/server/bridges/messages.ts index 18a68220998f..824a9d5c15af 100644 --- a/apps/meteor/app/apps/server/bridges/messages.ts +++ b/apps/meteor/app/apps/server/bridges/messages.ts @@ -1,4 +1,5 @@ import type { IAppServerOrchestrator, IAppsMessage, IAppsUser } from '@rocket.chat/apps'; +import type { Reaction } from '@rocket.chat/apps-engine/definition/messages'; import type { IRoom } from '@rocket.chat/apps-engine/definition/rooms'; import type { ITypingDescriptor } from '@rocket.chat/apps-engine/server/bridges/MessageBridge'; import { MessageBridge } from '@rocket.chat/apps-engine/server/bridges/MessageBridge'; @@ -10,6 +11,7 @@ import { deleteMessage } from '../../../lib/server/functions/deleteMessage'; import { updateMessage } from '../../../lib/server/functions/updateMessage'; import { executeSendMessage } from '../../../lib/server/methods/sendMessage'; import notifications from '../../../notifications/server/lib/Notifications'; +import { executeSetReaction } from '../../../reactions/server/setReaction'; export class AppMessageBridge extends MessageBridge { constructor(private readonly orch: IAppServerOrchestrator) { @@ -118,4 +120,24 @@ export class AppMessageBridge extends MessageBridge { throw new Error('Unrecognized typing scope provided'); } } + + private isValidReaction(reaction: Reaction): boolean { + return reaction.startsWith(':') && reaction.endsWith(':'); + } + + protected async addReaction(messageId: string, userId: string, reaction: Reaction): Promise { + if (!this.isValidReaction(reaction)) { + throw new Error('Invalid reaction'); + } + + return executeSetReaction(userId, reaction, messageId, true); + } + + protected async removeReaction(messageId: string, userId: string, reaction: Reaction): Promise { + if (!this.isValidReaction(reaction)) { + throw new Error('Invalid reaction'); + } + + return executeSetReaction(userId, reaction, messageId, false); + } } diff --git a/apps/meteor/app/apps/server/converters/rooms.js b/apps/meteor/app/apps/server/converters/rooms.js index 670c1a248a0f..a98a6701b2c2 100644 --- a/apps/meteor/app/apps/server/converters/rooms.js +++ b/apps/meteor/app/apps/server/converters/rooms.js @@ -111,8 +111,8 @@ export class AppRoomsConverter { return Object.assign(newRoom, room._unmappedProperties_); } - async convertRoom(room) { - if (!room) { + async convertRoom(originalRoom) { + if (!originalRoom) { return undefined; } @@ -134,6 +134,7 @@ export class AppRoomsConverter { _USERNAMES: '_USERNAMES', description: 'description', source: 'source', + closer: 'closer', isDefault: (room) => { const result = !!room.default; delete room.default; @@ -210,6 +211,19 @@ export class AppRoomsConverter { return this.orch.getConverters().get('departments').convertById(departmentId); }, + closedBy: async (room) => { + const { closedBy } = room; + + if (!closedBy) { + return undefined; + } + + delete room.closedBy; + if (originalRoom.closer === 'user') { + return this.orch.getConverters().get('users').convertById(closedBy._id); + } + return this.orch.getConverters().get('visitors').convertById(closedBy._id); + }, servedBy: async (room) => { const { servedBy } = room; @@ -245,7 +259,7 @@ export class AppRoomsConverter { }, }; - return transformMappedData(room, map); + return transformMappedData(originalRoom, map); } _convertTypeToApp(typeChar) { diff --git a/apps/meteor/app/authorization/server/constant/permissions.ts b/apps/meteor/app/authorization/server/constant/permissions.ts index 6efe99e14d0e..d9ae4133e49e 100644 --- a/apps/meteor/app/authorization/server/constant/permissions.ts +++ b/apps/meteor/app/authorization/server/constant/permissions.ts @@ -93,6 +93,10 @@ export const permissions = [ _id: 'view-l-room', roles: ['livechat-manager', 'livechat-monitor', 'livechat-agent', 'admin'], }, + { + _id: 'create-livechat-contact', + roles: ['livechat-manager', 'livechat-monitor', 'livechat-agent', 'admin'], + }, { _id: 'view-livechat-manager', roles: ['livechat-manager', 'livechat-monitor', 'admin'] }, { _id: 'view-omnichannel-contact-center', diff --git a/apps/meteor/app/autotranslate/server/methods/saveSettings.ts b/apps/meteor/app/autotranslate/server/methods/saveSettings.ts index 2f119c948263..3d4d15c0316e 100644 --- a/apps/meteor/app/autotranslate/server/methods/saveSettings.ts +++ b/apps/meteor/app/autotranslate/server/methods/saveSettings.ts @@ -4,6 +4,7 @@ import { check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; +import { notifyOnSubscriptionChangedById } from '../../../lib/server/lib/notifyListener'; declare module '@rocket.chat/ddp-client' { // eslint-disable-next-line @typescript-eslint/naming-convention @@ -44,6 +45,8 @@ Meteor.methods({ }); } + let shouldNotifySubscriptionChanged = false; + switch (field) { case 'autoTranslate': const room = await Rooms.findE2ERoomById(rid, { projection: { _id: 1 } }); @@ -53,16 +56,34 @@ Meteor.methods({ }); } - await Subscriptions.updateAutoTranslateById(subscription._id, value === '1'); + const updateAutoTranslateResponse = await Subscriptions.updateAutoTranslateById(subscription._id, value === '1'); + if (updateAutoTranslateResponse.modifiedCount) { + shouldNotifySubscriptionChanged = true; + } + if (!subscription.autoTranslateLanguage && options.defaultLanguage) { - await Subscriptions.updateAutoTranslateLanguageById(subscription._id, options.defaultLanguage); + const updateAutoTranslateLanguageResponse = await Subscriptions.updateAutoTranslateLanguageById( + subscription._id, + options.defaultLanguage, + ); + if (updateAutoTranslateLanguageResponse.modifiedCount) { + shouldNotifySubscriptionChanged = true; + } } + break; case 'autoTranslateLanguage': - await Subscriptions.updateAutoTranslateLanguageById(subscription._id, value); + const updateAutoTranslateLanguage = await Subscriptions.updateAutoTranslateLanguageById(subscription._id, value); + if (updateAutoTranslateLanguage.modifiedCount) { + shouldNotifySubscriptionChanged = true; + } break; } + if (shouldNotifySubscriptionChanged) { + void notifyOnSubscriptionChangedById(subscription._id); + } + return true; }, }); diff --git a/apps/meteor/app/channel-settings/server/functions/saveRoomCustomFields.ts b/apps/meteor/app/channel-settings/server/functions/saveRoomCustomFields.ts index 55d40cf3d7e6..ef70ff65c067 100644 --- a/apps/meteor/app/channel-settings/server/functions/saveRoomCustomFields.ts +++ b/apps/meteor/app/channel-settings/server/functions/saveRoomCustomFields.ts @@ -3,21 +3,28 @@ import { Match } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; import type { UpdateResult } from 'mongodb'; +import { notifyOnSubscriptionChangedByRoomId } from '../../../lib/server/lib/notifyListener'; + export const saveRoomCustomFields = async function (rid: string, roomCustomFields: Record): Promise { if (!Match.test(rid, String)) { throw new Meteor.Error('invalid-room', 'Invalid room', { function: 'RocketChat.saveRoomCustomFields', }); } + if (!Match.test(roomCustomFields, Object)) { throw new Meteor.Error('invalid-roomCustomFields-type', 'Invalid roomCustomFields type', { function: 'RocketChat.saveRoomCustomFields', }); } + const ret = await Rooms.setCustomFieldsById(rid, roomCustomFields); // Update customFields of any user's Subscription related with this rid - await Subscriptions.updateCustomFieldsByRoomId(rid, roomCustomFields); + const { modifiedCount } = await Subscriptions.updateCustomFieldsByRoomId(rid, roomCustomFields); + if (modifiedCount) { + void notifyOnSubscriptionChangedByRoomId(rid); + } return ret; }; diff --git a/apps/meteor/app/channel-settings/server/functions/saveRoomEncrypted.ts b/apps/meteor/app/channel-settings/server/functions/saveRoomEncrypted.ts index ed07540ba2b0..c1a441463a98 100644 --- a/apps/meteor/app/channel-settings/server/functions/saveRoomEncrypted.ts +++ b/apps/meteor/app/channel-settings/server/functions/saveRoomEncrypted.ts @@ -6,6 +6,8 @@ import { Match } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; import type { UpdateResult } from 'mongodb'; +import { notifyOnSubscriptionChangedByRoomId } from '../../../lib/server/lib/notifyListener'; + export const saveRoomEncrypted = async function (rid: string, encrypted: boolean, user: IUser, sendMessage = true): Promise { if (!Match.test(rid, String)) { throw new Meteor.Error('invalid-room', 'Invalid room', { @@ -27,7 +29,10 @@ export const saveRoomEncrypted = async function (rid: string, encrypted: boolean } if (encrypted) { - await Subscriptions.disableAutoTranslateByRoomId(rid); + const { modifiedCount } = await Subscriptions.disableAutoTranslateByRoomId(rid); + if (modifiedCount) { + void notifyOnSubscriptionChangedByRoomId(rid); + } } return update; }; diff --git a/apps/meteor/app/channel-settings/server/functions/saveRoomName.ts b/apps/meteor/app/channel-settings/server/functions/saveRoomName.ts index 0fc15f878bcf..f4a5afbb6380 100644 --- a/apps/meteor/app/channel-settings/server/functions/saveRoomName.ts +++ b/apps/meteor/app/channel-settings/server/functions/saveRoomName.ts @@ -1,4 +1,4 @@ -import { Message } from '@rocket.chat/core-services'; +import { Message, Room } from '@rocket.chat/core-services'; import type { IUser } from '@rocket.chat/core-typings'; import { isRoomFederated } from '@rocket.chat/core-typings'; import { Integrations, Rooms, Subscriptions } from '@rocket.chat/models'; @@ -8,11 +8,17 @@ import type { Document, UpdateResult } from 'mongodb'; import { callbacks } from '../../../../lib/callbacks'; import { roomCoordinator } from '../../../../server/lib/rooms/roomCoordinator'; import { checkUsernameAvailability } from '../../../lib/server/functions/checkUsernameAvailability'; -import { notifyOnIntegrationChangedByChannels } from '../../../lib/server/lib/notifyListener'; +import { notifyOnIntegrationChangedByChannels, notifyOnSubscriptionChangedByRoomId } from '../../../lib/server/lib/notifyListener'; import { getValidRoomName } from '../../../utils/server/lib/getValidRoomName'; const updateFName = async (rid: string, displayName: string): Promise<(UpdateResult | Document)[]> => { - return Promise.all([Rooms.setFnameById(rid, displayName), Subscriptions.updateFnameByRoomId(rid, displayName)]); + const responses = await Promise.all([Rooms.setFnameById(rid, displayName), Subscriptions.updateFnameByRoomId(rid, displayName)]); + + if (responses[1]?.modifiedCount) { + void notifyOnSubscriptionChangedByRoomId(rid); + } + + return responses; }; const updateRoomName = async (rid: string, displayName: string, slugifiedRoomName: string) => { @@ -24,10 +30,16 @@ const updateRoomName = async (rid: string, displayName: string, slugifiedRoomNam }); } - return Promise.all([ + const responses = await Promise.all([ Rooms.setNameById(rid, slugifiedRoomName, displayName), Subscriptions.updateNameAndAlertByRoomId(rid, slugifiedRoomName, displayName), ]); + + if (responses[1]?.modifiedCount) { + void notifyOnSubscriptionChangedByRoomId(rid); + } + + return responses; }; export async function saveRoomName( @@ -48,6 +60,9 @@ export async function saveRoomName( function: 'RocketChat.saveRoomdisplayName', }); } + + await Room.beforeNameChange(room); + if (displayName === room.name) { return; } diff --git a/apps/meteor/app/channel-settings/server/functions/saveRoomTopic.ts b/apps/meteor/app/channel-settings/server/functions/saveRoomTopic.ts index 11b9b5b6e565..a59f2ba82fba 100644 --- a/apps/meteor/app/channel-settings/server/functions/saveRoomTopic.ts +++ b/apps/meteor/app/channel-settings/server/functions/saveRoomTopic.ts @@ -1,4 +1,4 @@ -import { Message } from '@rocket.chat/core-services'; +import { Message, Room } from '@rocket.chat/core-services'; import { Rooms } from '@rocket.chat/models'; import { Match } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; @@ -20,6 +20,10 @@ export const saveRoomTopic = async function ( }); } + const room = await Rooms.findOneById(rid); + + await Room.beforeTopicChange(room!); + const update = await Rooms.setTopicById(rid, roomTopic); if (update && sendMessage) { await Message.saveSystemMessage('room_changed_topic', rid, roomTopic || '', user); diff --git a/apps/meteor/app/channel-settings/server/functions/saveRoomType.ts b/apps/meteor/app/channel-settings/server/functions/saveRoomType.ts index e8a60d1ea0eb..4600d1d46a80 100644 --- a/apps/meteor/app/channel-settings/server/functions/saveRoomType.ts +++ b/apps/meteor/app/channel-settings/server/functions/saveRoomType.ts @@ -8,6 +8,7 @@ import type { UpdateResult, Document } from 'mongodb'; import { RoomSettingsEnum } from '../../../../definition/IRoomTypeConfig'; import { i18n } from '../../../../server/lib/i18n'; import { roomCoordinator } from '../../../../server/lib/rooms/roomCoordinator'; +import { notifyOnSubscriptionChangedByRoomId } from '../../../lib/server/lib/notifyListener'; import { settings } from '../../../settings/server'; export const saveRoomType = async function ( @@ -41,11 +42,16 @@ export const saveRoomType = async function ( }); } - const result = (await Rooms.setTypeById(rid, roomType)) && (await Subscriptions.updateTypeByRoomId(rid, roomType)); + const result = await Promise.all([Rooms.setTypeById(rid, roomType), Subscriptions.updateTypeByRoomId(rid, roomType)]); + if (!result) { return result; } + if (result[1]?.modifiedCount) { + void notifyOnSubscriptionChangedByRoomId(rid); + } + if (sendMessage) { let message; if (roomType === 'c') { @@ -59,5 +65,6 @@ export const saveRoomType = async function ( } await Message.saveSystemMessage('room_changed_privacy', rid, message, user); } + return result; }; diff --git a/apps/meteor/app/channel-settings/server/functions/saveStreamingOptions.ts b/apps/meteor/app/channel-settings/server/functions/saveStreamingOptions.ts deleted file mode 100644 index aee596402cc6..000000000000 --- a/apps/meteor/app/channel-settings/server/functions/saveStreamingOptions.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Rooms } from '@rocket.chat/models'; -import { Match, check } from 'meteor/check'; -import { Meteor } from 'meteor/meteor'; - -export const saveStreamingOptions = async function (rid: string, options: Record): Promise { - if (!Match.test(rid, String)) { - throw new Meteor.Error('invalid-room', 'Invalid room', { - function: 'RocketChat.saveStreamingOptions', - }); - } - - check(options, { - id: Match.Optional(String), - type: Match.Optional(String), - url: Match.Optional(String), - thumbnail: Match.Optional(String), - isAudioOnly: Match.Optional(Boolean), - message: Match.Optional(String), - }); - - await Rooms.setStreamingOptionsById(rid, options); -}; diff --git a/apps/meteor/app/channel-settings/server/methods/saveRoomSettings.ts b/apps/meteor/app/channel-settings/server/methods/saveRoomSettings.ts index 44ad253d83ef..04e8fdbaf186 100644 --- a/apps/meteor/app/channel-settings/server/methods/saveRoomSettings.ts +++ b/apps/meteor/app/channel-settings/server/methods/saveRoomSettings.ts @@ -21,7 +21,6 @@ import { saveRoomReadOnly } from '../functions/saveRoomReadOnly'; import { saveRoomSystemMessages } from '../functions/saveRoomSystemMessages'; import { saveRoomTopic } from '../functions/saveRoomTopic'; import { saveRoomType } from '../functions/saveRoomType'; -import { saveStreamingOptions } from '../functions/saveStreamingOptions'; type RoomSettings = { roomAvatar: string; @@ -37,7 +36,6 @@ type RoomSettings = { systemMessages: MessageTypesValues[]; default: boolean; joinCode: string; - streamingOptions: NonNullable; retentionEnabled: boolean; retentionMaxAge: number; retentionExcludePinned: boolean; @@ -272,9 +270,6 @@ const settingSavers: RoomSettingsSavers = { void Team.update(user._id, room.teamId, { type, updateRoom: false }); } }, - async streamingOptions({ value, rid }) { - await saveStreamingOptions(rid, value); - }, async readOnly({ value, room, rid, user }) { if (value !== room.ro) { await saveRoomReadOnly(rid, value, user); @@ -354,7 +349,6 @@ const fields: (keyof RoomSettings)[] = [ 'systemMessages', 'default', 'joinCode', - 'streamingOptions', 'retentionEnabled', 'retentionMaxAge', 'retentionExcludePinned', diff --git a/apps/meteor/app/e2e/server/functions/handleSuggestedGroupKey.ts b/apps/meteor/app/e2e/server/functions/handleSuggestedGroupKey.ts index 860051c04d4d..22eccf03f407 100644 --- a/apps/meteor/app/e2e/server/functions/handleSuggestedGroupKey.ts +++ b/apps/meteor/app/e2e/server/functions/handleSuggestedGroupKey.ts @@ -1,6 +1,8 @@ import { Rooms, Subscriptions } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; +import { notifyOnSubscriptionChangedById } from '../../../lib/server/lib/notifyListener'; + export async function handleSuggestedGroupKey( handle: 'accept' | 'reject', rid: string, @@ -30,5 +32,8 @@ export async function handleSuggestedGroupKey( await Rooms.addUserIdToE2EEQueueByRoomIds([sub.rid], userId); } - await Subscriptions.unsetGroupE2ESuggestedKey(sub._id); + const { modifiedCount } = await Subscriptions.unsetGroupE2ESuggestedKey(sub._id); + if (modifiedCount) { + void notifyOnSubscriptionChangedById(sub._id); + } } diff --git a/apps/meteor/app/e2e/server/methods/updateGroupKey.ts b/apps/meteor/app/e2e/server/methods/updateGroupKey.ts index 5764a021f54c..87182f723e7d 100644 --- a/apps/meteor/app/e2e/server/methods/updateGroupKey.ts +++ b/apps/meteor/app/e2e/server/methods/updateGroupKey.ts @@ -3,6 +3,7 @@ import { Subscriptions } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; import { methodDeprecationLogger } from '../../../lib/server/lib/deprecationWarningLogger'; +import { notifyOnSubscriptionChangedById, notifyOnSubscriptionChangedByRoomIdAndUserId } from '../../../lib/server/lib/notifyListener'; declare module '@rocket.chat/ddp-client' { // eslint-disable-next-line @typescript-eslint/naming-convention @@ -25,12 +26,18 @@ Meteor.methods({ if (mySub) { // Setting the key to myself, can set directly to the final field if (userId === uid) { - await Subscriptions.setGroupE2EKey(mySub._id, key); + const setGroupE2EKeyResponse = await Subscriptions.setGroupE2EKey(mySub._id, key); + if (setGroupE2EKeyResponse.modifiedCount) { + void notifyOnSubscriptionChangedById(mySub._id); + } return; } // uid also has subscription to this room - await Subscriptions.setGroupE2ESuggestedKey(uid, rid, key); + const { modifiedCount } = await Subscriptions.setGroupE2ESuggestedKey(uid, rid, key); + if (modifiedCount) { + void notifyOnSubscriptionChangedByRoomIdAndUserId(rid, uid); + } } }, }); diff --git a/apps/meteor/app/federation/server/endpoints/dispatch.js b/apps/meteor/app/federation/server/endpoints/dispatch.js index 7090f053a22b..4f2a197b25ee 100644 --- a/apps/meteor/app/federation/server/endpoints/dispatch.js +++ b/apps/meteor/app/federation/server/endpoints/dispatch.js @@ -6,7 +6,13 @@ import EJSON from 'ejson'; import { API } from '../../../api/server'; import { FileUpload } from '../../../file-upload/server'; import { deleteRoom } from '../../../lib/server/functions/deleteRoom'; -import { notifyOnMessageChange, notifyOnRoomChanged, notifyOnRoomChangedById } from '../../../lib/server/lib/notifyListener'; +import { + notifyOnMessageChange, + notifyOnRoomChanged, + notifyOnRoomChangedById, + notifyOnSubscriptionChanged, + notifyOnSubscriptionChangedById, +} from '../../../lib/server/lib/notifyListener'; import { notifyUsersOnMessage } from '../../../lib/server/lib/notifyUsersOnMessage'; import { sendAllNotifications } from '../../../lib/server/lib/sendNotificationsOnMessage'; import { processThreads } from '../../../threads/server/hooks/aftersavemessage'; @@ -141,7 +147,10 @@ const eventHandlers = { const denormalizedSubscription = normalizers.denormalizeSubscription(subscription); // Create the subscription - await Subscriptions.insertOne(denormalizedSubscription); + const { insertedId } = await Subscriptions.insertOne(denormalizedSubscription); + if (insertedId) { + void notifyOnSubscriptionChangedById(insertedId); + } federationAltered = true; } } catch (ex) { @@ -176,7 +185,10 @@ const eventHandlers = { } = event; // Remove the user's subscription - await Subscriptions.removeByRoomIdAndUserId(roomId, user._id); + const deletedSubscription = await Subscriptions.removeByRoomIdAndUserId(roomId, user._id); + if (deletedSubscription) { + void notifyOnSubscriptionChanged(deletedSubscription, 'removed'); + } // Refresh the servers list await FederationServers.refreshServers(); @@ -204,7 +216,10 @@ const eventHandlers = { } = event; // Remove the user's subscription - await Subscriptions.removeByRoomIdAndUserId(roomId, user._id); + const deletedSubscription = await Subscriptions.removeByRoomIdAndUserId(roomId, user._id); + if (deletedSubscription) { + void notifyOnSubscriptionChanged(deletedSubscription, 'removed'); + } // Refresh the servers list await FederationServers.refreshServers(); diff --git a/apps/meteor/app/file-upload/server/lib/FileUpload.ts b/apps/meteor/app/file-upload/server/lib/FileUpload.ts index 08e2ccb0a52b..8714c71f20d6 100644 --- a/apps/meteor/app/file-upload/server/lib/FileUpload.ts +++ b/apps/meteor/app/file-upload/server/lib/FileUpload.ts @@ -10,7 +10,7 @@ import URL from 'url'; import { hashLoginToken } from '@rocket.chat/account-utils'; import { Apps, AppEvents } from '@rocket.chat/apps'; import { AppsEngineException } from '@rocket.chat/apps-engine/definition/exceptions'; -import type { IUpload } from '@rocket.chat/core-typings'; +import { isE2EEUpload, type IUpload } from '@rocket.chat/core-typings'; import { Users, Avatars, UserDataFiles, Uploads, Settings, Subscriptions, Messages, Rooms } from '@rocket.chat/models'; import type { NextFunction } from 'connect'; import filesize from 'filesize'; @@ -170,7 +170,13 @@ export const FileUpload = { throw new Meteor.Error('error-file-too-large', reason); } - if (!fileUploadIsValidContentType(file?.type)) { + if (!settings.get('E2E_Enable_Encrypt_Files') && isE2EEUpload(file)) { + const reason = i18n.t('Encrypted_file_not_allowed', { lng: language }); + throw new Meteor.Error('error-invalid-file-type', reason); + } + + // E2EE files are of type - application/octet-stream, application/octet-stream is whitelisted for E2EE files. + if (!fileUploadIsValidContentType(file?.type, isE2EEUpload(file) ? 'application/octet-stream' : undefined)) { const reason = i18n.t('File_type_is_not_accepted', { lng: language }); throw new Meteor.Error('error-invalid-file-type', reason); } diff --git a/apps/meteor/app/importer/server/classes/ImportDataConverter.ts b/apps/meteor/app/importer/server/classes/ImportDataConverter.ts index 7b1e71eaa0f0..6de47e33b2b6 100644 --- a/apps/meteor/app/importer/server/classes/ImportDataConverter.ts +++ b/apps/meteor/app/importer/server/classes/ImportDataConverter.ts @@ -28,7 +28,7 @@ import { generateUsernameSuggestion } from '../../../lib/server/functions/getUse import { insertMessage } from '../../../lib/server/functions/insertMessage'; import { saveUserIdentity } from '../../../lib/server/functions/saveUserIdentity'; import { setUserActiveStatus } from '../../../lib/server/functions/setUserActiveStatus'; -import { notifyOnUserChange } from '../../../lib/server/lib/notifyListener'; +import { notifyOnSubscriptionChangedByRoomId, notifyOnUserChange } from '../../../lib/server/lib/notifyListener'; import { createChannelMethod } from '../../../lib/server/methods/createChannel'; import { createPrivateGroupMethod } from '../../../lib/server/methods/createPrivateGroup'; import { getValidRoomName } from '../../../utils/server/lib/getValidRoomName'; @@ -1161,8 +1161,11 @@ export class ImportDataConverter { } async archiveRoomById(rid: string) { - await Rooms.archiveById(rid); - await Subscriptions.archiveByRoomId(rid); + const responses = await Promise.all([Rooms.archiveById(rid), Subscriptions.archiveByRoomId(rid)]); + + if (responses[1]?.modifiedCount) { + void notifyOnSubscriptionChangedByRoomId(rid); + } } async convertData(startedByUserId: string, callbacks: IConversionCallbacks = {}): Promise { diff --git a/apps/meteor/app/lib/server/functions/addUserToDefaultChannels.ts b/apps/meteor/app/lib/server/functions/addUserToDefaultChannels.ts index b6d977dc36e2..3fb9c419aa5f 100644 --- a/apps/meteor/app/lib/server/functions/addUserToDefaultChannels.ts +++ b/apps/meteor/app/lib/server/functions/addUserToDefaultChannels.ts @@ -5,6 +5,7 @@ import { Subscriptions } from '@rocket.chat/models'; import { callbacks } from '../../../../lib/callbacks'; import { getSubscriptionAutotranslateDefaultConfig } from '../../../../server/lib/getSubscriptionAutotranslateDefaultConfig'; import { getDefaultSubscriptionPref } from '../../../utils/lib/getDefaultSubscriptionPref'; +import { notifyOnSubscriptionChangedById } from '../lib/notifyListener'; import { getDefaultChannels } from './getDefaultChannels'; export const addUserToDefaultChannels = async function (user: IUser, silenced?: boolean): Promise { @@ -14,8 +15,9 @@ export const addUserToDefaultChannels = async function (user: IUser, silenced?: for await (const room of defaultRooms) { if (!(await Subscriptions.findOneByRoomIdAndUserId(room._id, user._id, { projection: { _id: 1 } }))) { const autoTranslateConfig = getSubscriptionAutotranslateDefaultConfig(user); + // Add a subscription to this user - await Subscriptions.createWithRoomAndUser(room, user, { + const { insertedId } = await Subscriptions.createWithRoomAndUser(room, user, { ts: new Date(), open: true, alert: true, @@ -27,6 +29,10 @@ export const addUserToDefaultChannels = async function (user: IUser, silenced?: ...getDefaultSubscriptionPref(user), }); + if (insertedId) { + void notifyOnSubscriptionChangedById(insertedId, 'inserted'); + } + // Insert user joined message if (!silenced) { await Message.saveSystemMessage('uj', room._id, user.username || '', user); diff --git a/apps/meteor/app/lib/server/functions/addUserToRoom.ts b/apps/meteor/app/lib/server/functions/addUserToRoom.ts index b6ffc0ca4629..e6ca7b2a8b4d 100644 --- a/apps/meteor/app/lib/server/functions/addUserToRoom.ts +++ b/apps/meteor/app/lib/server/functions/addUserToRoom.ts @@ -11,7 +11,7 @@ import { getSubscriptionAutotranslateDefaultConfig } from '../../../../server/li import { roomCoordinator } from '../../../../server/lib/rooms/roomCoordinator'; import { settings } from '../../../settings/server'; import { getDefaultSubscriptionPref } from '../../../utils/lib/getDefaultSubscriptionPref'; -import { notifyOnRoomChangedById } from '../lib/notifyListener'; +import { notifyOnRoomChangedById, notifyOnSubscriptionChangedById } from '../lib/notifyListener'; export const addUserToRoom = async function ( rid: string, @@ -82,7 +82,7 @@ export const addUserToRoom = async function ( const autoTranslateConfig = getSubscriptionAutotranslateDefaultConfig(userToBeAdded); - await Subscriptions.createWithRoomAndUser(room, userToBeAdded as IUser, { + const { insertedId } = await Subscriptions.createWithRoomAndUser(room, userToBeAdded as IUser, { ts: now, open: true, alert: !skipAlertSound, @@ -93,6 +93,10 @@ export const addUserToRoom = async function ( ...getDefaultSubscriptionPref(userToBeAdded as IUser), }); + if (insertedId) { + void notifyOnSubscriptionChangedById(insertedId, 'inserted'); + } + void notifyOnRoomChangedById(rid); if (!userToBeAdded.username) { diff --git a/apps/meteor/app/lib/server/functions/archiveRoom.ts b/apps/meteor/app/lib/server/functions/archiveRoom.ts index 3378d69f99ff..46fd7a1ac35b 100644 --- a/apps/meteor/app/lib/server/functions/archiveRoom.ts +++ b/apps/meteor/app/lib/server/functions/archiveRoom.ts @@ -3,11 +3,16 @@ import type { IMessage } from '@rocket.chat/core-typings'; import { Rooms, Subscriptions } from '@rocket.chat/models'; import { callbacks } from '../../../../lib/callbacks'; -import { notifyOnRoomChanged } from '../lib/notifyListener'; +import { notifyOnRoomChanged, notifyOnSubscriptionChangedByRoomId } from '../lib/notifyListener'; export const archiveRoom = async function (rid: string, user: IMessage['u']): Promise { await Rooms.archiveById(rid); - await Subscriptions.archiveByRoomId(rid); + + const archiveResponse = await Subscriptions.archiveByRoomId(rid); + if (archiveResponse.modifiedCount) { + void notifyOnSubscriptionChangedByRoomId(rid); + } + await Message.saveSystemMessage('room-archived', rid, '', user); const room = await Rooms.findOneById(rid); diff --git a/apps/meteor/app/lib/server/functions/cleanRoomHistory.ts b/apps/meteor/app/lib/server/functions/cleanRoomHistory.ts index 2bfb1086c635..765a03cad87b 100644 --- a/apps/meteor/app/lib/server/functions/cleanRoomHistory.ts +++ b/apps/meteor/app/lib/server/functions/cleanRoomHistory.ts @@ -4,7 +4,7 @@ import { Messages, Rooms, Subscriptions, ReadReceipts, Users } from '@rocket.cha import { i18n } from '../../../../server/lib/i18n'; import { FileUpload } from '../../../file-upload/server'; -import { notifyOnRoomChangedById } from '../lib/notifyListener'; +import { notifyOnRoomChangedById, notifyOnSubscriptionChangedById } from '../lib/notifyListener'; import { deleteRoom } from './deleteRoom'; export async function cleanRoomHistory({ @@ -75,6 +75,7 @@ export async function cleanRoomHistory({ if (!ignoreThreads) { const threads = new Set(); + await Messages.findThreadsByRoomIdPinnedTimestampAndUsers( { rid, pinned: excludePinned, ignoreDiscussion, ts, users: fromUsers }, { projection: { _id: 1 } }, @@ -83,7 +84,14 @@ export async function cleanRoomHistory({ }); if (threads.size > 0) { - await Subscriptions.removeUnreadThreadsByRoomId(rid, [...threads]); + const subscriptionIds: string[] = ( + await Subscriptions.findUnreadThreadsByRoomId(rid, [...threads], { projection: { _id: 1 } }).toArray() + ).map(({ _id }) => _id); + + const { modifiedCount } = await Subscriptions.removeUnreadThreadsByRoomId(rid, [...threads]); + if (modifiedCount) { + subscriptionIds.forEach((id) => notifyOnSubscriptionChangedById(id)); + } } } diff --git a/apps/meteor/app/lib/server/functions/closeLivechatRoom.ts b/apps/meteor/app/lib/server/functions/closeLivechatRoom.ts index b716be044d57..263b137ae00c 100644 --- a/apps/meteor/app/lib/server/functions/closeLivechatRoom.ts +++ b/apps/meteor/app/lib/server/functions/closeLivechatRoom.ts @@ -5,6 +5,7 @@ import { LivechatRooms, Subscriptions } from '@rocket.chat/models'; import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; import type { CloseRoomParams } from '../../../livechat/server/lib/LivechatTyped'; import { Livechat } from '../../../livechat/server/lib/LivechatTyped'; +import { notifyOnSubscriptionChanged } from '../lib/notifyListener'; export const closeLivechatRoom = async ( user: IUser, @@ -34,9 +35,12 @@ export const closeLivechatRoom = async ( } if (!room.open) { - const subscriptionsLeft = await Subscriptions.countByRoomId(roomId); - if (subscriptionsLeft) { - await Subscriptions.removeByRoomId(roomId); + const { deletedCount } = await Subscriptions.removeByRoomId(roomId, { + async onTrash(doc) { + void notifyOnSubscriptionChanged(doc, 'removed'); + }, + }); + if (deletedCount) { return; } throw new Error('error-room-already-closed'); diff --git a/apps/meteor/app/lib/server/functions/createDirectRoom.ts b/apps/meteor/app/lib/server/functions/createDirectRoom.ts index 67c6328f38f4..f77ee1f55901 100644 --- a/apps/meteor/app/lib/server/functions/createDirectRoom.ts +++ b/apps/meteor/app/lib/server/functions/createDirectRoom.ts @@ -11,7 +11,7 @@ import { callbacks } from '../../../../lib/callbacks'; import { isTruthy } from '../../../../lib/isTruthy'; import { settings } from '../../../settings/server'; import { getDefaultSubscriptionPref } from '../../../utils/lib/getDefaultSubscriptionPref'; -import { notifyOnRoomChangedById } from '../lib/notifyListener'; +import { notifyOnRoomChangedById, notifyOnSubscriptionChangedByRoomIdAndUserId } from '../lib/notifyListener'; const generateSubscription = ( fname: string, @@ -135,7 +135,7 @@ export async function createDirectRoom( if (roomMembers.length === 1) { // dm to yourself - await Subscriptions.updateOne( + const { modifiedCount, upsertedCount } = await Subscriptions.updateOne( { rid, 'u._id': roomMembers[0]._id }, { $set: { open: true }, @@ -146,6 +146,9 @@ export async function createDirectRoom( }, { upsert: true }, ); + if (modifiedCount || upsertedCount) { + void notifyOnSubscriptionChangedByRoomIdAndUserId(rid, roomMembers[0]._id, modifiedCount ? 'updated' : 'inserted'); + } } else { const memberIds = roomMembers.map((member) => member._id); const membersWithPreferences: IUser[] = await Users.find( @@ -155,7 +158,7 @@ export async function createDirectRoom( for await (const member of membersWithPreferences) { const otherMembers = sortedMembers.filter(({ _id }) => _id !== member._id); - await Subscriptions.updateOne( + const { modifiedCount, upsertedCount } = await Subscriptions.updateOne( { rid, 'u._id': member._id }, { ...(options?.creator === member._id && { $set: { open: true } }), @@ -166,6 +169,9 @@ export async function createDirectRoom( }, { upsert: true }, ); + if (modifiedCount || upsertedCount) { + void notifyOnSubscriptionChangedByRoomIdAndUserId(rid, member._id, modifiedCount ? 'updated' : 'inserted'); + } } } diff --git a/apps/meteor/app/lib/server/functions/createRoom.ts b/apps/meteor/app/lib/server/functions/createRoom.ts index 183cb789051f..769155b66b60 100644 --- a/apps/meteor/app/lib/server/functions/createRoom.ts +++ b/apps/meteor/app/lib/server/functions/createRoom.ts @@ -1,7 +1,7 @@ /* eslint-disable complexity */ import { AppEvents, Apps } from '@rocket.chat/apps'; import { AppsEngineException } from '@rocket.chat/apps-engine/definition/exceptions'; -import { Message, Team } from '@rocket.chat/core-services'; +import { Federation, FederationEE, License, Message, Team } from '@rocket.chat/core-services'; import type { ICreateRoomParams, ISubscriptionExtraData } from '@rocket.chat/core-services'; import type { ICreatedRoom, IUser, IRoom, RoomType } from '@rocket.chat/core-typings'; import { Rooms, Subscriptions, Users } from '@rocket.chat/models'; @@ -12,7 +12,7 @@ import { beforeCreateRoomCallback } from '../../../../lib/callbacks/beforeCreate import { getSubscriptionAutotranslateDefaultConfig } from '../../../../server/lib/getSubscriptionAutotranslateDefaultConfig'; import { getDefaultSubscriptionPref } from '../../../utils/lib/getDefaultSubscriptionPref'; import { getValidRoomName } from '../../../utils/server/lib/getValidRoomName'; -import { notifyOnRoomChanged } from '../lib/notifyListener'; +import { notifyOnRoomChanged, notifyOnSubscriptionChangedById } from '../lib/notifyListener'; import { createDirectRoom } from './createDirectRoom'; const isValidName = (name: unknown): name is string => { @@ -47,7 +47,11 @@ async function createUsersSubscriptions({ ...getDefaultSubscriptionPref(owner), }; - await Subscriptions.createWithRoomAndUser(room, owner, extra); + const { insertedId } = await Subscriptions.createWithRoomAndUser(room, owner, extra); + + if (insertedId) { + await notifyOnRoomChanged(room, 'inserted'); + } return; } @@ -98,7 +102,9 @@ async function createUsersSubscriptions({ await Users.addRoomByUserIds(memberIds, room._id); } - await Subscriptions.createWithRoomAndManyUsers(room, subs); + const { insertedIds } = await Subscriptions.createWithRoomAndManyUsers(room, subs); + + Object.values(insertedIds).forEach((subId) => notifyOnSubscriptionChangedById(subId, 'inserted')); await Rooms.incUsersCountById(room._id, subs.length); } @@ -224,6 +230,13 @@ export const createRoom = async ( Object.assign(roomProps, eventResult); } + const shouldBeHandledByFederation = roomProps.federated === true || owner.username.includes(':'); + + if (shouldBeHandledByFederation) { + const federation = (await License.hasValidLicense()) ? FederationEE : Federation; + await federation.beforeCreateRoom(roomProps); + } + if (type === 'c') { await callbacks.run('beforeCreateChannel', owner, roomProps); } @@ -232,8 +245,6 @@ export const createRoom = async ( void notifyOnRoomChanged(room, 'inserted'); - const shouldBeHandledByFederation = room.federated === true || owner.username.includes(':'); - await createUsersSubscriptions({ room, members, now, owner, options, shouldBeHandledByFederation }); if (type === 'c') { diff --git a/apps/meteor/app/lib/server/functions/deleteMessage.ts b/apps/meteor/app/lib/server/functions/deleteMessage.ts index 04542d5f1d27..a91e77858043 100644 --- a/apps/meteor/app/lib/server/functions/deleteMessage.ts +++ b/apps/meteor/app/lib/server/functions/deleteMessage.ts @@ -1,5 +1,5 @@ import { AppEvents, Apps } from '@rocket.chat/apps'; -import { api } from '@rocket.chat/core-services'; +import { api, Message } from '@rocket.chat/core-services'; import type { AtLeast, IMessage, IUser } from '@rocket.chat/core-typings'; import { Messages, Rooms, Uploads, Users, ReadReceipts } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; @@ -35,10 +35,18 @@ export async function deleteMessage(message: IMessage, user: IUser): Promise { await FileUpload.removeFilesByRoomId(rid); + await Messages.removeByRoomId(rid); + await callbacks.run('beforeDeleteRoom', rid); - await Subscriptions.removeByRoomId(rid); + + await Subscriptions.removeByRoomId(rid, { + async onTrash(doc) { + void notifyOnSubscriptionChanged(doc, 'removed'); + }, + }); + await FileUpload.getStore('Avatars').deleteByRoomId(rid); + await callbacks.run('afterDeleteRoom', rid); - await Rooms.removeById(rid); - void notifyOnRoomChangedById(rid, 'removed'); + const { deletedCount } = await Rooms.removeById(rid); + if (deletedCount) { + void notifyOnRoomChangedById(rid, 'removed'); + } }; diff --git a/apps/meteor/app/lib/server/functions/deleteUser.ts b/apps/meteor/app/lib/server/functions/deleteUser.ts index e66c8c2d5eef..483085d40811 100644 --- a/apps/meteor/app/lib/server/functions/deleteUser.ts +++ b/apps/meteor/app/lib/server/functions/deleteUser.ts @@ -112,7 +112,7 @@ export async function deleteUser(userId: string, confirmRelinquish = false, dele const rids = subscribedRooms.map((room) => room.rid); void notifyOnRoomChangedById(rids); - await Subscriptions.removeByUserId(userId); // Remove user subscriptions + await Subscriptions.removeByUserId(userId); // Remove user as livechat agent if (user.roles.includes('livechat-agent')) { diff --git a/apps/meteor/app/lib/server/functions/relinquishRoomOwnerships.ts b/apps/meteor/app/lib/server/functions/relinquishRoomOwnerships.ts index 75b232462077..8f1981ca386d 100644 --- a/apps/meteor/app/lib/server/functions/relinquishRoomOwnerships.ts +++ b/apps/meteor/app/lib/server/functions/relinquishRoomOwnerships.ts @@ -1,6 +1,7 @@ import { Messages, Roles, Rooms, Subscriptions, ReadReceipts } from '@rocket.chat/models'; import { FileUpload } from '../../../file-upload/server'; +import { notifyOnSubscriptionChanged } from '../lib/notifyListener'; import type { SubscribedRoomsForUserWithDetails } from './getRoomsWithSingleOwner'; const bulkRoomCleanUp = async (rids: string[]): Promise => { @@ -8,7 +9,11 @@ const bulkRoomCleanUp = async (rids: string[]): Promise => { await Promise.all(rids.map((rid) => FileUpload.removeFilesByRoomId(rid))); return Promise.all([ - Subscriptions.removeByRoomIds(rids), + Subscriptions.removeByRoomIds(rids, { + async onTrash(doc) { + void notifyOnSubscriptionChanged(doc, 'removed'); + }, + }), Messages.removeByRoomIds(rids), ReadReceipts.removeByRoomIds(rids), Rooms.removeByIds(rids), diff --git a/apps/meteor/app/lib/server/functions/removeUserFromRoom.ts b/apps/meteor/app/lib/server/functions/removeUserFromRoom.ts index c55ee382f10c..5800cb68af81 100644 --- a/apps/meteor/app/lib/server/functions/removeUserFromRoom.ts +++ b/apps/meteor/app/lib/server/functions/removeUserFromRoom.ts @@ -1,6 +1,6 @@ import { Apps, AppEvents } from '@rocket.chat/apps'; import { AppsEngineException } from '@rocket.chat/apps-engine/definition/exceptions'; -import { Message, Team } from '@rocket.chat/core-services'; +import { Message, Team, Room } from '@rocket.chat/core-services'; import type { IUser } from '@rocket.chat/core-typings'; import { Subscriptions, Rooms } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; @@ -8,7 +8,7 @@ import { Meteor } from 'meteor/meteor'; import { afterLeaveRoomCallback } from '../../../../lib/callbacks/afterLeaveRoomCallback'; import { beforeLeaveRoomCallback } from '../../../../lib/callbacks/beforeLeaveRoomCallback'; import { settings } from '../../../settings/server'; -import { notifyOnRoomChangedById } from '../lib/notifyListener'; +import { notifyOnRoomChangedById, notifyOnSubscriptionChanged } from '../lib/notifyListener'; export const removeUserFromRoom = async function (rid: string, user: IUser, options?: { byUser: IUser }): Promise { const room = await Rooms.findOneById(rid); @@ -27,6 +27,9 @@ export const removeUserFromRoom = async function (rid: string, user: IUser, opti throw error; } + await Room.beforeLeave(room); + + // TODO: move before callbacks to service await beforeLeaveRoomCallback.run(user, room); const subscription = await Subscriptions.findOneByRoomIdAndUserId(rid, user._id, { @@ -56,7 +59,10 @@ export const removeUserFromRoom = async function (rid: string, user: IUser, opti await Message.saveSystemMessage('command', rid, 'survey', user); } - await Subscriptions.removeByRoomIdAndUserId(rid, user._id); + const deletedSubscription = await Subscriptions.removeByRoomIdAndUserId(rid, user._id); + if (deletedSubscription) { + void notifyOnSubscriptionChanged(deletedSubscription, 'removed'); + } if (room.teamId && room.teamMain) { await Team.removeMember(room.teamId, user._id); diff --git a/apps/meteor/app/lib/server/functions/saveCustomFieldsWithoutValidation.ts b/apps/meteor/app/lib/server/functions/saveCustomFieldsWithoutValidation.ts index 4a0ac005e55c..5383048f13bd 100644 --- a/apps/meteor/app/lib/server/functions/saveCustomFieldsWithoutValidation.ts +++ b/apps/meteor/app/lib/server/functions/saveCustomFieldsWithoutValidation.ts @@ -5,6 +5,7 @@ import type { UpdateFilter } from 'mongodb'; import { trim } from '../../../../lib/utils/stringUtils'; import { settings } from '../../../settings/server'; +import { notifyOnSubscriptionChangedByUserIdAndRoomType } from '../lib/notifyListener'; export const saveCustomFieldsWithoutValidation = async function (userId: string, formData: Record): Promise { if (trim(settings.get('Accounts_CustomFields')) !== '') { @@ -22,7 +23,10 @@ export const saveCustomFieldsWithoutValidation = async function (userId: string, await Users.setCustomFields(userId, customFields); // Update customFields of all Direct Messages' Rooms for userId - await Subscriptions.setCustomFieldsDirectMessagesByUserId(userId, customFields); + const setCustomFieldsResponse = await Subscriptions.setCustomFieldsDirectMessagesByUserId(userId, customFields); + if (setCustomFieldsResponse.modifiedCount) { + void notifyOnSubscriptionChangedByUserIdAndRoomType(userId, 'd'); + } for await (const fieldName of Object.keys(customFields)) { if (!customFieldsMeta[fieldName].modifyRecordField) { diff --git a/apps/meteor/app/lib/server/functions/saveUserIdentity.ts b/apps/meteor/app/lib/server/functions/saveUserIdentity.ts index 0b9ff21e53e3..1729a1ba8abd 100644 --- a/apps/meteor/app/lib/server/functions/saveUserIdentity.ts +++ b/apps/meteor/app/lib/server/functions/saveUserIdentity.ts @@ -3,7 +3,11 @@ import { Messages, VideoConference, LivechatDepartmentAgents, Rooms, Subscriptio import { SystemLogger } from '../../../../server/lib/logger/system'; import { FileUpload } from '../../../file-upload/server'; -import { notifyOnRoomChangedByUsernamesOrUids } from '../lib/notifyListener'; +import { + notifyOnRoomChangedByUsernamesOrUids, + notifyOnSubscriptionChangedByUserId, + notifyOnSubscriptionChangedByNameAndRoomType, +} from '../lib/notifyListener'; import { _setRealName } from './setRealName'; import { _setUsername } from './setUsername'; import { updateGroupDMsName } from './updateGroupDMsName'; @@ -129,20 +133,38 @@ async function updateUsernameReferences({ await Messages.updateUsernameAndMessageOfMentionByIdAndOldUsername(msg._id, previousUsername, username, updatedMsg); } - await Rooms.replaceUsername(previousUsername, username); - await Rooms.replaceMutedUsername(previousUsername, username); - await Rooms.replaceUsernameOfUserByUserId(user._id, username); - await Subscriptions.setUserUsernameByUserId(user._id, username); + const responses = await Promise.all([ + Rooms.replaceUsername(previousUsername, username), + Rooms.replaceMutedUsername(previousUsername, username), + Rooms.replaceUsernameOfUserByUserId(user._id, username), + Subscriptions.setUserUsernameByUserId(user._id, username), + LivechatDepartmentAgents.replaceUsernameOfAgentByUserId(user._id, username), + ]); - await LivechatDepartmentAgents.replaceUsernameOfAgentByUserId(user._id, username); + if (responses[3]?.modifiedCount) { + void notifyOnSubscriptionChangedByUserId(user._id); + } - void notifyOnRoomChangedByUsernamesOrUids([user._id], [previousUsername, username]); + if (responses[0]?.modifiedCount || responses[1]?.modifiedCount || responses[2]?.modifiedCount) { + void notifyOnRoomChangedByUsernamesOrUids([user._id], [previousUsername, username]); + } } // update other references if either the name or username has changed if (usernameChanged || nameChanged) { // update name and fname of 1-on-1 direct messages - await Subscriptions.updateDirectNameAndFnameByName(previousUsername, rawUsername && username, rawName && name); + const updateDirectNameResponse = await Subscriptions.updateDirectNameAndFnameByName( + previousUsername, + rawUsername && username, + rawName && name, + ); + + if (updateDirectNameResponse?.modifiedCount) { + void notifyOnSubscriptionChangedByNameAndRoomType({ + t: 'd', + name: username, + }); + } // update name and fname of group direct messages await updateGroupDMsName(user); diff --git a/apps/meteor/app/lib/server/functions/setUserActiveStatus.ts b/apps/meteor/app/lib/server/functions/setUserActiveStatus.ts index 9d7a3e113fc4..929c24210d2d 100644 --- a/apps/meteor/app/lib/server/functions/setUserActiveStatus.ts +++ b/apps/meteor/app/lib/server/functions/setUserActiveStatus.ts @@ -9,7 +9,12 @@ import { Meteor } from 'meteor/meteor'; import { callbacks } from '../../../../lib/callbacks'; import * as Mailer from '../../../mailer/server/api'; import { settings } from '../../../settings/server'; -import { notifyOnRoomChangedById, notifyOnRoomChangedByUserDM, notifyOnUserChange } from '../lib/notifyListener'; +import { + notifyOnRoomChangedById, + notifyOnRoomChangedByUserDM, + notifyOnSubscriptionChangedByNameAndRoomType, + notifyOnUserChange, +} from '../lib/notifyListener'; import { closeOmnichannelConversations } from './closeOmnichannelConversations'; import { shouldRemoveOrChangeOwner, getSubscribedRoomsForUserWithDetails } from './getRoomsWithSingleOwner'; import { getUserSingleOwnedRooms } from './getUserSingleOwnedRooms'; @@ -39,8 +44,10 @@ async function reactivateDirectConversations(userId: string) { return acc; }, []); - await Rooms.setDmReadOnlyByUserId(userId, roomsToReactivate, false, false); - void notifyOnRoomChangedById(roomsToReactivate); + const setDmReadOnlyResponse = await Rooms.setDmReadOnlyByUserId(userId, roomsToReactivate, false, false); + if (setDmReadOnlyResponse.modifiedCount) { + void notifyOnRoomChangedById(roomsToReactivate); + } } export async function setUserActiveStatus(userId: string, active: boolean, confirmRelinquish = false): Promise { @@ -118,7 +125,10 @@ export async function setUserActiveStatus(userId: string, active: boolean, confi } if (user.username) { - await Subscriptions.setArchivedByUsername(user.username, !active); + const { modifiedCount } = await Subscriptions.setArchivedByUsername(user.username, !active); + if (modifiedCount) { + void notifyOnSubscriptionChangedByNameAndRoomType({ t: 'd', name: user.username }); + } } if (active === false) { diff --git a/apps/meteor/app/lib/server/functions/setUsername.ts b/apps/meteor/app/lib/server/functions/setUsername.ts index e19ef874db0f..5b2b1923da75 100644 --- a/apps/meteor/app/lib/server/functions/setUsername.ts +++ b/apps/meteor/app/lib/server/functions/setUsername.ts @@ -17,6 +17,7 @@ import { getAvatarSuggestionForUser } from './getAvatarSuggestionForUser'; import { joinDefaultChannels } from './joinDefaultChannels'; import { saveUserIdentity } from './saveUserIdentity'; import { setUserAvatar } from './setUserAvatar'; +import { validateUsername } from './validateUsername'; export const setUsernameWithValidation = async (userId: string, username: string, joinDefaultChannelsSilenced?: boolean): Promise => { if (!username) { @@ -37,14 +38,7 @@ export const setUsernameWithValidation = async (userId: string, username: string return; } - let nameValidation; - try { - nameValidation = new RegExp(`^${settings.get('UTF8_User_Names_Validation')}$`); - } catch (error) { - nameValidation = new RegExp('^[0-9a-zA-Z-_.]+$'); - } - - if (!nameValidation.test(username)) { + if (!validateUsername(username)) { throw new Meteor.Error( 'username-invalid', `${_.escape(username)} is not a valid username, use only letters, numbers, dots, hyphens and underscores`, @@ -74,18 +68,15 @@ export const setUsernameWithValidation = async (userId: string, username: string export const _setUsername = async function (userId: string, u: string, fullUser: IUser): Promise { const username = u.trim(); + if (!userId || !username) { return false; } - let nameValidation; - try { - nameValidation = new RegExp(`^${settings.get('UTF8_User_Names_Validation')}$`); - } catch (error) { - nameValidation = new RegExp('^[0-9a-zA-Z-_.]+$'); - } - if (!nameValidation.test(username)) { + + if (!validateUsername(username)) { return false; } + const user = fullUser || (await Users.findOneById(userId)); // User already has desired username, return if (user.username === username) { diff --git a/apps/meteor/app/lib/server/functions/unarchiveRoom.ts b/apps/meteor/app/lib/server/functions/unarchiveRoom.ts index 7db86ed933a3..699f9c3701b1 100644 --- a/apps/meteor/app/lib/server/functions/unarchiveRoom.ts +++ b/apps/meteor/app/lib/server/functions/unarchiveRoom.ts @@ -2,11 +2,16 @@ import { Message } from '@rocket.chat/core-services'; import type { IMessage } from '@rocket.chat/core-typings'; import { Rooms, Subscriptions } from '@rocket.chat/models'; -import { notifyOnRoomChangedById } from '../lib/notifyListener'; +import { notifyOnRoomChangedById, notifyOnSubscriptionChangedByRoomId } from '../lib/notifyListener'; export const unarchiveRoom = async function (rid: string, user: IMessage['u']): Promise { await Rooms.unarchiveById(rid); - await Subscriptions.unarchiveByRoomId(rid); + + const unarchiveResponse = await Subscriptions.unarchiveByRoomId(rid); + if (unarchiveResponse.modifiedCount) { + void notifyOnSubscriptionChangedByRoomId(rid); + } + await Message.saveSystemMessage('room-unarchived', rid, '', user); void notifyOnRoomChangedById(rid); diff --git a/apps/meteor/app/lib/server/functions/updateGroupDMsName.ts b/apps/meteor/app/lib/server/functions/updateGroupDMsName.ts index a0ad2eedcf55..feb26ce6a1b0 100644 --- a/apps/meteor/app/lib/server/functions/updateGroupDMsName.ts +++ b/apps/meteor/app/lib/server/functions/updateGroupDMsName.ts @@ -1,6 +1,8 @@ import type { IUser } from '@rocket.chat/core-typings'; import { Rooms, Subscriptions, Users } from '@rocket.chat/models'; +import { notifyOnSubscriptionChangedByRoomId } from '../lib/notifyListener'; + const getFname = (members: IUser[]): string => members.map(({ name, username }) => name || username).join(', '); const getName = (members: IUser[]): string => members.map(({ username }) => username).join(','); @@ -63,7 +65,10 @@ export const updateGroupDMsName = async (userThatChangedName: IUser): Promise _id !== sub.u._id); - await Subscriptions.updateNameAndFnameById(sub._id, getName(otherMembers), getFname(otherMembers)); + const updateNameRespose = await Subscriptions.updateNameAndFnameById(sub._id, getName(otherMembers), getFname(otherMembers)); + if (updateNameRespose.modifiedCount) { + void notifyOnSubscriptionChangedByRoomId(room._id); + } } } }; diff --git a/apps/meteor/app/lib/server/functions/validateUsername.ts b/apps/meteor/app/lib/server/functions/validateUsername.ts new file mode 100644 index 000000000000..523667282d22 --- /dev/null +++ b/apps/meteor/app/lib/server/functions/validateUsername.ts @@ -0,0 +1,15 @@ +import { settings } from '../../../settings/server'; + +export const validateUsername = (username: string): boolean => { + const settingsRegExp = settings.get('UTF8_User_Names_Validation'); + const defaultPattern = /^[0-9a-zA-Z-_.]+$/; + + let usernameRegExp: RegExp; + try { + usernameRegExp = settingsRegExp ? new RegExp(`^${settingsRegExp}$`) : defaultPattern; + } catch (e) { + usernameRegExp = defaultPattern; + } + + return usernameRegExp.test(username); +}; diff --git a/apps/meteor/app/lib/server/index.ts b/apps/meteor/app/lib/server/index.ts index 80aaa2a64a9e..49fad2002c75 100644 --- a/apps/meteor/app/lib/server/index.ts +++ b/apps/meteor/app/lib/server/index.ts @@ -49,5 +49,6 @@ import './methods/unarchiveRoom'; import './methods/unblockUser'; import './methods/updateMessage'; import './methods/saveCustomFields'; +import './methods/checkFederationConfiguration'; export * from './lib'; diff --git a/apps/meteor/app/lib/server/lib/notifyListener.ts b/apps/meteor/app/lib/server/lib/notifyListener.ts index 934742945f2d..778fe89dbbf4 100644 --- a/apps/meteor/app/lib/server/lib/notifyListener.ts +++ b/apps/meteor/app/lib/server/lib/notifyListener.ts @@ -15,6 +15,7 @@ import type { IEmailInbox, IIntegrationHistory, AtLeast, + ISubscription, ISettingColor, IUser, IMessage, @@ -30,6 +31,7 @@ import { Integrations, LoginServiceConfiguration, IntegrationHistory, + Subscriptions, LivechatInquiry, LivechatDepartmentAgents, Users, @@ -37,6 +39,7 @@ import { } from '@rocket.chat/models'; import mem from 'mem'; +import { subscriptionFields } from '../../../../lib/publishFields'; import { shouldHideSystemMessage } from '../../../../server/lib/systemMessage/hideSystemMessage'; type ClientAction = 'inserted' | 'updated' | 'removed'; @@ -467,3 +470,119 @@ export const notifyOnMessageChange = withDbWatcherCheck(async ({ id, data }: { i } void api.broadcast('watch.messages', { message }); }); + +export const notifyOnSubscriptionChanged = withDbWatcherCheck( + async (subscription: ISubscription, clientAction: ClientAction = 'updated'): Promise => { + void api.broadcast('watch.subscriptions', { clientAction, subscription }); + }, +); + +export const notifyOnSubscriptionChangedByRoomIdAndUserId = withDbWatcherCheck( + async ( + rid: ISubscription['rid'], + uid: ISubscription['u']['_id'], + clientAction: Exclude = 'updated', + ): Promise => { + const cursor = Subscriptions.findByUserIdAndRoomIds(uid, [rid], { projection: subscriptionFields }); + + void cursor.forEach((subscription) => { + void api.broadcast('watch.subscriptions', { clientAction, subscription }); + }); + }, +); + +export const notifyOnSubscriptionChangedById = withDbWatcherCheck( + async (id: ISubscription['_id'], clientAction: Exclude = 'updated'): Promise => { + const subscription = await Subscriptions.findOneById(id); + if (!subscription) { + return; + } + + void api.broadcast('watch.subscriptions', { clientAction, subscription }); + }, +); + +export const notifyOnSubscriptionChangedByUserPreferences = withDbWatcherCheck( + async ( + uid: ISubscription['u']['_id'], + notificationOriginField: keyof ISubscription, + originFieldNotEqualValue: 'user' | 'subscription', + clientAction: Exclude = 'updated', + ): Promise => { + const cursor = Subscriptions.findByUserPreferences(uid, notificationOriginField, originFieldNotEqualValue, { + projection: subscriptionFields, + }); + + void cursor.forEach((subscription) => { + void api.broadcast('watch.subscriptions', { clientAction, subscription }); + }); + }, +); + +export const notifyOnSubscriptionChangedByRoomId = withDbWatcherCheck( + async (rid: ISubscription['rid'], clientAction: Exclude = 'updated'): Promise => { + const cursor = Subscriptions.findByRoomId(rid, { projection: subscriptionFields }); + + void cursor.forEach((subscription) => { + void api.broadcast('watch.subscriptions', { clientAction, subscription }); + }); + }, +); + +export const notifyOnSubscriptionChangedByAutoTranslateAndUserId = withDbWatcherCheck( + async (uid: ISubscription['u']['_id'], clientAction: Exclude = 'updated'): Promise => { + const cursor = Subscriptions.findByAutoTranslateAndUserId(uid, true, { projection: subscriptionFields }); + + void cursor.forEach((subscription) => { + void api.broadcast('watch.subscriptions', { clientAction, subscription }); + }); + }, +); + +export const notifyOnSubscriptionChangedByUserIdAndRoomType = withDbWatcherCheck( + async ( + uid: ISubscription['u']['_id'], + t: ISubscription['t'], + clientAction: Exclude = 'updated', + ): Promise => { + const cursor = Subscriptions.findByUserIdAndRoomType(uid, t, { projection: subscriptionFields }); + + void cursor.forEach((subscription) => { + void api.broadcast('watch.subscriptions', { clientAction, subscription }); + }); + }, +); + +export const notifyOnSubscriptionChangedByNameAndRoomType = withDbWatcherCheck( + async (filter: Partial>, clientAction: Exclude = 'updated'): Promise => { + const cursor = Subscriptions.findByNameAndRoomType(filter, { projection: subscriptionFields }); + + void cursor.forEach((subscription) => { + void api.broadcast('watch.subscriptions', { clientAction, subscription }); + }); + }, +); + +export const notifyOnSubscriptionChangedByUserId = withDbWatcherCheck( + async (uid: ISubscription['u']['_id'], clientAction: Exclude = 'updated'): Promise => { + const cursor = Subscriptions.findByUserId(uid, { projection: subscriptionFields }); + + void cursor.forEach((subscription) => { + void api.broadcast('watch.subscriptions', { clientAction, subscription }); + }); + }, +); + +export const notifyOnSubscriptionChangedByRoomIdAndUserIds = withDbWatcherCheck( + async ( + rid: ISubscription['rid'], + uids: ISubscription['u']['_id'][], + clientAction: Exclude = 'updated', + ): Promise => { + const cursor = Subscriptions.findByRoomIdAndUserIds(rid, uids, { projection: subscriptionFields }); + + void cursor.forEach((subscription) => { + void api.broadcast('watch.subscriptions', { clientAction, subscription }); + }); + }, +); diff --git a/apps/meteor/app/lib/server/lib/notifyUsersOnMessage.ts b/apps/meteor/app/lib/server/lib/notifyUsersOnMessage.ts index 85f2ac52b702..7551cabb6e63 100644 --- a/apps/meteor/app/lib/server/lib/notifyUsersOnMessage.ts +++ b/apps/meteor/app/lib/server/lib/notifyUsersOnMessage.ts @@ -7,6 +7,11 @@ import moment from 'moment'; import { callbacks } from '../../../../lib/callbacks'; import { settings } from '../../../settings/server'; +import { + notifyOnSubscriptionChanged, + notifyOnSubscriptionChangedByRoomIdAndUserId, + notifyOnSubscriptionChangedByRoomIdAndUserIds, +} from './notifyListener'; function messageContainsHighlight(message: IMessage, highlights: string[]): boolean { if (!highlights || highlights.length === 0) return false; @@ -51,26 +56,14 @@ export async function getMentions(message: IMessage): Promise<{ toAll: boolean; type UnreadCountType = 'all_messages' | 'user_mentions_only' | 'group_mentions_only' | 'user_and_group_mentions_only'; -const incGroupMentions = async ( - rid: IRoom['_id'], - roomType: RoomType, - excludeUserId: IUser['_id'], - unreadCount: Exclude, -): Promise => { +const getGroupMentions = (roomType: RoomType, unreadCount: Exclude): number => { const incUnreadByGroup = ['all_messages', 'group_mentions_only', 'user_and_group_mentions_only'].includes(unreadCount); - const incUnread = roomType === 'd' || roomType === 'l' || incUnreadByGroup ? 1 : 0; - await Subscriptions.incGroupMentionsAndUnreadForRoomIdExcludingUserId(rid, excludeUserId, 1, incUnread); + return roomType === 'd' || roomType === 'l' || incUnreadByGroup ? 1 : 0; }; -const incUserMentions = async ( - rid: IRoom['_id'], - roomType: RoomType, - uids: IUser['_id'][], - unreadCount: Exclude, -): Promise => { - const incUnreadByUser = new Set(['all_messages', 'user_mentions_only', 'user_and_group_mentions_only']).has(unreadCount); - const incUnread = roomType === 'd' || roomType === 'l' || incUnreadByUser ? 1 : 0; - await Subscriptions.incUserMentionsAndUnreadForRoomIdAndUserIds(rid, uids, 1, incUnread); +const getUserMentions = (roomType: RoomType, unreadCount: Exclude): number => { + const incUnreadByUser = ['all_messages', 'user_mentions_only', 'user_and_group_mentions_only'].includes(unreadCount); + return roomType === 'd' || roomType === 'l' || incUnreadByUser ? 1 : 0; }; export const getUserIdsFromHighlights = async (rid: IRoom['_id'], message: IMessage): Promise => { @@ -101,45 +94,77 @@ const getUnreadSettingCount = (roomType: RoomType): UnreadCountType => { }; async function updateUsersSubscriptions(message: IMessage, room: IRoom): Promise { - // Don't increase unread counter on thread messages - if (room != null && !message.tmid) { - const { toAll, toHere, mentionIds } = await getMentions(message); - - const userIds = new Set(mentionIds); - - const unreadCount = getUnreadSettingCount(room.t); + if (!room || message.tmid) { + return; + } - (await getUserIdsFromHighlights(room._id, message)).forEach((uid) => userIds.add(uid)); + const [mentions, highlightIds] = await Promise.all([getMentions(message), getUserIdsFromHighlights(room._id, message)]); + + const { toAll, toHere, mentionIds } = mentions; + const userIds = [...new Set([...mentionIds, ...highlightIds])]; + const unreadCount = getUnreadSettingCount(room.t); + + const userMentionInc = getUserMentions(room.t, unreadCount as Exclude); + const groupMentionInc = getGroupMentions(room.t, unreadCount as Exclude); + + void Subscriptions.findByRoomIdAndNotAlertOrOpenExcludingUserIds({ + roomId: room._id, + uidsExclude: [message.u._id], + uidsInclude: userIds, + onlyRead: !toAll && !toHere, + }).forEach((sub) => { + const hasUserMention = userIds.includes(sub.u._id); + const shouldIncUnread = hasUserMention || toAll || toHere || unreadCount === 'all_messages'; + void notifyOnSubscriptionChanged( + { + ...sub, + alert: true, + open: true, + ...(shouldIncUnread && { unread: sub.unread + 1 }), + ...(hasUserMention && { userMentions: sub.userMentions + 1 }), + ...((toAll || toHere) && { groupMentions: sub.groupMentions + 1 }), + }, + 'updated', + ); + }); - // Give priority to user mentions over group mentions - if (userIds.size > 0) { - await incUserMentions(room._id, room.t, [...userIds], unreadCount as Exclude); - } else if (toAll || toHere) { - await incGroupMentions(room._id, room.t, message.u._id, unreadCount as Exclude); - } + // Give priority to user mentions over group mentions + if (userIds.length) { + await Subscriptions.incUserMentionsAndUnreadForRoomIdAndUserIds(room._id, userIds, 1, userMentionInc); + } else if (toAll || toHere) { + await Subscriptions.incGroupMentionsAndUnreadForRoomIdExcludingUserId(room._id, message.u._id, 1, groupMentionInc); + } - // this shouldn't run only if has group mentions because it will already exclude mentioned users from the query - if (!toAll && !toHere && unreadCount === 'all_messages') { - await Subscriptions.incUnreadForRoomIdExcludingUserIds(room._id, [...userIds, message.u._id], 1); - } + if (!toAll && !toHere && unreadCount === 'all_messages') { + await Subscriptions.incUnreadForRoomIdExcludingUserIds(room._id, [...userIds, message.u._id], 1); } - // Update all other subscriptions to alert their owners but without incrementing - // the unread counter, as it is only for mentions and direct messages - // We now set alert and open properties in two separate update commands. This proved to be more efficient on MongoDB - because it uses a more efficient index. + // update subscriptions of other members of the room await Promise.all([ Subscriptions.setAlertForRoomIdExcludingUserId(message.rid, message.u._id), Subscriptions.setOpenForRoomIdExcludingUserId(message.rid, message.u._id), ]); + + // update subscription of the message sender + await Subscriptions.setAsReadByRoomIdAndUserId(message.rid, message.u._id); + const setAsReadResponse = await Subscriptions.setAsReadByRoomIdAndUserId(message.rid, message.u._id); + if (setAsReadResponse.modifiedCount) { + void notifyOnSubscriptionChangedByRoomIdAndUserId(message.rid, message.u._id); + } } export async function updateThreadUsersSubscriptions(message: IMessage, replies: IUser['_id'][]): Promise { // Don't increase unread counter on thread messages - - await Subscriptions.setAlertForRoomIdAndUserIds(message.rid, replies); const repliesPlusSender = [...new Set([message.u._id, ...replies])]; - await Subscriptions.setOpenForRoomIdAndUserIds(message.rid, repliesPlusSender); - await Subscriptions.setLastReplyForRoomIdAndUserIds(message.rid, repliesPlusSender, new Date()); + + const responses = await Promise.all([ + Subscriptions.setAlertForRoomIdAndUserIds(message.rid, replies), + Subscriptions.setOpenForRoomIdAndUserIds(message.rid, repliesPlusSender), + Subscriptions.setLastReplyForRoomIdAndUserIds(message.rid, repliesPlusSender, new Date()), + ]); + + responses.some((response) => response?.modifiedCount) && + void notifyOnSubscriptionChangedByRoomIdAndUserIds(message.rid, repliesPlusSender); } export async function notifyUsersOnMessage(message: IMessage, room: IRoom, roomUpdater: Updater): Promise { diff --git a/apps/meteor/app/lib/server/methods/blockUser.ts b/apps/meteor/app/lib/server/methods/blockUser.ts index b967e35d7bc1..7fe6ec803dd1 100644 --- a/apps/meteor/app/lib/server/methods/blockUser.ts +++ b/apps/meteor/app/lib/server/methods/blockUser.ts @@ -5,6 +5,7 @@ import { Meteor } from 'meteor/meteor'; import { RoomMemberActions } from '../../../../definition/IRoomTypeConfig'; import { roomCoordinator } from '../../../../server/lib/rooms/roomCoordinator'; +import { notifyOnSubscriptionChangedByRoomIdAndUserIds } from '../lib/notifyListener'; declare module '@rocket.chat/ddp-client' { // eslint-disable-next-line @typescript-eslint/naming-convention @@ -33,14 +34,22 @@ Meteor.methods({ throw new Meteor.Error('error-invalid-room', 'Invalid room', { method: 'blockUser' }); } - const subscription = await Subscriptions.findOneByRoomIdAndUserId(rid, userId); - const subscription2 = await Subscriptions.findOneByRoomIdAndUserId(rid, blocked); + const [blockedUser, blockerUser] = await Promise.all([ + Subscriptions.findOneByRoomIdAndUserId(rid, blocked, { projection: { _id: 1 } }), + Subscriptions.findOneByRoomIdAndUserId(rid, userId, { projection: { _id: 1 } }), + ]); - if (!subscription || !subscription2) { + if (!blockedUser || !blockerUser) { throw new Meteor.Error('error-invalid-room', 'Invalid room', { method: 'blockUser' }); } - await Subscriptions.setBlockedByRoomId(rid, blocked, userId); + const [blockedResponse, blockerResponse] = await Subscriptions.setBlockedByRoomId(rid, blocked, userId); + + const listenerUsers = [...(blockedResponse?.modifiedCount ? [blocked] : []), ...(blockerResponse?.modifiedCount ? [userId] : [])]; + + if (listenerUsers.length) { + void notifyOnSubscriptionChangedByRoomIdAndUserIds(rid, listenerUsers); + } return true; }, diff --git a/apps/meteor/app/lib/server/methods/checkFederationConfiguration.ts b/apps/meteor/app/lib/server/methods/checkFederationConfiguration.ts new file mode 100644 index 000000000000..e32f2ab5d7af --- /dev/null +++ b/apps/meteor/app/lib/server/methods/checkFederationConfiguration.ts @@ -0,0 +1,80 @@ +import { Federation, FederationEE, Authorization } from '@rocket.chat/core-services'; +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { License } from '@rocket.chat/license'; +import { Meteor } from 'meteor/meteor'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + checkFederationConfiguration(): Promise<{ message: string }>; + } +} + +Meteor.methods({ + async checkFederationConfiguration() { + const uid = Meteor.userId(); + + if (!uid) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'checkFederationConfiguration', + }); + } + + if (!(await Authorization.hasPermission(uid, 'view-privileged-setting'))) { + throw new Meteor.Error('error-not-allowed', 'Action not allowed', { + method: 'checkFederationConfiguration', + }); + } + + const errors: string[] = []; + + const successes: string[] = []; + + const service = License.hasValidLicense() ? FederationEE : Federation; + + const status = await service.configurationStatus(); + + if (status.externalReachability.ok) { + successes.push('homeserver configuration looks good'); + } else { + let err = 'external reachability could not be verified'; + + const { error } = status.externalReachability; + if (error) { + err += `, error: ${error}`; + } + + errors.push(err); + } + + const { + roundTrip: { durationMs: duration }, + } = status.appservice; + + if (status.appservice.ok) { + successes.push(`appservice configuration looks good, total round trip time to homeserver ${duration}ms`); + } else { + errors.push(`failed to verify appservice configuration: ${status.appservice.error}`); + } + + if (errors.length) { + void service.markConfigurationInvalid(); + + if (successes.length) { + const message = ['Configuration could only be partially verified'].concat(successes).concat(errors).join(', '); + + throw new Meteor.Error('error-invalid-configuration', message, { method: 'checkFederationConfiguration' }); + } + + throw new Meteor.Error('error-invalid-configuration', ['Invalid configuration'].concat(errors).join(', '), { + method: 'checkFederationConfiguration', + }); + } + + void service.markConfigurationValid(); + + return { + message: ['All configuration looks good'].concat(successes).join(', '), + }; + }, +}); diff --git a/apps/meteor/app/lib/server/methods/unblockUser.ts b/apps/meteor/app/lib/server/methods/unblockUser.ts index 2eec5a082109..7b4bc5660010 100644 --- a/apps/meteor/app/lib/server/methods/unblockUser.ts +++ b/apps/meteor/app/lib/server/methods/unblockUser.ts @@ -3,6 +3,8 @@ import { Subscriptions } from '@rocket.chat/models'; import { check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; +import { notifyOnSubscriptionChangedByRoomIdAndUserIds } from '../lib/notifyListener'; + declare module '@rocket.chat/ddp-client' { // eslint-disable-next-line @typescript-eslint/naming-convention interface ServerMethods { @@ -20,14 +22,22 @@ Meteor.methods({ throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'blockUser' }); } - const subscription = await Subscriptions.findOneByRoomIdAndUserId(rid, userId); - const subscription2 = await Subscriptions.findOneByRoomIdAndUserId(rid, blocked); + const [blockedUser, blockerUser] = await Promise.all([ + Subscriptions.findOneByRoomIdAndUserId(rid, blocked, { projection: { _id: 1 } }), + Subscriptions.findOneByRoomIdAndUserId(rid, userId, { projection: { _id: 1 } }), + ]); - if (!subscription || !subscription2) { + if (!blockedUser || !blockerUser) { throw new Meteor.Error('error-invalid-room', 'Invalid room', { method: 'blockUser' }); } - await Subscriptions.unsetBlockedByRoomId(rid, blocked, userId); + const [blockedResponse, blockerResponse] = await Subscriptions.unsetBlockedByRoomId(rid, blocked, userId); + + const listenerUsers = [...(blockedResponse?.modifiedCount ? [blocked] : []), ...(blockerResponse?.modifiedCount ? [userId] : [])]; + + if (listenerUsers.length) { + void notifyOnSubscriptionChangedByRoomIdAndUserIds(rid, listenerUsers); + } return true; }, diff --git a/apps/meteor/app/livechat/client/lib/chartHandler.ts b/apps/meteor/app/livechat/client/lib/chartHandler.ts index 19c1a004ca22..da2d4be3735c 100644 --- a/apps/meteor/app/livechat/client/lib/chartHandler.ts +++ b/apps/meteor/app/livechat/client/lib/chartHandler.ts @@ -177,10 +177,9 @@ export const drawDoughnutChart = async ( chartContext: { destroy: () => void } | undefined, dataLabels: string[], dataPoints: number[], -): Promise | void> => { +): Promise => { if (!chart) { - console.error('No chart element'); - return; + throw new Error('No chart element'); } if (chartContext) { chartContext.destroy(); @@ -200,7 +199,7 @@ export const drawDoughnutChart = async ( ], }, options: doughnutChartConfiguration(title), - }); + }) as ChartType; }; /** @@ -209,12 +208,12 @@ export const drawDoughnutChart = async ( * @param {String} label [chart label] * @param {Array(Double)} data [updated data] */ -export const updateChart = async (c: ChartType, label: string, data: { [x: string]: number }): Promise => { +export const updateChart = async (c: ChartType, label: string, data: number[]): Promise => { const chart = await c; if (chart.data?.labels?.indexOf(label) === -1) { // insert data chart.data.labels.push(label); - chart.data.datasets.forEach((dataset: { data: any[] }, idx: string | number) => { + chart.data.datasets.forEach((dataset: { data: any[] }, idx: number) => { dataset.data.push(data[idx]); }); } else { @@ -224,7 +223,7 @@ export const updateChart = async (c: ChartType, label: string, data: { [x: strin return; } - chart.data.datasets.forEach((dataset: { data: { [x: string]: any } }, idx: string | number) => { + chart.data.datasets.forEach((dataset: { data: { [x: string]: any } }, idx: number) => { dataset.data[index] = data[idx]; }); } diff --git a/apps/meteor/app/livechat/server/api/lib/livechat.ts b/apps/meteor/app/livechat/server/api/lib/livechat.ts index 617d255cb6cb..a922edd40899 100644 --- a/apps/meteor/app/livechat/server/api/lib/livechat.ts +++ b/apps/meteor/app/livechat/server/api/lib/livechat.ts @@ -61,6 +61,10 @@ export function findGuest(token: string): Promise { }); } +export function findGuestWithoutActivity(token: string): Promise { + return LivechatVisitors.getVisitorByToken(token, { projection: { name: 1, username: 1, token: 1, visitorEmails: 1, department: 1 } }); +} + export async function findRoom(token: string, rid?: string): Promise { const fields = { t: 1, diff --git a/apps/meteor/app/livechat/server/api/v1/agent.ts b/apps/meteor/app/livechat/server/api/v1/agent.ts index 4c3cad33c130..abc6163fe9c9 100644 --- a/apps/meteor/app/livechat/server/api/v1/agent.ts +++ b/apps/meteor/app/livechat/server/api/v1/agent.ts @@ -6,6 +6,7 @@ import { isGETAgentNextToken, isPOSTLivechatAgentStatusProps } from '@rocket.cha import { API } from '../../../../api/server'; import { hasPermissionAsync } from '../../../../authorization/server/functions/hasPermission'; import { Livechat as LivechatTyped } from '../../lib/LivechatTyped'; +import { RoutingManager } from '../../lib/RoutingManager'; import { findRoom, findGuest, findAgent, findOpenRoom } from '../lib/livechat'; API.v1.addRoute('livechat/agent.info/:rid/:token', { @@ -48,7 +49,7 @@ API.v1.addRoute( } } - const agentData = await LivechatTyped.getNextAgent(department); + const agentData = await RoutingManager.getNextAgent(department); if (!agentData) { throw new Error('agent-not-found'); } diff --git a/apps/meteor/app/livechat/server/api/v1/config.ts b/apps/meteor/app/livechat/server/api/v1/config.ts index 79a6132136c5..17a2945e75de 100644 --- a/apps/meteor/app/livechat/server/api/v1/config.ts +++ b/apps/meteor/app/livechat/server/api/v1/config.ts @@ -2,8 +2,9 @@ import { isGETLivechatConfigParams } from '@rocket.chat/rest-typings'; import mem from 'mem'; import { API } from '../../../../api/server'; +import { settings as serverSettings } from '../../../../settings/server'; import { Livechat } from '../../lib/LivechatTyped'; -import { settings, findOpenRoom, getExtraConfigInfo, findAgent } from '../lib/livechat'; +import { settings, findOpenRoom, getExtraConfigInfo, findAgent, findGuestWithoutActivity } from '../lib/livechat'; const cachedSettings = mem(settings, { maxAge: process.env.TEST_MODE === 'true' ? 1 : 1000, cacheKey: JSON.stringify }); @@ -12,7 +13,7 @@ API.v1.addRoute( { validateParams: isGETLivechatConfigParams }, { async get() { - const enabled = Livechat.enabled(); + const enabled = serverSettings.get('Livechat_enabled'); if (!enabled) { return API.v1.success({ config: { enabled: false } }); @@ -23,7 +24,7 @@ API.v1.addRoute( const config = await cachedSettings({ businessUnit }); const status = await Livechat.online(department); - const guest = token ? await Livechat.findGuest(token) : null; + const guest = token ? await findGuestWithoutActivity(token) : null; const room = guest ? await findOpenRoom(guest.token) : undefined; const agent = guest && room && room.servedBy && (await findAgent(room.servedBy._id)); diff --git a/apps/meteor/app/livechat/server/api/v1/contact.ts b/apps/meteor/app/livechat/server/api/v1/contact.ts index 57c1d117f1b0..91b18a6b21af 100644 --- a/apps/meteor/app/livechat/server/api/v1/contact.ts +++ b/apps/meteor/app/livechat/server/api/v1/contact.ts @@ -1,14 +1,18 @@ import { LivechatCustomField, LivechatVisitors } from '@rocket.chat/models'; +import { isPOSTOmnichannelContactsProps } from '@rocket.chat/rest-typings'; import { escapeRegExp } from '@rocket.chat/string-helpers'; import { Match, check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; import { API } from '../../../../api/server'; -import { Contacts } from '../../lib/Contacts'; +import { Contacts, createContact } from '../../lib/Contacts'; API.v1.addRoute( 'omnichannel/contact', - { authRequired: true, permissionsRequired: ['view-l-room'] }, + { + authRequired: true, + permissionsRequired: ['view-l-room'], + }, { async post() { check(this.bodyParams, { @@ -82,3 +86,18 @@ API.v1.addRoute( }, }, ); + +API.v1.addRoute( + 'omnichannel/contacts', + { authRequired: true, permissionsRequired: ['create-livechat-contact'], validateParams: isPOSTOmnichannelContactsProps }, + { + async post() { + if (!process.env.TEST_MODE) { + throw new Meteor.Error('error-not-allowed', 'This endpoint is only allowed in test mode'); + } + const contactId = await createContact({ ...this.bodyParams, unknown: false }); + + return API.v1.success({ contactId }); + }, + }, +); diff --git a/apps/meteor/app/livechat/server/api/v1/transcript.ts b/apps/meteor/app/livechat/server/api/v1/transcript.ts index b36873a6ac27..e46e841628f1 100644 --- a/apps/meteor/app/livechat/server/api/v1/transcript.ts +++ b/apps/meteor/app/livechat/server/api/v1/transcript.ts @@ -6,6 +6,7 @@ import { isPOSTLivechatTranscriptParams, isPOSTLivechatTranscriptRequestParams } import { i18n } from '../../../../../server/lib/i18n'; import { API } from '../../../../api/server'; import { Livechat } from '../../lib/LivechatTyped'; +import { sendTranscript } from '../../lib/sendTranscript'; API.v1.addRoute( 'livechat/transcript', @@ -13,7 +14,7 @@ API.v1.addRoute( { async post() { const { token, rid, email } = this.bodyParams; - if (!(await Livechat.sendTranscript({ token, rid, email }))) { + if (!(await sendTranscript({ token, rid, email }))) { return API.v1.failure({ message: i18n.t('Error_sending_livechat_transcript') }); } diff --git a/apps/meteor/app/livechat/server/hooks/markRoomResponded.ts b/apps/meteor/app/livechat/server/hooks/markRoomResponded.ts index 69e9b11c57b9..6820bd4664bd 100644 --- a/apps/meteor/app/livechat/server/hooks/markRoomResponded.ts +++ b/apps/meteor/app/livechat/server/hooks/markRoomResponded.ts @@ -1,5 +1,5 @@ import type { IOmnichannelRoom, IMessage } from '@rocket.chat/core-typings'; -import { isEditedMessage, isMessageFromVisitor } from '@rocket.chat/core-typings'; +import { isEditedMessage, isMessageFromVisitor, isSystemMessage } from '@rocket.chat/core-typings'; import type { Updater } from '@rocket.chat/models'; import { LivechatRooms, LivechatVisitors, LivechatInquiry } from '@rocket.chat/models'; import moment from 'moment'; @@ -12,7 +12,7 @@ export async function markRoomResponded( room: IOmnichannelRoom, roomUpdater: Updater, ): Promise { - if (message.t || isEditedMessage(message) || isMessageFromVisitor(message)) { + if (isSystemMessage(message) || isEditedMessage(message) || isMessageFromVisitor(message)) { return; } @@ -62,7 +62,7 @@ export async function markRoomResponded( callbacks.add( 'afterOmnichannelSaveMessage', async (message, { room, roomUpdater }) => { - if (!message || message.t || isEditedMessage(message) || isMessageFromVisitor(message)) { + if (!message || isEditedMessage(message) || isMessageFromVisitor(message) || isSystemMessage(message)) { return; } diff --git a/apps/meteor/app/livechat/server/hooks/saveAnalyticsData.ts b/apps/meteor/app/livechat/server/hooks/saveAnalyticsData.ts index 109f49f440b5..9553e9fe981b 100644 --- a/apps/meteor/app/livechat/server/hooks/saveAnalyticsData.ts +++ b/apps/meteor/app/livechat/server/hooks/saveAnalyticsData.ts @@ -1,4 +1,4 @@ -import { isEditedMessage, isMessageFromVisitor } from '@rocket.chat/core-typings'; +import { isEditedMessage, isMessageFromVisitor, isSystemMessage } from '@rocket.chat/core-typings'; import type { IOmnichannelRoom } from '@rocket.chat/core-typings'; import { LivechatRooms } from '@rocket.chat/models'; @@ -62,7 +62,7 @@ const getAnalyticsData = (room: IOmnichannelRoom, now: Date): Record { - if (!message || isEditedMessage(message)) { + if (!message || isEditedMessage(message) || isSystemMessage(message)) { return message; } diff --git a/apps/meteor/app/livechat/server/hooks/saveContactLastChat.ts b/apps/meteor/app/livechat/server/hooks/saveContactLastChat.ts index 6f42a910417d..9969f03bf8bb 100644 --- a/apps/meteor/app/livechat/server/hooks/saveContactLastChat.ts +++ b/apps/meteor/app/livechat/server/hooks/saveContactLastChat.ts @@ -1,7 +1,7 @@ import { isOmnichannelRoom } from '@rocket.chat/core-typings'; +import { LivechatVisitors } from '@rocket.chat/models'; import { callbacks } from '../../../../lib/callbacks'; -import { Livechat } from '../lib/LivechatTyped'; callbacks.add( 'livechat.newRoom', @@ -19,7 +19,7 @@ callbacks.add( _id, ts: new Date(), }; - await Livechat.updateLastChat(guestId, lastChat); + await LivechatVisitors.setLastChatById(guestId, lastChat); }, callbacks.priority.MEDIUM, 'livechat-save-last-chat', diff --git a/apps/meteor/app/livechat/server/hooks/sendEmailTranscriptOnClose.ts b/apps/meteor/app/livechat/server/hooks/sendEmailTranscriptOnClose.ts index fcc0e0228bc7..f6a35f4dd7f9 100644 --- a/apps/meteor/app/livechat/server/hooks/sendEmailTranscriptOnClose.ts +++ b/apps/meteor/app/livechat/server/hooks/sendEmailTranscriptOnClose.ts @@ -3,8 +3,8 @@ import { isOmnichannelRoom } from '@rocket.chat/core-typings'; import { LivechatRooms } from '@rocket.chat/models'; import { callbacks } from '../../../../lib/callbacks'; -import { Livechat } from '../lib/LivechatTyped'; import type { CloseRoomParams } from '../lib/LivechatTyped'; +import { sendTranscript } from '../lib/sendTranscript'; type LivechatCloseCallbackParams = { room: IOmnichannelRoom; @@ -30,10 +30,7 @@ const sendEmailTranscriptOnClose = async (params: LivechatCloseCallbackParams): const { email, subject, requestedBy: user } = transcriptData; - await Promise.all([ - Livechat.sendTranscript({ token, rid, email, subject, user }), - LivechatRooms.unsetEmailTranscriptRequestedByRoomId(rid), - ]); + await Promise.all([sendTranscript({ token, rid, email, subject, user }), LivechatRooms.unsetEmailTranscriptRequestedByRoomId(rid)]); delete room.transcriptRequest; diff --git a/apps/meteor/app/livechat/server/lib/AnalyticsTyped.ts b/apps/meteor/app/livechat/server/lib/AnalyticsTyped.ts index 3b7c6a3051bf..c0be707ba212 100644 --- a/apps/meteor/app/livechat/server/lib/AnalyticsTyped.ts +++ b/apps/meteor/app/livechat/server/lib/AnalyticsTyped.ts @@ -1,7 +1,10 @@ import { OmnichannelAnalytics } from '@rocket.chat/core-services'; import mem from 'mem'; -export const getAgentOverviewDataCached = mem(OmnichannelAnalytics.getAgentOverviewData, { maxAge: 60000, cacheKey: JSON.stringify }); +export const getAgentOverviewDataCached = mem(OmnichannelAnalytics.getAgentOverviewData, { + maxAge: process.env.TEST_MODE === 'true' ? 1 : 60000, + cacheKey: JSON.stringify, +}); // Agent overview data on realtime is cached for 5 seconds // while the data on the overview page is cached for 1 minute export const getAnalyticsOverviewDataCached = mem(OmnichannelAnalytics.getAnalyticsOverviewData, { diff --git a/apps/meteor/app/livechat/server/lib/Contacts.ts b/apps/meteor/app/livechat/server/lib/Contacts.ts index 2e648b02f5dd..4f4a33ee61b2 100644 --- a/apps/meteor/app/livechat/server/lib/Contacts.ts +++ b/apps/meteor/app/livechat/server/lib/Contacts.ts @@ -1,12 +1,25 @@ -import type { ILivechatCustomField, ILivechatVisitor, IOmnichannelRoom } from '@rocket.chat/core-typings'; -import { LivechatVisitors, Users, LivechatRooms, LivechatCustomField, LivechatInquiry, Rooms, Subscriptions } from '@rocket.chat/models'; +import type { ILivechatContactChannel, ILivechatCustomField, ILivechatVisitor, IOmnichannelRoom, IUser } from '@rocket.chat/core-typings'; +import { + LivechatVisitors, + Users, + LivechatRooms, + LivechatCustomField, + LivechatInquiry, + Rooms, + Subscriptions, + LivechatContacts, +} from '@rocket.chat/models'; import { check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; import type { MatchKeysAndValues, OnlyFieldsOfType } from 'mongodb'; import { callbacks } from '../../../../lib/callbacks'; import { trim } from '../../../../lib/utils/stringUtils'; -import { notifyOnRoomChangedById, notifyOnLivechatInquiryChangedByRoom } from '../../../lib/server/lib/notifyListener'; +import { + notifyOnRoomChangedById, + notifyOnSubscriptionChangedByRoomId, + notifyOnLivechatInquiryChangedByRoom, +} from '../../../lib/server/lib/notifyListener'; import { i18n } from '../../../utils/lib/i18n'; type RegisterContactProps = { @@ -22,6 +35,16 @@ type RegisterContactProps = { }; }; +type CreateContactParams = { + name: string; + emails: string[]; + phones: string[]; + unknown: boolean; + customFields?: Record; + contactManager?: string; + channels?: ILivechatContactChannel[]; +}; + export const Contacts = { async registerContact({ token, @@ -138,17 +161,88 @@ export const Contacts = { for await (const room of rooms) { const { _id: rid } = room; - await Promise.all([ + const responses = await Promise.all([ Rooms.setFnameById(rid, name), LivechatInquiry.setNameByRoomId(rid, name), Subscriptions.updateDisplayNameByRoomId(rid, name), ]); - void notifyOnLivechatInquiryChangedByRoom(rid, 'updated', { name }); - void notifyOnRoomChangedById(rid); + if (responses[0]?.modifiedCount) { + void notifyOnRoomChangedById(rid); + } + + if (responses[1]?.modifiedCount) { + void notifyOnLivechatInquiryChangedByRoom(rid, 'updated', { name }); + } + + if (responses[2]?.modifiedCount) { + void notifyOnSubscriptionChangedByRoomId(rid); + } } } return contactId; }, }; + +export async function createContact(params: CreateContactParams): Promise { + const { name, emails, phones, customFields = {}, contactManager, channels, unknown } = params; + + if (contactManager) { + const contactManagerUser = await Users.findOneAgentById>(contactManager, { projection: { roles: 1 } }); + if (!contactManagerUser) { + throw new Error('error-contact-manager-not-found'); + } + } + + const allowedCustomFields = await getAllowedCustomFields(); + validateCustomFields(allowedCustomFields, customFields); + + const { insertedId } = await LivechatContacts.insertOne({ + name, + emails, + phones, + contactManager, + channels, + customFields, + unknown, + }); + + return insertedId; +} + +async function getAllowedCustomFields(): Promise { + return LivechatCustomField.findByScope( + 'visitor', + { + projection: { _id: 1, label: 1, regexp: 1, required: 1 }, + }, + false, + ).toArray(); +} + +export function validateCustomFields(allowedCustomFields: ILivechatCustomField[], customFields: Record) { + for (const cf of allowedCustomFields) { + if (!customFields.hasOwnProperty(cf._id)) { + if (cf.required) { + throw new Error(i18n.t('error-invalid-custom-field-value', { field: cf.label })); + } + continue; + } + const cfValue: string = trim(customFields[cf._id]); + + if (!cfValue || typeof cfValue !== 'string') { + if (cf.required) { + throw new Error(i18n.t('error-invalid-custom-field-value', { field: cf.label })); + } + continue; + } + + if (cf.regexp) { + const regex = new RegExp(cf.regexp); + if (!regex.test(cfValue)) { + throw new Error(i18n.t('error-invalid-custom-field-value', { field: cf.label })); + } + } + } +} diff --git a/apps/meteor/app/livechat/server/lib/Helper.ts b/apps/meteor/app/livechat/server/lib/Helper.ts index 1ef572df3068..17f21d8d7b04 100644 --- a/apps/meteor/app/livechat/server/lib/Helper.ts +++ b/apps/meteor/app/livechat/server/lib/Helper.ts @@ -39,6 +39,9 @@ import { sendNotification } from '../../../lib/server'; import { notifyOnLivechatDepartmentAgentChanged, notifyOnLivechatDepartmentAgentChangedByAgentsAndDepartmentId, + notifyOnSubscriptionChangedById, + notifyOnSubscriptionChangedByRoomId, + notifyOnSubscriptionChanged, } from '../../../lib/server/lib/notifyListener'; import { settings } from '../../../settings/server'; import { Livechat as LivechatTyped } from './LivechatTyped'; @@ -285,7 +288,13 @@ export const createLivechatSubscription = async ( ...(department && { department }), } as InsertionModel; - return Subscriptions.insertOne(subscriptionData); + const response = await Subscriptions.insertOne(subscriptionData); + + if (response?.insertedId) { + void notifyOnSubscriptionChangedById(response.insertedId, 'inserted'); + } + + return response; }; export const removeAgentFromSubscription = async (rid: string, { _id, username }: Pick) => { @@ -296,7 +305,11 @@ export const removeAgentFromSubscription = async (rid: string, { _id, username } return; } - await Subscriptions.removeByRoomIdAndUserId(rid, _id); + const deletedSubscription = await Subscriptions.removeByRoomIdAndUserId(rid, _id); + if (deletedSubscription) { + void notifyOnSubscriptionChanged(deletedSubscription, 'removed'); + } + await Message.saveSystemMessage('ul', rid, username || '', { _id: user._id, username: user.username, name: user.name }); setImmediate(() => { @@ -513,12 +526,16 @@ export const updateChatDepartment = async ({ newDepartmentId: string; oldDepartmentId?: string; }) => { - await Promise.all([ + const responses = await Promise.all([ LivechatRooms.changeDepartmentIdByRoomId(rid, newDepartmentId), LivechatInquiry.changeDepartmentIdByRoomId(rid, newDepartmentId), Subscriptions.changeDepartmentByRoomId(rid, newDepartmentId), ]); + if (responses[2].modifiedCount) { + void notifyOnSubscriptionChangedByRoomId(rid); + } + setImmediate(() => { void Apps.self?.triggerEvent(AppEvents.IPostLivechatRoomTransferred, { type: LivechatTransferEventType.DEPARTMENT, diff --git a/apps/meteor/app/livechat/server/lib/LivechatTyped.ts b/apps/meteor/app/livechat/server/lib/LivechatTyped.ts index bb8a3fd77ba2..be79d565f6de 100644 --- a/apps/meteor/app/livechat/server/lib/LivechatTyped.ts +++ b/apps/meteor/app/livechat/server/lib/LivechatTyped.ts @@ -21,6 +21,7 @@ import type { ILivechatDepartmentAgents, LivechatDepartmentDTO, OmnichannelSourceType, + ILivechatInquiryRecord, } from '@rocket.chat/core-typings'; import { ILivechatAgentStatus, UserStatus, isOmnichannelRoom } from '@rocket.chat/core-typings'; import { Logger, type MainLogger } from '@rocket.chat/logger'; @@ -40,11 +41,12 @@ import { import { serverFetch as fetch } from '@rocket.chat/server-fetch'; import { Match, check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; -import type { Filter, FindCursor } from 'mongodb'; +import type { Filter, FindCursor, ClientSession, MongoError } from 'mongodb'; import UAParser from 'ua-parser-js'; import { callbacks } from '../../../../lib/callbacks'; import { trim } from '../../../../lib/utils/stringUtils'; +import { client } from '../../../../server/database/utils'; import { i18n } from '../../../../server/lib/i18n'; import { addUserRolesAsync } from '../../../../server/lib/roles/addUserRoles'; import { removeUserFromRolesAsync } from '../../../../server/lib/roles/removeUserFromRoles'; @@ -60,8 +62,10 @@ import { notifyOnLivechatInquiryChangedByRoom, notifyOnRoomChangedById, notifyOnLivechatInquiryChangedByToken, - notifyOnLivechatDepartmentAgentChangedByDepartmentId, notifyOnUserChange, + notifyOnLivechatDepartmentAgentChangedByDepartmentId, + notifyOnSubscriptionChangedByRoomId, + notifyOnSubscriptionChanged, } from '../../../lib/server/lib/notifyListener'; import * as Mailer from '../../../mailer/server/api'; import { metrics } from '../../../metrics/server'; @@ -73,7 +77,6 @@ import { RoutingManager } from './RoutingManager'; import { isDepartmentCreationAvailable } from './isDepartmentCreationAvailable'; import type { CloseRoomParams, CloseRoomParamsByUser, CloseRoomParamsByVisitor } from './localTypes'; import { parseTranscriptRequest } from './parseTranscriptRequest'; -import { sendTranscript as sendTranscriptFunc } from './sendTranscript'; type RegisterGuestType = Partial> & { id?: string; @@ -140,6 +143,13 @@ type ICRMData = { crmData?: IOmnichannelRoom['crmData']; }; +type ChatCloser = { _id: string; username: string | undefined }; + +const isRoomClosedByUserParams = (params: CloseRoomParams): params is CloseRoomParamsByUser => + (params as CloseRoomParamsByUser).user !== undefined; +const isRoomClosedByVisitorParams = (params: CloseRoomParams): params is CloseRoomParamsByVisitor => + (params as CloseRoomParamsByVisitor).visitor !== undefined; + const dnsResolveMx = util.promisify(dns.resolveMx); class LivechatClass { @@ -152,22 +162,6 @@ class LivechatClass { this.webhookLogger = this.logger.section('Webhook'); } - findGuest(token: string) { - return LivechatVisitors.getVisitorByToken(token, { - projection: { - name: 1, - username: 1, - token: 1, - visitorEmails: 1, - department: 1, - }, - }); - } - - enabled() { - return Boolean(settings.get('Livechat_enabled')); - } - async online(department?: string, skipNoAgentSetting = false, skipFallbackCheck = false): Promise { Livechat.logger.debug(`Checking online agents ${department ? `for department ${department}` : ''}`); if (!skipNoAgentSetting && settings.get('Livechat_accept_chats_with_no_agents')) { @@ -192,10 +186,6 @@ class LivechatClass { return agentsOnline; } - getNextAgent(department?: string): Promise { - return RoutingManager.getNextAgent(department); - } - async getOnlineAgents(department?: string, agent?: SelectedAgent | null): Promise | undefined> { if (agent?.agentId) { return Users.findOnlineAgents(agent.agentId); @@ -217,14 +207,115 @@ class LivechatClass { return Users.findOnlineAgents(); } - async closeRoom(params: CloseRoomParams): Promise { + async closeRoom(params: CloseRoomParams, attempts = 2): Promise { + let newRoom: IOmnichannelRoom; + let chatCloser: ChatCloser; + let removedInquiryObj: ILivechatInquiryRecord | null; + + const session = client.startSession(); + try { + session.startTransaction(); + const { room, closedBy, removedInquiry } = await this.doCloseRoom(params, session); + await session.commitTransaction(); + + newRoom = room; + chatCloser = closedBy; + removedInquiryObj = removedInquiry; + } catch (e) { + this.logger.error({ err: e, msg: 'Failed to close room', afterAttempts: attempts }); + await session.abortTransaction(); + // Dont propagate transaction errors + if ( + (e as unknown as MongoError)?.errorLabels?.includes('UnknownTransactionCommitResult') || + (e as unknown as MongoError)?.errorLabels?.includes('TransientTransactionError') + ) { + if (attempts > 0) { + this.logger.debug(`Retrying close room because of transient error. Attempts left: ${attempts}`); + return this.closeRoom(params, attempts - 1); + } + + throw new Error('error-room-cannot-be-closed-try-again'); + } + throw e; + } finally { + await session.endSession(); + } + + // Note: when reaching this point, the room has been closed + // Transaction is commited and so these messages can be sent here. + return this.afterRoomClosed(newRoom, chatCloser, removedInquiryObj, params); + } + + async afterRoomClosed( + newRoom: IOmnichannelRoom, + chatCloser: ChatCloser, + inquiry: ILivechatInquiryRecord | null, + params: CloseRoomParams, + ): Promise { + if (!chatCloser) { + // this should never happen + return; + } + // Note: we are okay with these messages being sent outside of the transaction. The process of sending a message + // is huge and involves multiple db calls. Making it transactionable this way would be really hard. + // And passing just _some_ actions to the transaction creates some deadlocks since messages are updated in the afterSaveMessages callbacks. + const transcriptRequested = + !!params.room.transcriptRequest || (!settings.get('Livechat_enable_transcript') && settings.get('Livechat_transcript_send_always')); + this.logger.debug(`Sending closing message to room ${newRoom._id}`); + await Message.saveSystemMessageAndNotifyUser('livechat-close', newRoom._id, params.comment ?? '', chatCloser, { + groupable: false, + transcriptRequested, + ...(isRoomClosedByVisitorParams(params) && { token: params.visitor.token }), + }); + + if (settings.get('Livechat_enable_transcript') && !settings.get('Livechat_transcript_send_always')) { + await Message.saveSystemMessage('command', newRoom._id, 'promptTranscript', chatCloser); + } + + this.logger.debug(`Running callbacks for room ${newRoom._id}`); + + process.nextTick(() => { + /** + * @deprecated the `AppEvents.ILivechatRoomClosedHandler` event will be removed + * in the next major version of the Apps-Engine + */ + void Apps.self?.getBridges()?.getListenerBridge().livechatEvent(AppEvents.ILivechatRoomClosedHandler, newRoom); + void Apps.self?.getBridges()?.getListenerBridge().livechatEvent(AppEvents.IPostLivechatRoomClosed, newRoom); + }); + + const visitor = isRoomClosedByVisitorParams(params) ? params.visitor : undefined; + const opts = await parseTranscriptRequest(params.room, params.options, visitor); + if (process.env.TEST_MODE) { + await callbacks.run('livechat.closeRoom', { + room: newRoom, + options: opts, + }); + } else { + callbacks.runAsync('livechat.closeRoom', { + room: newRoom, + options: opts, + }); + } + + void notifyOnRoomChangedById(newRoom._id); + if (inquiry) { + void notifyOnLivechatInquiryChanged(inquiry, 'removed'); + } + + this.logger.debug(`Room ${newRoom._id} was closed`); + } + + async doCloseRoom( + params: CloseRoomParams, + session: ClientSession, + ): Promise<{ room: IOmnichannelRoom; closedBy: ChatCloser; removedInquiry: ILivechatInquiryRecord | null }> { const { comment } = params; const { room } = params; this.logger.debug(`Attempting to close room ${room._id}`); if (!room || !isOmnichannelRoom(room) || !room.open) { this.logger.debug(`Room ${room._id} is not open`); - return; + throw new Error('error-room-closed'); } const commentRequired = settings.get('Livechat_request_comment_when_closing_conversation'); @@ -236,7 +327,7 @@ class LivechatClass { this.logger.debug(`Resolved chat tags for room ${room._id}`); const now = new Date(); - const { _id: rid, servedBy, transcriptRequest } = room; + const { _id: rid, servedBy } = room; const serviceTimeDuration = servedBy && (now.getTime() - new Date(servedBy.ts).getTime()) / 1000; const closeData: IOmnichannelRoomClosingInfo = { @@ -247,11 +338,6 @@ class LivechatClass { }; this.logger.debug(`Room ${room._id} was closed at ${closeData.closedAt} (duration ${closeData.chatDuration})`); - const isRoomClosedByUserParams = (params: CloseRoomParams): params is CloseRoomParamsByUser => - (params as CloseRoomParamsByUser).user !== undefined; - const isRoomClosedByVisitorParams = (params: CloseRoomParams): params is CloseRoomParamsByVisitor => - (params as CloseRoomParamsByVisitor).visitor !== undefined; - if (isRoomClosedByUserParams(params)) { const { user } = params; this.logger.debug(`Closing by user ${user?._id}`); @@ -274,72 +360,38 @@ class LivechatClass { this.logger.debug(`Updating DB for room ${room._id} with close data`); - const inquiry = await LivechatInquiry.findOneByRoomId(rid); - - const removedInquiry = await LivechatInquiry.removeByRoomId(rid); + const inquiry = await LivechatInquiry.findOneByRoomId(rid, { session }); + const removedInquiry = await LivechatInquiry.removeByRoomId(rid, { session }); if (removedInquiry && removedInquiry.deletedCount !== 1) { throw new Error('Error removing inquiry'); } - if (inquiry) { - void notifyOnLivechatInquiryChanged(inquiry, 'removed'); - } - const updatedRoom = await LivechatRooms.closeRoomById(rid, closeData); + const updatedRoom = await LivechatRooms.closeRoomById(rid, closeData, { session }); if (!updatedRoom || updatedRoom.modifiedCount !== 1) { throw new Error('Error closing room'); } - await Subscriptions.removeByRoomId(rid); - - this.logger.debug(`DB updated for room ${room._id}`); - - const newRoom = await LivechatRooms.findOneById(rid); - - if (!newRoom) { - throw new Error('Error: Room not found'); - } - - this.logger.debug(`Sending closing message to room ${room._id}`); - - const transcriptRequested = - !!transcriptRequest || (!settings.get('Livechat_enable_transcript') && settings.get('Livechat_transcript_send_always')); - - await Message.saveSystemMessageAndNotifyUser('livechat-close', rid, comment ?? '', closeData.closedBy, { - groupable: false, - transcriptRequested, - ...(isRoomClosedByVisitorParams(params) && { token: params.visitor.token }), + const subs = await Subscriptions.countByRoomId(rid, { session }); + const removedSubs = await Subscriptions.removeByRoomId(rid, { + async onTrash(doc) { + void notifyOnSubscriptionChanged(doc, 'removed'); + }, + session, }); - if (settings.get('Livechat_enable_transcript') && !settings.get('Livechat_transcript_send_always')) { - await Message.saveSystemMessage('command', rid, 'promptTranscript', closeData.closedBy); + if (removedSubs.deletedCount !== subs) { + throw new Error('Error removing subscriptions'); } - process.nextTick(() => { - /** - * @deprecated the `AppEvents.ILivechatRoomClosedHandler` event will be removed - * in the next major version of the Apps-Engine - */ - void Apps.self?.getBridges()?.getListenerBridge().livechatEvent(AppEvents.ILivechatRoomClosedHandler, newRoom); - void Apps.self?.getBridges()?.getListenerBridge().livechatEvent(AppEvents.IPostLivechatRoomClosed, newRoom); - }); + this.logger.debug(`DB updated for room ${room._id}`); - const visitor = isRoomClosedByVisitorParams(params) ? params.visitor : undefined; - const opts = await parseTranscriptRequest(params.room, options, visitor); - if (process.env.TEST_MODE) { - await callbacks.run('livechat.closeRoom', { - room: newRoom, - options: opts, - }); - } else { - callbacks.runAsync('livechat.closeRoom', { - room: newRoom, - options: opts, - }); + // Retrieve the closed room + const newRoom = await LivechatRooms.findOneById(rid, { session }); + if (!newRoom) { + throw new Error('Error: Room not found'); } - void notifyOnRoomChangedById(newRoom._id); - - this.logger.debug(`Room ${newRoom._id} was closed`); + return { room: newRoom, closedBy: closeData.closedBy, removedInquiry: inquiry }; } async getRequiredDepartment(onlineRequired = true) { @@ -378,7 +430,7 @@ class LivechatClass { agent?: SelectedAgent; extraData?: Record; }) { - if (!this.enabled()) { + if (!settings.get('Livechat_enabled')) { throw new Meteor.Error('error-omnichannel-is-disabled'); } @@ -429,7 +481,7 @@ class LivechatClass { agent?: SelectedAgent, extraData?: E, ) { - if (!this.enabled()) { + if (!settings.get('Livechat_enabled')) { throw new Meteor.Error('error-omnichannel-is-disabled'); } Livechat.logger.debug(`Attempting to find or create a room for visitor ${guest._id}`); @@ -515,12 +567,16 @@ class LivechatClass { const result = await Promise.allSettled([ Messages.removeByRoomId(rid), ReadReceipts.removeByRoomId(rid), - Subscriptions.removeByRoomId(rid), + Subscriptions.removeByRoomId(rid, { + async onTrash(doc) { + void notifyOnSubscriptionChanged(doc, 'removed'); + }, + }), LivechatInquiry.removeByRoomId(rid), LivechatRooms.removeById(rid), ]); - if (inquiry) { + if (result[3]?.status === 'fulfilled' && result[3].value?.deletedCount && inquiry) { void notifyOnLivechatInquiryChanged(inquiry, 'removed'); } @@ -799,15 +855,6 @@ class LivechatClass { } } - async updateLastChat(contactId: string, lastChat: Required) { - const updateUser = { - $set: { - lastChat, - }, - }; - await LivechatVisitors.updateById(contactId, updateUser); - } - notifyRoomVisitorChange(roomId: string, visitor: ILivechatVisitor) { void api.broadcast('omnichannel.room', roomId, { type: 'visitorData', @@ -1143,13 +1190,18 @@ class LivechatClass { const cursor = LivechatRooms.findByVisitorToken(token); for await (const room of cursor) { await Promise.all([ + Subscriptions.removeByRoomId(room._id, { + async onTrash(doc) { + void notifyOnSubscriptionChanged(doc, 'removed'); + }, + }), FileUpload.removeFilesByRoomId(room._id), Messages.removeByRoomId(room._id), ReadReceipts.removeByRoomId(room._id), ]); } - await Promise.all([Subscriptions.removeByVisitorToken(token), LivechatRooms.removeByVisitorToken(token)]); + await LivechatRooms.removeByVisitorToken(token); const livechatInquiries = await LivechatInquiry.findIdsByVisitorToken(token).toArray(); await LivechatInquiry.removeByIds(livechatInquiries.map(({ _id }) => _id)); @@ -1701,13 +1753,19 @@ class LivechatClass { const { _id: rid } = roomData; const { name } = guestData; - await Promise.all([ + const responses = await Promise.all([ Rooms.setFnameById(rid, name), LivechatInquiry.setNameByRoomId(rid, name), Subscriptions.updateDisplayNameByRoomId(rid, name), ]); - void notifyOnLivechatInquiryChangedByRoom(rid, 'updated', { name }); + if (responses[1]?.modifiedCount) { + void notifyOnLivechatInquiryChangedByRoom(rid, 'updated', { name }); + } + + if (responses[2]?.modifiedCount) { + await notifyOnSubscriptionChangedByRoomId(rid); + } } void notifyOnRoomChangedById(roomData._id); @@ -1819,22 +1877,6 @@ class LivechatClass { return departmentDB; } - - async sendTranscript({ - token, - rid, - email, - subject, - user, - }: { - token: string; - rid: string; - email: string; - subject?: string; - user?: Pick | null; - }): Promise { - return sendTranscriptFunc({ token, rid, email, subject, user }); - } } export const Livechat = new LivechatClass(); diff --git a/apps/meteor/app/livechat/server/methods/getNextAgent.ts b/apps/meteor/app/livechat/server/methods/getNextAgent.ts index 179f1f95dedf..f603aeef97f3 100644 --- a/apps/meteor/app/livechat/server/methods/getNextAgent.ts +++ b/apps/meteor/app/livechat/server/methods/getNextAgent.ts @@ -8,6 +8,7 @@ import { callbacks } from '../../../../lib/callbacks'; import { methodDeprecationLogger } from '../../../lib/server/lib/deprecationWarningLogger'; import { settings } from '../../../settings/server'; import { Livechat } from '../lib/LivechatTyped'; +import { RoutingManager } from '../lib/RoutingManager'; declare module '@rocket.chat/ddp-client' { // eslint-disable-next-line @typescript-eslint/naming-convention @@ -38,7 +39,7 @@ Meteor.methods({ } } - const agent = await Livechat.getNextAgent(department); + const agent = await RoutingManager.getNextAgent(department); if (!agent) { return; } diff --git a/apps/meteor/app/livechat/server/methods/sendTranscript.ts b/apps/meteor/app/livechat/server/methods/sendTranscript.ts index 4891f579926a..00287fa89327 100644 --- a/apps/meteor/app/livechat/server/methods/sendTranscript.ts +++ b/apps/meteor/app/livechat/server/methods/sendTranscript.ts @@ -6,7 +6,7 @@ import { Meteor } from 'meteor/meteor'; import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; import { RateLimiter } from '../../../lib/server'; -import { Livechat } from '../lib/LivechatTyped'; +import { sendTranscript } from '../lib/sendTranscript'; declare module '@rocket.chat/ddp-client' { // eslint-disable-next-line @typescript-eslint/naming-convention @@ -39,7 +39,7 @@ Meteor.methods({ throw new Meteor.Error('error-mac-limit-reached', 'MAC limit reached', { method: 'livechat:sendTranscript' }); } - return Livechat.sendTranscript({ token, rid, email, subject, user }); + return sendTranscript({ token, rid, email, subject, user }); }, }); diff --git a/apps/meteor/app/message-mark-as-unread/server/unreadMessages.ts b/apps/meteor/app/message-mark-as-unread/server/unreadMessages.ts index 6ef1f5567a20..f213ae4b7243 100644 --- a/apps/meteor/app/message-mark-as-unread/server/unreadMessages.ts +++ b/apps/meteor/app/message-mark-as-unread/server/unreadMessages.ts @@ -3,6 +3,7 @@ import type { ServerMethods } from '@rocket.chat/ddp-client'; import { Messages, Subscriptions } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; +import { notifyOnSubscriptionChangedByRoomIdAndUserId } from '../../lib/server/lib/notifyListener'; import logger from './logger'; declare module '@rocket.chat/ddp-client' { @@ -36,7 +37,11 @@ Meteor.methods({ }); } - await Subscriptions.setAsUnreadByRoomIdAndUserId(lastMessage.rid, userId, lastMessage.ts); + const setAsUnreadResponse = await Subscriptions.setAsUnreadByRoomIdAndUserId(lastMessage.rid, userId, lastMessage.ts); + if (setAsUnreadResponse.modifiedCount) { + void notifyOnSubscriptionChangedByRoomIdAndUserId(lastMessage.rid, userId); + } + return; } @@ -72,7 +77,11 @@ Meteor.methods({ if (firstUnreadMessage.ts >= lastSeen) { return logger.debug('Provided message is already marked as unread'); } - logger.debug(`Updating unread message of ${originalMessage.ts} as the first unread`); - await Subscriptions.setAsUnreadByRoomIdAndUserId(originalMessage.rid, userId, originalMessage.ts); + + logger.debug(`Updating unread message of ${originalMessage.ts} as the first unread`); + const setAsUnreadResponse = await Subscriptions.setAsUnreadByRoomIdAndUserId(originalMessage.rid, userId, originalMessage.ts); + if (setAsUnreadResponse.modifiedCount) { + void notifyOnSubscriptionChangedByRoomIdAndUserId(originalMessage.rid, userId); + } }, }); diff --git a/apps/meteor/app/models/client/models/CachedChatRoom.ts b/apps/meteor/app/models/client/models/CachedChatRoom.ts index f66e5b447432..852bed5a6067 100644 --- a/apps/meteor/app/models/client/models/CachedChatRoom.ts +++ b/apps/meteor/app/models/client/models/CachedChatRoom.ts @@ -46,7 +46,6 @@ class CachedChatRoom extends CachedCollection { usernames: room.usernames, usersCount: room.usersCount, lastMessage: room.lastMessage, - streamingOptions: room.streamingOptions, teamId: room.teamId, teamMain: room.teamMain, v: (room as IOmnichannelRoom | undefined)?.v, diff --git a/apps/meteor/app/models/client/models/CachedChatSubscription.ts b/apps/meteor/app/models/client/models/CachedChatSubscription.ts index 0e325453539a..7c0e84800c77 100644 --- a/apps/meteor/app/models/client/models/CachedChatSubscription.ts +++ b/apps/meteor/app/models/client/models/CachedChatSubscription.ts @@ -35,7 +35,6 @@ class CachedChatSubscription extends CachedCollection @@ -132,7 +133,10 @@ Meteor.methods({ }); } - await notifications[field].updateMethod(subscription, value); + const updateResponse = await notifications[field].updateMethod(subscription, value); + if (updateResponse.modifiedCount) { + void notifyOnSubscriptionChangedById(subscription._id); + } return true; }, @@ -144,13 +148,19 @@ Meteor.methods({ method: 'saveAudioNotificationValue', }); } + const subscription = await Subscriptions.findOneByRoomIdAndUserId(rid, userId); if (!subscription) { throw new Meteor.Error('error-invalid-subscription', 'Invalid subscription', { method: 'saveAudioNotificationValue', }); } - await saveAudioNotificationValue(subscription._id, value); + + const saveAudioNotificationResponse = await saveAudioNotificationValue(subscription._id, value); + if (saveAudioNotificationResponse.modifiedCount) { + void notifyOnSubscriptionChangedById(subscription._id); + } + return true; }, }); diff --git a/apps/meteor/app/reactions/server/setReaction.ts b/apps/meteor/app/reactions/server/setReaction.ts index e35103e9d333..d513c8dda6a5 100644 --- a/apps/meteor/app/reactions/server/setReaction.ts +++ b/apps/meteor/app/reactions/server/setReaction.ts @@ -1,5 +1,5 @@ import { Apps, AppEvents } from '@rocket.chat/apps'; -import { api } from '@rocket.chat/core-services'; +import { api, Message } from '@rocket.chat/core-services'; import type { IMessage, IRoom, IUser } from '@rocket.chat/core-typings'; import type { ServerMethods } from '@rocket.chat/ddp-client'; import { Messages, EmojiCustom, Rooms, Users } from '@rocket.chat/models'; @@ -52,6 +52,8 @@ async function setReaction(room: IRoom, user: IUser, message: IMessage, reaction // return; // } + await Message.beforeReacted(message, room); + const userAlreadyReacted = message.reactions && Boolean(message.reactions[reaction]) && diff --git a/apps/meteor/app/statistics/server/lib/statistics.ts b/apps/meteor/app/statistics/server/lib/statistics.ts index cff2aaefcc5a..e5001b2bff87 100644 --- a/apps/meteor/app/statistics/server/lib/statistics.ts +++ b/apps/meteor/app/statistics/server/lib/statistics.ts @@ -542,7 +542,6 @@ export const statistics = { statistics.totalOTRRooms = await Rooms.findByCreatedOTR().count(); statistics.totalOTR = settings.get('OTR_Count'); statistics.totalBroadcastRooms = await Rooms.findByBroadcast().count(); - statistics.totalRoomsWithActiveLivestream = await Rooms.findByActiveLivestream().count(); statistics.totalTriggeredEmails = settings.get('Triggered_Emails_Count'); statistics.totalRoomsWithStarred = await Messages.countRoomsWithStarredMessages({ readPreference }); statistics.totalRoomsWithPinned = await Messages.countRoomsWithPinnedMessages({ readPreference }); diff --git a/apps/meteor/app/threads/server/functions.ts b/apps/meteor/app/threads/server/functions.ts index 30daef8b8b93..194e482c54ae 100644 --- a/apps/meteor/app/threads/server/functions.ts +++ b/apps/meteor/app/threads/server/functions.ts @@ -2,51 +2,57 @@ import type { IMessage } from '@rocket.chat/core-typings'; import { isEditedMessage } from '@rocket.chat/core-typings'; import { Messages, Subscriptions, ReadReceipts, NotificationQueue } from '@rocket.chat/models'; +import { + notifyOnSubscriptionChangedByRoomIdAndUserIds, + notifyOnSubscriptionChangedByRoomIdAndUserId, +} from '../../lib/server/lib/notifyListener'; import { getMentions, getUserIdsFromHighlights } from '../../lib/server/lib/notifyUsersOnMessage'; export async function reply({ tmid }: { tmid?: string }, message: IMessage, parentMessage: IMessage, followers: string[]) { - const { rid, ts, u } = message; if (!tmid || isEditedMessage(message)) { return false; } - const { toAll, toHere, mentionIds } = await getMentions(message); + const { rid, ts, u } = message; + + const [highlightsUids, threadFollowers, { toAll, toHere, mentionIds }] = await Promise.all([ + getUserIdsFromHighlights(rid, message), + Messages.getThreadFollowsByThreadId(tmid), + getMentions(message), + ]); const addToReplies = [ - ...new Set([ - ...followers, - ...mentionIds, - ...(Array.isArray(parentMessage.replies) && parentMessage.replies.length ? [u._id] : [parentMessage.u._id, u._id]), - ]), + ...new Set([...followers, ...mentionIds, ...(parentMessage.replies?.length ? [u._id] : [parentMessage.u._id, u._id])]), ]; - const highlightedUserIds = new Set(); - (await getUserIdsFromHighlights(rid, message)).forEach((uid) => highlightedUserIds.add(uid)); - await Messages.updateRepliesByThreadId(tmid, addToReplies, ts); - await ReadReceipts.setAsThreadById(tmid); + const threadFollowersUids = threadFollowers?.filter((userId) => userId !== u._id && !mentionIds.includes(userId)) || []; - const replies = await Messages.getThreadFollowsByThreadId(tmid); + // Notify everyone involved in the thread + const notifyOptions = toAll || toHere ? { groupMention: true } : {}; - const repliesFiltered = (replies || []).filter((userId) => userId !== u._id).filter((userId) => !mentionIds.includes(userId)); + // Notify message mentioned users and highlights + const mentionedUsers = [...new Set([...mentionIds, ...highlightsUids])]; - if (toAll || toHere) { - await Subscriptions.addUnreadThreadByRoomIdAndUserIds(rid, repliesFiltered, tmid, { - groupMention: true, - }); - } else { - await Subscriptions.addUnreadThreadByRoomIdAndUserIds(rid, repliesFiltered, tmid, {}); - } + const promises = [ + Messages.updateRepliesByThreadId(tmid, addToReplies, ts), + ReadReceipts.setAsThreadById(tmid), + Subscriptions.addUnreadThreadByRoomIdAndUserIds(rid, threadFollowersUids, tmid, notifyOptions), + ]; - const mentionedUsers = new Set([...mentionIds, ...highlightedUserIds]); - for await (const userId of mentionedUsers) { - await Subscriptions.addUnreadThreadByRoomIdAndUserIds(rid, [userId], tmid, { userMention: true }); + if (mentionedUsers.length) { + promises.push(Subscriptions.addUnreadThreadByRoomIdAndUserIds(rid, mentionedUsers, tmid, { userMention: true })); } - const highlightIds = Array.from(highlightedUserIds); - if (highlightIds.length) { - await Subscriptions.setAlertForRoomIdAndUserIds(rid, highlightIds); - await Subscriptions.setOpenForRoomIdAndUserIds(rid, highlightIds); + if (highlightsUids.length) { + promises.push( + Subscriptions.setAlertForRoomIdAndUserIds(rid, highlightsUids), + Subscriptions.setOpenForRoomIdAndUserIds(rid, highlightsUids), + ); } + + await Promise.allSettled(promises); + + void notifyOnSubscriptionChangedByRoomIdAndUserIds(rid, [...threadFollowersUids, ...mentionedUsers, ...highlightsUids]); } export async function follow({ tmid, uid }: { tmid: string; uid: string }) { @@ -62,20 +68,27 @@ export async function unfollow({ tmid, rid, uid }: { tmid: string; rid: string; return false; } - await Subscriptions.removeUnreadThreadByRoomIdAndUserId(rid, uid, tmid); + const removeUnreadThreadResponse = await Subscriptions.removeUnreadThreadByRoomIdAndUserId(rid, uid, tmid); + if (removeUnreadThreadResponse.modifiedCount) { + void notifyOnSubscriptionChangedByRoomIdAndUserId(rid, uid); + } await Messages.removeThreadFollowerByThreadId(tmid, uid); } export const readThread = async ({ userId, rid, tmid }: { userId: string; rid: string; tmid: string }) => { - const projection = { tunread: 1 }; - const sub = await Subscriptions.findOneByRoomIdAndUserId(rid, userId, { projection }); + const sub = await Subscriptions.findOneByRoomIdAndUserId(rid, userId, { projection: { tunread: 1 } }); if (!sub) { return; } + // if the thread being marked as read is the last one unread also clear the unread subscription flag const clearAlert = sub.tunread && sub.tunread?.length <= 1 && sub.tunread.includes(tmid); - await Subscriptions.removeUnreadThreadByRoomIdAndUserId(rid, userId, tmid, clearAlert); + const removeUnreadThreadResponse = await Subscriptions.removeUnreadThreadByRoomIdAndUserId(rid, userId, tmid, clearAlert); + if (removeUnreadThreadResponse.modifiedCount) { + void notifyOnSubscriptionChangedByRoomIdAndUserId(rid, userId); + } + await NotificationQueue.clearQueueByUserId(userId); }; diff --git a/apps/meteor/app/utils/server/functions/getMongoInfo.ts b/apps/meteor/app/utils/server/functions/getMongoInfo.ts index 8460e9e4ced0..1caef4a22e32 100644 --- a/apps/meteor/app/utils/server/functions/getMongoInfo.ts +++ b/apps/meteor/app/utils/server/functions/getMongoInfo.ts @@ -7,7 +7,6 @@ function getOplogInfo(): { oplogEnabled: boolean; mongo: MongoConnection } { const oplogEnabled = isWatcherRunning(); - // @ts-expect-error - You're drunk ts return { oplogEnabled, mongo }; } diff --git a/apps/meteor/client/components/Contextualbar/ContextualbarHeader.tsx b/apps/meteor/client/components/Contextualbar/ContextualbarHeader.tsx index 795182df8465..ebd92f0095e3 100644 --- a/apps/meteor/client/components/Contextualbar/ContextualbarHeader.tsx +++ b/apps/meteor/client/components/Contextualbar/ContextualbarHeader.tsx @@ -8,10 +8,10 @@ type ContextualbarHeaderProps = { children: ReactNode; } & ComponentPropsWithoutRef; -const ContextualbarHeader = (props: ContextualbarHeaderProps) => ( +const ContextualbarHeader = ({ expanded, ...props }: ContextualbarHeaderProps) => ( - + diff --git a/apps/meteor/client/components/MarkdownText.tsx b/apps/meteor/client/components/MarkdownText.tsx index 3670bcc7cec0..c9af942f6e1c 100644 --- a/apps/meteor/client/components/MarkdownText.tsx +++ b/apps/meteor/client/components/MarkdownText.tsx @@ -16,21 +16,16 @@ type MarkdownTextParams = { withTruncatedText: boolean; } & ComponentProps; -const walkTokens = (token: marked.Token) => { - const boldPattern = /^\*.*\*$|^\*.*|.*\*$/; - const italicPattern = /^__(?=\S)([\s\S]*?\S)__(?!_)|^_(?=\S)([\s\S]*?\S)_(?!_)/; - if (boldPattern.test(token.raw)) { - token.type = 'strong'; - } else if (italicPattern.test(token.raw)) { - token.type = 'em'; - } -}; - -marked.use({ walkTokens }); const documentRenderer = new marked.Renderer(); const inlineRenderer = new marked.Renderer(); const inlineWithoutBreaks = new marked.Renderer(); +marked.Lexer.rules.gfm = { + ...marked.Lexer.rules.gfm, + strong: /^\*\*(?=\S)([\s\S]*?\S)\*\*(?!\*)|^\*(?=\S)([\s\S]*?\S)\*(?!\*)/, + em: /^__(?=\S)([\s\S]*?\S)__(?!_)|^_(?=\S)([\s\S]*?\S)_(?!_)/, +}; + const linkMarked = (href: string | null, _title: string | null, text: string): string => `${text} `; const paragraphMarked = (text: string): string => text; @@ -117,6 +112,7 @@ const MarkdownText = ({ const markedHtml = /inline/.test(variant) ? marked.parseInline(new Option(content).innerHTML, markedOptions) : marked.parse(new Option(content).innerHTML, markedOptions); + if (parseEmoji) { // We are using the old emoji parser here. This could come // with additional processing use, but is the workaround available right now. diff --git a/apps/meteor/client/components/UserCard/UserCardInfo.tsx b/apps/meteor/client/components/UserCard/UserCardInfo.tsx index 8e235670a3dc..2afcf6a37f2c 100644 --- a/apps/meteor/client/components/UserCard/UserCardInfo.tsx +++ b/apps/meteor/client/components/UserCard/UserCardInfo.tsx @@ -3,7 +3,7 @@ import type { ReactElement, ComponentProps } from 'react'; import React from 'react'; const UserCardInfo = (props: ComponentProps): ReactElement => ( - + ); export default UserCardInfo; diff --git a/apps/meteor/client/hooks/roomActions/useStartCallRoomAction.ts b/apps/meteor/client/hooks/roomActions/useStartCallRoomAction.ts index 9d867d571cf7..8d1fa251c051 100644 --- a/apps/meteor/client/hooks/roomActions/useStartCallRoomAction.ts +++ b/apps/meteor/client/hooks/roomActions/useStartCallRoomAction.ts @@ -15,7 +15,6 @@ export const useStartCallRoomAction = () => { const federated = isRoomFederated(room); const ownUser = room.uids?.length === 1 ?? false; - const live = room?.streamingOptions?.type === 'call' ?? false; const permittedToPostReadonly = usePermission('post-readonly', room._id); const permittedToCallManagement = usePermission('call-management', room._id); @@ -81,8 +80,8 @@ export const useStartCallRoomAction = () => { disabled: true, }), full: true, - order: live ? -1 : 4, + order: 4, featured: true, }; - }, [allowed, disabled, groups, handleOpenVideoConf, live, t]); + }, [allowed, disabled, groups, handleOpenVideoConf, t]); }; diff --git a/apps/meteor/client/lib/chats/flows/uploadFiles.ts b/apps/meteor/client/lib/chats/flows/uploadFiles.ts index 32e17da8ac6f..a16e368198bb 100644 --- a/apps/meteor/client/lib/chats/flows/uploadFiles.ts +++ b/apps/meteor/client/lib/chats/flows/uploadFiles.ts @@ -2,6 +2,7 @@ import type { IMessage, FileAttachmentProps, IE2EEMessage, IUpload } from '@rock import { isRoomFederated } from '@rocket.chat/core-typings'; import { e2e } from '../../../../app/e2e/client'; +import { settings } from '../../../../app/settings/client'; import { fileUploadIsValidContentType } from '../../../../app/utils/client'; import { getFileExtension } from '../../../../lib/utils/getFileExtension'; import FileUploadModal from '../../../views/room/modals/FileUploadModal'; @@ -83,6 +84,11 @@ export const uploadFiles = async (chat: ChatAPI, files: readonly File[], resetFi return; } + if (!settings.get('E2E_Enable_Encrypt_Files')) { + uploadFile(file, { description }); + return; + } + const shouldConvertSentMessages = await e2eRoom.shouldConvertSentMessages({ msg }); if (!shouldConvertSentMessages) { diff --git a/apps/meteor/client/startup/accounts.ts b/apps/meteor/client/startup/accounts.ts index 3be110bc0a09..60f2de02bde0 100644 --- a/apps/meteor/client/startup/accounts.ts +++ b/apps/meteor/client/startup/accounts.ts @@ -2,6 +2,7 @@ import { Accounts } from 'meteor/accounts-base'; import { Meteor } from 'meteor/meteor'; import { Tracker } from 'meteor/tracker'; +import { settings } from '../../app/settings/client'; import { mainReady } from '../../app/ui-utils/client'; import { sdk } from '../../app/utils/client/lib/SDKClient'; import { t } from '../../app/utils/lib/i18n'; @@ -24,3 +25,15 @@ Accounts.onEmailVerificationLink((token: string) => { }); }); }); + +Meteor.startup(() => { + Tracker.autorun(() => { + const forgetUserSessionOnWindowClose = settings.get('Accounts_ForgetUserSessionOnWindowClose'); + + if (forgetUserSessionOnWindowClose === undefined) { + return; + } + + Accounts.config({ clientStorage: forgetUserSessionOnWindowClose ? 'session' : 'local' }); + }); +}); diff --git a/apps/meteor/client/views/admin/rooms/RoomsTableFilters.tsx b/apps/meteor/client/views/admin/rooms/RoomsTableFilters.tsx index d52d45415c8a..2ec5954332f7 100644 --- a/apps/meteor/client/views/admin/rooms/RoomsTableFilters.tsx +++ b/apps/meteor/client/views/admin/rooms/RoomsTableFilters.tsx @@ -94,8 +94,8 @@ const RoomsTableFilters = ({ setFilters }: { setFilters: Dispatch { + const { t } = useTranslation(); + const router = useRouter(); + + return ( + <> + + + + + + + + + ); +}; + +export default AdminUserCreated; diff --git a/apps/meteor/client/views/admin/users/AdminUserForm.tsx b/apps/meteor/client/views/admin/users/AdminUserForm.tsx index 69aeb4e31205..023d3df851e5 100644 --- a/apps/meteor/client/views/admin/users/AdminUserForm.tsx +++ b/apps/meteor/client/views/admin/users/AdminUserForm.tsx @@ -1,4 +1,4 @@ -import type { AvatarObject, IUser, Serialized } from '@rocket.chat/core-typings'; +import type { AvatarObject, IRole, IUser, Serialized } from '@rocket.chat/core-typings'; import { Field, FieldLabel, @@ -7,20 +7,19 @@ import { FieldHint, TextInput, TextAreaInput, - PasswordInput, MultiSelectFiltered, Box, ToggleSwitch, Icon, - Divider, FieldGroup, ContextualbarFooter, - ButtonGroup, Button, Callout, + Skeleton, } from '@rocket.chat/fuselage'; import type { SelectOption } from '@rocket.chat/fuselage'; import { useUniqueId, useMutableCallback } from '@rocket.chat/fuselage-hooks'; +import type { UserCreateParamsPOST } from '@rocket.chat/rest-typings'; import { CustomFieldsForm } from '@rocket.chat/ui-client'; import { useAccountsCustomFields, @@ -30,8 +29,8 @@ import { useToastMessageDispatch, useTranslation, } from '@rocket.chat/ui-contexts'; -import { useQuery, useMutation } from '@tanstack/react-query'; -import React, { useCallback } from 'react'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import React, { useMemo, useState } from 'react'; import { Controller, useForm } from 'react-hook-form'; import { validateEmail } from '../../../../lib/emailValidator'; @@ -41,24 +40,35 @@ import UserAvatarEditor from '../../../components/avatar/UserAvatarEditor'; import { useEndpointAction } from '../../../hooks/useEndpointAction'; import { useUpdateAvatar } from '../../../hooks/useUpdateAvatar'; import { USER_STATUS_TEXT_MAX_LENGTH, BIO_TEXT_MAX_LENGTH } from '../../../lib/constants'; +import AdminUserSetRandomPasswordContent from './AdminUserSetRandomPasswordContent'; +import AdminUserSetRandomPasswordRadios from './AdminUserSetRandomPasswordRadios'; +import PasswordFieldSkeleton from './PasswordFieldSkeleton'; import { useSmtpQuery } from './hooks/useSmtpQuery'; type AdminUserFormProps = { userData?: Serialized; onReload: () => void; + context: string; + refetchUserFormData?: () => void; + roleData: { roles: IRole[] } | undefined; + roleError: unknown; }; +export type UserFormProps = Omit; + const getInitialValue = ({ data, defaultUserRoles, isSmtpEnabled, - isEditingExistingUser, + isVerificationNeeded, + isNewUserPage, }: { data?: Serialized; defaultUserRoles?: IUser['roles']; isSmtpEnabled?: boolean; - isEditingExistingUser?: boolean; -}) => ({ + isVerificationNeeded?: boolean; + isNewUserPage?: boolean; +}): UserFormProps => ({ roles: data?.roles ?? defaultUserRoles, name: data?.name ?? '', password: '', @@ -66,53 +76,59 @@ const getInitialValue = ({ bio: data?.bio ?? '', nickname: data?.nickname ?? '', email: (data?.emails?.length && data.emails[0].address) || '', - verified: (data?.emails?.length && data.emails[0].verified) || false, - setRandomPassword: false, - requirePasswordChange: data?.requirePasswordChange || false, + verified: isSmtpEnabled && isVerificationNeeded && ((data?.emails?.length && data.emails[0].verified) || false), + setRandomPassword: isNewUserPage && isSmtpEnabled, + requirePasswordChange: isNewUserPage && isSmtpEnabled && (data?.requirePasswordChange ?? true), customFields: data?.customFields ?? {}, statusText: data?.statusText ?? '', - ...(!isEditingExistingUser && { joinDefaultChannels: true }), + ...(isNewUserPage && { joinDefaultChannels: true }), sendWelcomeEmail: isSmtpEnabled, avatar: '' as AvatarObject, + passwordConfirmation: '', }); -const UserForm = ({ userData, onReload, ...props }: AdminUserFormProps) => { +const AdminUserForm = ({ userData, onReload, context, refetchUserFormData, roleData, roleError, ...props }: AdminUserFormProps) => { const t = useTranslation(); const router = useRouter(); const dispatchToastMessage = useToastMessageDispatch(); + const queryClient = useQueryClient(); const customFieldsMetadata = useAccountsCustomFields(); const defaultRoles = useSetting('Accounts_Registration_Users_Default_Roles') || ''; - + const isVerificationNeeded = useSetting('Accounts_EmailVerification'); const defaultUserRoles = parseCSV(defaultRoles); - const { data } = useSmtpQuery(); - const isSmtpEnabled = data?.isSMTPConfigured; - - const eventStats = useEndpointAction('POST', '/v1/statistics.telemetry'); - const updateUserAction = useEndpoint('POST', '/v1/users.update'); - const createUserAction = useEndpoint('POST', '/v1/users.create'); - const getRoles = useEndpoint('GET', '/v1/roles.list'); - const { data: roleData, error: roleError } = useQuery(['roles'], async () => getRoles()); - - const availableRoles: SelectOption[] = roleData?.roles.map(({ _id, name, description }) => [_id, description || name]) || []; - - const goToUser = useCallback((id) => router.navigate(`/admin/users/info/${id}`), [router]); - - const isEditingExistingUser = Boolean(userData?._id); + const { data, isLoading: isLoadingSmtpStatus } = useSmtpQuery(); + const isSmtpEnabled = !!data?.isSMTPConfigured; + const isNewUserPage = context === 'new'; const { control, watch, handleSubmit, - reset, formState: { errors, isDirty }, + setValue, } = useForm({ - defaultValues: getInitialValue({ data: userData, defaultUserRoles, isSmtpEnabled, isEditingExistingUser }), + values: getInitialValue({ + data: userData, + defaultUserRoles, + isSmtpEnabled, + isNewUserPage, + isVerificationNeeded: !!isVerificationNeeded, + }), mode: 'onBlur', }); - const { avatar, username, setRandomPassword } = watch(); + const { avatar, username, setRandomPassword, password } = watch(); + + const eventStats = useEndpointAction('POST', '/v1/statistics.telemetry'); + const updateUserAction = useEndpoint('POST', '/v1/users.update'); + const createUserAction = useEndpoint('POST', '/v1/users.create'); + + const availableRoles: SelectOption[] = useMemo( + () => roleData?.roles.map(({ _id, name, description }) => [_id, description || name]) || [], + [roleData], + ); const updateAvatar = useUpdateAvatar(avatar, userData?._id || ''); @@ -123,6 +139,7 @@ const UserForm = ({ userData, onReload, ...props }: AdminUserFormProps) => { await updateAvatar(); router.navigate(`/admin/users/info/${_id}`); onReload(); + refetchUserFormData?.(); }, onError: (error) => { dispatchToastMessage({ type: 'error', message: error }); @@ -131,12 +148,15 @@ const UserForm = ({ userData, onReload, ...props }: AdminUserFormProps) => { const handleCreateUser = useMutation({ mutationFn: createUserAction, - onSuccess: async (data) => { - dispatchToastMessage({ type: 'success', message: t('User_created_successfully!') }); + onSuccess: async ({ user: { _id } }) => { + dispatchToastMessage({ type: 'success', message: t('New_user_manually_created') }); await eventStats({ params: [{ eventName: 'updateCounter', settingsId: 'Manual_Entry_User_Count' }], }); - goToUser(data.user._id); + queryClient.invalidateQueries(['pendingUsersCount'], { + refetchType: 'all', + }); + router.navigate(`/admin/users/created/${_id}`); onReload(); }, onError: (error) => { @@ -144,12 +164,14 @@ const UserForm = ({ userData, onReload, ...props }: AdminUserFormProps) => { }, }); - const handleSaveUser = useMutableCallback(async (userFormPayload) => { - const { avatar, ...userFormData } = userFormPayload; - if (userData?._id) { + const handleSaveUser = useMutableCallback(async (userFormPayload: UserFormProps) => { + const { avatar, passwordConfirmation, ...userFormData } = userFormPayload; + + if (!isNewUserPage && userData?._id) { return handleUpdateUser.mutateAsync({ userId: userData?._id, data: userFormData }); } - return handleCreateUser.mutateAsync(userFormData); + + return handleCreateUser.mutateAsync({ ...userFormData, fields: '' }); }); const nameId = useUniqueId(); @@ -160,17 +182,22 @@ const UserForm = ({ userData, onReload, ...props }: AdminUserFormProps) => { const bioId = useUniqueId(); const nicknameId = useUniqueId(); const passwordId = useUniqueId(); - const requirePasswordChangeId = useUniqueId(); - const setRandomPasswordId = useUniqueId(); const rolesId = useUniqueId(); const joinDefaultChannelsId = useUniqueId(); const sendWelcomeEmailId = useUniqueId(); + const setRandomPasswordId = useUniqueId(); + + const [showCustomFields, setShowCustomFields] = useState(true); + + if (!context) { + return null; + } return ( <> - {isEditingExistingUser && ( + {!isNewUserPage && ( { /> )} + {isNewUserPage && {t('Manually_created_users_briefing')}} + + {t('Email')} + + (validateEmail(email) ? undefined : t('ensure_email_address_valid')), + }} + render={({ field }) => ( + + )} + /> + + {errors?.email && ( + + {errors.email.message} + + )} + {isLoadingSmtpStatus ? ( + + ) : ( + <> + + + + {t('Mark_email_as_verified')} + + + + ( + + )} + /> + + {isVerificationNeeded && !isSmtpEnabled && ( + + )} + {!isVerificationNeeded && ( + + )} + + )} + {t('Name')} @@ -226,7 +320,6 @@ const UserForm = ({ userData, onReload, ...props }: AdminUserFormProps) => { aria-describedby={`${usernameId}-error`} error={errors.username?.message} flexGrow={1} - addon={} /> )} /> @@ -238,43 +331,108 @@ const UserForm = ({ userData, onReload, ...props }: AdminUserFormProps) => { )} - {t('Email')} - - (validateEmail(email) ? undefined : t('error-invalid-email-address')), - }} - render={({ field }) => ( - } + {isLoadingSmtpStatus ? ( + + ) : ( + <> + + {t('Password')} + + + {!setRandomPassword && ( + )} - /> - - {errors?.email && ( - - {errors.email.message} - + )} + {t('Roles')} - {t('Verified')} - } - /> + {roleError && {roleError}} + {!roleError && ( + ( + + )} + /> + )} + {errors?.roles && {errors.roles.message}} + + {isNewUserPage && ( + + + {t('Join_default_channels')} + + ( + + )} + /> + + + + )} + + {isLoadingSmtpStatus ? ( + + ) : ( + <> + + + {t('Send_welcome_email')} + + + ( + + )} + /> + + + {!isSmtpEnabled && ( + + )} + + )} {t('StatusMessage')} @@ -291,7 +449,6 @@ const UserForm = ({ userData, onReload, ...props }: AdminUserFormProps) => { aria-invalid={errors.statusText ? 'true' : 'false'} aria-describedby={`${statusTextId}-error`} flexGrow={1} - addon={} /> )} /> @@ -332,175 +489,34 @@ const UserForm = ({ userData, onReload, ...props }: AdminUserFormProps) => { {t('Nickname')} - ( - } /> - )} - /> - - - - - {!setRandomPassword && ( - - {t('Password')} - - ( - } - /> - )} - /> - - {errors?.password && ( - - {errors.password.message} - - )} - - )} - - - {t('Require_password_change')} - ( - - )} - /> - - - - - {t('Set_random_password_and_send_by_email')} - ( - - )} - /> - - {!isSmtpEnabled && ( - - )} - - - {t('Roles')} - - {roleError && {roleError}} - {!roleError && ( - ( - - )} - /> - )} + } /> - {errors?.roles && {errors.roles.message}} - - {!isEditingExistingUser && ( - - - {t('Join_default_channels')} - ( - - )} - /> - - - )} - - - {t('Send_welcome_email')} - ( - - )} - /> - - {!isSmtpEnabled && ( - - )} - {Boolean(customFieldsMetadata.length) && ( + {!!customFieldsMetadata.length && ( <> - - {t('Custom_Fields')} - + + {showCustomFields && } )} - - - - + ); }; -export default UserForm; +export default AdminUserForm; diff --git a/apps/meteor/client/views/admin/users/AdminUserFormWithData.tsx b/apps/meteor/client/views/admin/users/AdminUserFormWithData.tsx index e595acc46951..db51f1401f32 100644 --- a/apps/meteor/client/views/admin/users/AdminUserFormWithData.tsx +++ b/apps/meteor/client/views/admin/users/AdminUserFormWithData.tsx @@ -1,5 +1,4 @@ -import type { IUser } from '@rocket.chat/core-typings'; -import { isUserFederated } from '@rocket.chat/core-typings'; +import type { IRole, IUser } from '@rocket.chat/core-typings'; import { Box, Callout } from '@rocket.chat/fuselage'; import { useEffectEvent } from '@rocket.chat/fuselage-hooks'; import { useTranslation } from '@rocket.chat/ui-contexts'; @@ -13,9 +12,12 @@ import AdminUserForm from './AdminUserForm'; type AdminUserFormWithDataProps = { uid: IUser['_id']; onReload: () => void; + context: string; + roleData: { roles: IRole[] } | undefined; + roleError: unknown; }; -const AdminUserFormWithData = ({ uid, onReload }: AdminUserFormWithDataProps): ReactElement => { +const AdminUserFormWithData = ({ uid, onReload, context, roleData, roleError }: AdminUserFormWithDataProps): ReactElement => { const t = useTranslation(); const { data, isLoading, isError, refetch } = useUserInfoQuery({ userId: uid }); @@ -40,7 +42,7 @@ const AdminUserFormWithData = ({ uid, onReload }: AdminUserFormWithDataProps): R ); } - if (data?.user && isUserFederated(data?.user as unknown as IUser)) { + if (data?.user && !!data.user.federated) { return ( {t('Edit_Federated_User_Not_Allowed')} @@ -48,7 +50,16 @@ const AdminUserFormWithData = ({ uid, onReload }: AdminUserFormWithDataProps): R ); } - return ; + return ( + + ); }; export default AdminUserFormWithData; diff --git a/apps/meteor/client/views/admin/users/AdminUserSetRandomPasswordContent.tsx b/apps/meteor/client/views/admin/users/AdminUserSetRandomPasswordContent.tsx new file mode 100644 index 000000000000..814ab8a22776 --- /dev/null +++ b/apps/meteor/client/views/admin/users/AdminUserSetRandomPasswordContent.tsx @@ -0,0 +1,109 @@ +import { Box, FieldError, FieldLabel, FieldRow, PasswordInput, ToggleSwitch } from '@rocket.chat/fuselage'; +import { useUniqueId } from '@rocket.chat/fuselage-hooks'; +import { PasswordVerifier } from '@rocket.chat/ui-client'; +import { useSetting, useTranslation } from '@rocket.chat/ui-contexts'; +import React from 'react'; +import type { Control, FieldErrors } from 'react-hook-form'; +import { Controller } from 'react-hook-form'; + +import type { UserFormProps } from './AdminUserForm'; + +type AdminUserSetRandomPasswordContentProps = { + control: Control; + setRandomPassword: boolean | undefined; + isNewUserPage: boolean; + passwordId: string; + errors: FieldErrors; + password: string; +}; + +const AdminUserSetRandomPasswordContent = ({ + control, + setRandomPassword, + isNewUserPage, + passwordId, + errors, + password, +}: AdminUserSetRandomPasswordContentProps) => { + const t = useTranslation(); + + const passwordConfirmationId = useUniqueId(); + const requirePasswordChangeId = useUniqueId(); + const passwordVerifierId = useUniqueId(); + + const requiresPasswordConfirmation = useSetting('Accounts_RequirePasswordConfirmation'); + const passwordPlaceholder = String(useSetting('Accounts_PasswordPlaceholder')); + const passwordConfirmationPlaceholder = String(useSetting('Accounts_ConfirmPasswordPlaceholder')); + + return ( + <> + + {t('Require_password_change')} + + ( + + )} + /> + + + + ( + + )} + /> + + {errors?.password && ( + + {errors.password.message} + + )} + {requiresPasswordConfirmation && ( + + (password === val ? true : t('Invalid_confirm_pass')), + }} + render={({ field }) => ( + + )} + /> + + )} + {errors?.passwordConfirmation && ( + + {errors.passwordConfirmation.message} + + )} + + + ); +}; + +export default AdminUserSetRandomPasswordContent; diff --git a/apps/meteor/client/views/admin/users/AdminUserSetRandomPasswordRadios.tsx b/apps/meteor/client/views/admin/users/AdminUserSetRandomPasswordRadios.tsx new file mode 100644 index 000000000000..bc773428608a --- /dev/null +++ b/apps/meteor/client/views/admin/users/AdminUserSetRandomPasswordRadios.tsx @@ -0,0 +1,92 @@ +import { Box, FieldHint, FieldLabel, FieldRow, RadioButton } from '@rocket.chat/fuselage'; +import { useUniqueId } from '@rocket.chat/fuselage-hooks'; +import { useTranslation } from '@rocket.chat/ui-contexts'; +import React from 'react'; +import type { Control, UseFormSetValue } from 'react-hook-form'; +import { Controller } from 'react-hook-form'; + +import type { UserFormProps } from './AdminUserForm'; + +type AdminUserSetRandomPasswordProps = { + isNewUserPage: boolean | undefined; + control: Control; + isSmtpEnabled: boolean | undefined; + setRandomPasswordId: string; + setValue: UseFormSetValue; +}; + +const AdminUserSetRandomPasswordRadios = ({ + isNewUserPage, + control, + isSmtpEnabled, + setRandomPasswordId, + setValue, +}: AdminUserSetRandomPasswordProps) => { + const t = useTranslation(); + + const setPasswordManuallyId = useUniqueId(); + + const handleSetRandomPasswordChange = (onChange: (...event: any[]) => void, value: boolean) => { + setValue('requirePasswordChange', value); + + onChange(value); + }; + + return ( + <> + + + ( + handleSetRandomPasswordChange(onChange, true)} + disabled={!isSmtpEnabled} + /> + )} + /> + + + {t('Set_randomly_and_send_by_email')} + + + {!isSmtpEnabled && ( + + )} + + + ( + handleSetRandomPasswordChange(onChange, false)} + /> + )} + /> + + + {t('Set_manually')} + + + + ); +}; + +export default AdminUserSetRandomPasswordRadios; diff --git a/apps/meteor/client/views/admin/users/AdminUsersPage.tsx b/apps/meteor/client/views/admin/users/AdminUsersPage.tsx index 4ef44122e303..56641f8959d0 100644 --- a/apps/meteor/client/views/admin/users/AdminUsersPage.tsx +++ b/apps/meteor/client/views/admin/users/AdminUsersPage.tsx @@ -1,5 +1,5 @@ import type { LicenseInfo } from '@rocket.chat/core-typings'; -import { Button, ButtonGroup, Callout, ContextualbarIcon, Skeleton, Tabs, TabsItem } from '@rocket.chat/fuselage'; +import { Button, ButtonGroup, Callout, ContextualbarIcon, Icon, Skeleton, Tabs, TabsItem } from '@rocket.chat/fuselage'; import { useDebouncedValue } from '@rocket.chat/fuselage-hooks'; import type { OptionProp } from '@rocket.chat/ui-client'; import { ExternalLink } from '@rocket.chat/ui-client'; @@ -23,6 +23,7 @@ import { useLicenseLimitsByBehavior } from '../../../hooks/useLicenseLimitsByBeh import { useShouldPreventAction } from '../../../hooks/useShouldPreventAction'; import { useCheckoutUrl } from '../subscription/hooks/useCheckoutUrl'; import AdminInviteUsers from './AdminInviteUsers'; +import AdminUserCreated from './AdminUserCreated'; import AdminUserForm from './AdminUserForm'; import AdminUserFormWithData from './AdminUserFormWithData'; import AdminUserInfoWithData from './AdminUserInfoWithData'; @@ -61,7 +62,7 @@ const AdminUsersPage = (): ReactElement => { const isCreateUserDisabled = useShouldPreventAction('activeUsers'); const getRoles = useEndpoint('GET', '/v1/roles.list'); - const { data } = useQuery(['roles'], async () => getRoles()); + const { data, error } = useQuery(['roles'], async () => getRoles()); const paginationData = usePagination(); const sortData = useSort('name'); @@ -181,14 +182,23 @@ const AdminUsersPage = (): ReactElement => { {context === 'info' && t('User_Info')} {context === 'edit' && t('Edit_User')} - {context === 'new' && t('Add_User')} + {(context === 'new' || context === 'created') && ( + <> + {t('New_user')} + + )} {context === 'invite' && t('Invite_Users')} router.navigate('/admin/users')} /> {context === 'info' && id && } - {context === 'edit' && id && } - {!isRoutePrevented && context === 'new' && } + {context === 'edit' && id && ( + + )} + {!isRoutePrevented && context === 'new' && ( + + )} + {!isRoutePrevented && context === 'created' && id && } {!isRoutePrevented && context === 'invite' && } {isRoutePrevented && } diff --git a/apps/meteor/client/views/admin/users/PasswordFieldSkeleton.tsx b/apps/meteor/client/views/admin/users/PasswordFieldSkeleton.tsx new file mode 100644 index 000000000000..b27bdabff428 --- /dev/null +++ b/apps/meteor/client/views/admin/users/PasswordFieldSkeleton.tsx @@ -0,0 +1,18 @@ +import { Box, Skeleton } from '@rocket.chat/fuselage'; +import React from 'react'; + +const PasswordFieldSkeleton = () => ( + <> + + + + + + + + + + +); + +export default PasswordFieldSkeleton; diff --git a/apps/meteor/client/views/admin/users/UsersTable/UsersTable.tsx b/apps/meteor/client/views/admin/users/UsersTable/UsersTable.tsx index 34d71e6ab371..abdf8cb787c3 100644 --- a/apps/meteor/client/views/admin/users/UsersTable/UsersTable.tsx +++ b/apps/meteor/client/views/admin/users/UsersTable/UsersTable.tsx @@ -1,7 +1,8 @@ import type { IRole, Serialized } from '@rocket.chat/core-typings'; -import { Pagination, States, StatesAction, StatesActions, StatesIcon, StatesTitle } from '@rocket.chat/fuselage'; +import { Pagination } from '@rocket.chat/fuselage'; import { useEffectEvent, useBreakpoints } from '@rocket.chat/fuselage-hooks'; import type { PaginatedResult, DefaultUserInfo } from '@rocket.chat/rest-typings'; +import type { TranslationKey } from '@rocket.chat/ui-contexts'; import { useRouter, useTranslation } from '@rocket.chat/ui-contexts'; import type { UseQueryResult } from '@tanstack/react-query'; import type { ReactElement, Dispatch, SetStateAction } from 'react'; @@ -129,16 +130,16 @@ const UsersTable = ({ )} {isError && ( - - - {t('Something_went_wrong')} - - {t('Reload_page')} - - + )} - {isSuccess && data.users.length === 0 && } + {isSuccess && data.users.length === 0 && ( + + )} {isSuccess && !!data?.users && ( <> @@ -163,7 +164,7 @@ const UsersTable = ({ divider current={current} itemsPerPage={itemsPerPage} - count={data?.total || 0} + count={data.total || 0} onSetItemsPerPage={setItemsPerPage} onSetCurrent={setCurrent} {...paginationProps} diff --git a/apps/meteor/client/views/admin/users/UsersTable/UsersTableFilters.tsx b/apps/meteor/client/views/admin/users/UsersTable/UsersTableFilters.tsx index e3b919ae4a02..bffbdd2f7a18 100644 --- a/apps/meteor/client/views/admin/users/UsersTable/UsersTableFilters.tsx +++ b/apps/meteor/client/views/admin/users/UsersTable/UsersTableFilters.tsx @@ -1,11 +1,12 @@ import type { IRole } from '@rocket.chat/core-typings'; +import { Box, Icon, Margins, TextInput } from '@rocket.chat/fuselage'; import { useBreakpoints } from '@rocket.chat/fuselage-hooks'; import type { OptionProp } from '@rocket.chat/ui-client'; import { MultiSelectCustom } from '@rocket.chat/ui-client'; +import type { FormEvent } from 'react'; import React, { useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import FilterByText from '../../../../components/FilterByText'; import type { UsersFilters } from '../AdminUsersPage'; type UsersTableFiltersProps = { @@ -20,9 +21,9 @@ const UsersTableFilters = ({ roleData, setUsersFilters }: UsersTableFiltersProps const [text, setText] = useState(''); const handleSearchTextChange = useCallback( - (text) => { - setUsersFilters({ text, roles: selectedRoles }); - setText(text); + ({ target: { value } }) => { + setText(value); + setUsersFilters({ text: value, roles: selectedRoles }); }, [selectedRoles, setUsersFilters], ); @@ -58,20 +59,43 @@ const UsersTableFilters = ({ roleData, setUsersFilters }: UsersTableFiltersProps ); const breakpoints = useBreakpoints(); - const fixFiltersSize = breakpoints.includes('lg') ? { maxWidth: 'x224', minWidth: 'x224' } : null; + const isLargeScreenOrBigger = breakpoints.includes('lg'); + const fixFiltersSize = isLargeScreenOrBigger ? { maxWidth: 'x224', minWidth: 'x224' } : null; return ( - - - + ) => { + event.preventDefault(); + }} + display='flex' + flexWrap='wrap' + alignItems='center' + > + + } + onChange={handleSearchTextChange} + value={text} + flexGrow={2} + minWidth='x220' + aria-label={t('Search_Users')} + /> + + + + + ); }; diff --git a/apps/meteor/client/views/hooks/useMemberExists.ts b/apps/meteor/client/views/hooks/useMemberExists.ts new file mode 100644 index 000000000000..f737ea6c4e94 --- /dev/null +++ b/apps/meteor/client/views/hooks/useMemberExists.ts @@ -0,0 +1,10 @@ +import { useEndpoint } from '@rocket.chat/ui-contexts'; +import { useQuery } from '@tanstack/react-query'; + +type UseMemberExistsProps = { roomId: string; username: string }; + +export const useMemberExists = ({ roomId, username }: UseMemberExistsProps) => { + const checkMember = useEndpoint('GET', '/v1/rooms.isMember'); + + return useQuery(['rooms/isMember', roomId, username], () => checkMember({ roomId, username })); +}; diff --git a/apps/meteor/client/views/marketplace/components/CategoryFilter/CategoryDropDownList.tsx b/apps/meteor/client/views/marketplace/components/CategoryFilter/CategoryDropDownList.tsx index 68025b1a350a..b96ddddb8b7f 100644 --- a/apps/meteor/client/views/marketplace/components/CategoryFilter/CategoryDropDownList.tsx +++ b/apps/meteor/client/views/marketplace/components/CategoryFilter/CategoryDropDownList.tsx @@ -6,7 +6,7 @@ import type { CategoryDropDownListProps } from '../../definitions/CategoryDropdo const CategoryDropDownList = ({ categories, onSelected }: CategoryDropDownListProps): ReactElement => { return ( - + {categories.map((category, index) => ( {category.label && ( diff --git a/apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/AgentStatusChart.js b/apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/AgentStatusChart.tsx similarity index 61% rename from apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/AgentStatusChart.js rename to apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/AgentStatusChart.tsx index 4724bea74350..f1b2d0eed337 100644 --- a/apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/AgentStatusChart.js +++ b/apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/AgentStatusChart.tsx @@ -1,4 +1,8 @@ +import type { OperationParams } from '@rocket.chat/rest-typings'; +import type { TranslationContextValue, TranslationKey } from '@rocket.chat/ui-contexts'; import { useTranslation } from '@rocket.chat/ui-contexts'; +import type { Chart as ChartType } from 'chart.js'; +import type { MutableRefObject } from 'react'; import React, { useRef, useEffect } from 'react'; import { drawDoughnutChart } from '../../../../../app/livechat/client/lib/chartHandler'; @@ -16,20 +20,25 @@ const initialData = { offline: 0, }; -const init = (canvas, context, t) => +const init = (canvas: HTMLCanvasElement, context: ChartType | undefined, t: TranslationContextValue['translate']): Promise => drawDoughnutChart( canvas, t('Agents'), context, - labels.map((l) => t(l)), + labels.map((l) => t(l as TranslationKey)), Object.values(initialData), ); -const AgentStatusChart = ({ params, reloadRef, ...props }) => { +type AgentStatusChartsProps = { + params: OperationParams<'GET', '/v1/livechat/analytics/dashboards/charts/agents-status'>; + reloadRef: MutableRefObject<{ [x: string]: () => void }>; +}; + +const AgentStatusChart = ({ params, reloadRef, ...props }: AgentStatusChartsProps) => { const t = useTranslation(); - const canvas = useRef(); - const context = useRef(); + const canvas: MutableRefObject = useRef(null); + const context: MutableRefObject = useRef(); const updateChartData = useUpdateChartData({ context, @@ -46,7 +55,9 @@ const AgentStatusChart = ({ params, reloadRef, ...props }) => { useEffect(() => { const initChart = async () => { - context.current = await init(canvas.current, context.current, t); + if (canvas?.current) { + context.current = await init(canvas.current, context.current, t); + } }; initChart(); }, [t]); diff --git a/apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/ChatsChart.js b/apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/ChatsChart.tsx similarity index 61% rename from apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/ChatsChart.js rename to apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/ChatsChart.tsx index 5a540dcd2dbd..b84dfe6918a2 100644 --- a/apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/ChatsChart.js +++ b/apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/ChatsChart.tsx @@ -1,4 +1,8 @@ +import type { OperationParams } from '@rocket.chat/rest-typings'; +import type { TranslationContextValue, TranslationKey } from '@rocket.chat/ui-contexts'; import { useTranslation } from '@rocket.chat/ui-contexts'; +import type { Chart as ChartType } from 'chart.js'; +import type { MutableRefObject } from 'react'; import React, { useRef, useEffect } from 'react'; import { drawDoughnutChart } from '../../../../../app/livechat/client/lib/chartHandler'; @@ -16,20 +20,25 @@ const initialData = { closed: 0, }; -const init = (canvas, context, t) => +const init = (canvas: HTMLCanvasElement, context: ChartType | undefined, t: TranslationContextValue['translate']) => drawDoughnutChart( canvas, t('Chats'), context, - labels.map((l) => t(l)), + labels.map((l) => t(l as TranslationKey)), Object.values(initialData), ); -const ChatsChart = ({ params, reloadRef, ...props }) => { +type ChatsChartProps = { + params: OperationParams<'GET', '/v1/livechat/analytics/dashboards/charts/chats'>; + reloadRef: MutableRefObject<{ [x: string]: () => void }>; +}; + +const ChatsChart = ({ params, reloadRef, ...props }: ChatsChartProps) => { const t = useTranslation(); - const canvas = useRef(); - const context = useRef(); + const canvas: MutableRefObject = useRef(null); + const context: MutableRefObject = useRef(); const updateChartData = useUpdateChartData({ context, @@ -46,7 +55,9 @@ const ChatsChart = ({ params, reloadRef, ...props }) => { useEffect(() => { const initChart = async () => { - context.current = await init(canvas.current, context.current, t); + if (canvas?.current) { + context.current = await init(canvas.current, context.current, t); + } }; initChart(); }, [t]); diff --git a/apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/useUpdateChartData.ts b/apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/useUpdateChartData.ts index 805d828a9893..d942b93a4223 100644 --- a/apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/useUpdateChartData.ts +++ b/apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/useUpdateChartData.ts @@ -1,19 +1,19 @@ import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; +import type { TranslationContextValue } from '@rocket.chat/ui-contexts'; import { type Chart } from 'chart.js'; -import { type TFunction } from 'i18next'; -import { type RefObject } from 'react'; +import { type MutableRefObject } from 'react'; import { updateChart } from '../../../../../app/livechat/client/lib/chartHandler'; type UseUpdateChartDataOptions = { - context: RefObject; - canvas: RefObject; - init: (canvas: HTMLCanvasElement, context: undefined, t: TFunction) => Promise; - t: TFunction; + context: MutableRefObject; + canvas: MutableRefObject; + init: (canvas: HTMLCanvasElement, context: undefined, t: TranslationContextValue['translate']) => Promise; + t: TranslationContextValue['translate']; }; export const useUpdateChartData = ({ context: contextRef, canvas: canvasRef, init, t }: UseUpdateChartDataOptions) => - useMutableCallback(async (label: string, data: { [x: string]: number }) => { + useMutableCallback(async (label: string, data: number[]) => { const canvas = canvasRef.current; if (!canvas) { diff --git a/apps/meteor/client/views/room/Announcement/Announcement.tsx b/apps/meteor/client/views/room/Announcement/Announcement.tsx index 13fa09265b43..d1ee3b5268c9 100644 --- a/apps/meteor/client/views/room/Announcement/Announcement.tsx +++ b/apps/meteor/client/views/room/Announcement/Announcement.tsx @@ -30,7 +30,7 @@ const Announcement = ({ announcement, announcementDetails }: AnnouncementProps) ? announcementDetails() : setModal( - + , diff --git a/apps/meteor/client/views/room/UserCard/UserCardWithData.tsx b/apps/meteor/client/views/room/UserCard/UserCardWithData.tsx index da9f9bb19035..a12e9a143cc2 100644 --- a/apps/meteor/client/views/room/UserCard/UserCardWithData.tsx +++ b/apps/meteor/client/views/room/UserCard/UserCardWithData.tsx @@ -10,6 +10,7 @@ import LocalTime from '../../../components/LocalTime'; import { UserCard, UserCardAction, UserCardRole, UserCardSkeleton } from '../../../components/UserCard'; import { ReactiveUserStatus } from '../../../components/UserStatus'; import { useUserInfoQuery } from '../../../hooks/useUserInfoQuery'; +import { useMemberExists } from '../../hooks/useMemberExists'; import { useUserInfoActions } from '../hooks/useUserInfoActions'; type UserCardWithDataProps = { @@ -24,7 +25,16 @@ const UserCardWithData = ({ username, rid, onOpenUserInfo, onClose }: UserCardWi const getRoles = useRolesDescription(); const showRealNames = Boolean(useSetting('UI_Use_Real_Name')); - const { data, isLoading } = useUserInfoQuery({ username }); + const { data, isLoading: isUserInfoLoading } = useUserInfoQuery({ username }); + const { + data: isMemberData, + refetch, + isSuccess: membershipCheckSuccess, + isLoading: isMembershipStatusLoading, + } = useMemberExists({ roomId: rid, username }); + + const isLoading = isUserInfoLoading || isMembershipStatusLoading; + const isMember = membershipCheckSuccess && isMemberData?.isMember; const user = useMemo(() => { const defaultValue = isLoading ? undefined : null; @@ -62,6 +72,9 @@ const UserCardWithData = ({ username, rid, onOpenUserInfo, onClose }: UserCardWi const { actions: actionsDefinition, menuActions: menuOptions } = useUserInfoActions( { _id: user._id ?? '', username: user.username, name: user.name }, rid, + refetch, + undefined, + isMember, ); const menu = useMemo(() => { diff --git a/apps/meteor/client/views/room/contextualBar/RoomMembers/RoomMembersActions.tsx b/apps/meteor/client/views/room/contextualBar/RoomMembers/RoomMembersActions.tsx index 78d61d9e9f6e..7df7c468bb01 100644 --- a/apps/meteor/client/views/room/contextualBar/RoomMembers/RoomMembersActions.tsx +++ b/apps/meteor/client/views/room/contextualBar/RoomMembers/RoomMembersActions.tsx @@ -16,7 +16,7 @@ type RoomMembersActionsProps = { const RoomMembersActions = ({ username, _id, name, rid, reload }: RoomMembersActionsProps): ReactElement | null => { const t = useTranslation(); - const { menuActions: menuOptions } = useUserInfoActions({ _id, username, name }, rid, reload, 0); + const { menuActions: menuOptions } = useUserInfoActions({ _id, username, name }, rid, reload, 0, true); if (!menuOptions) { return null; } diff --git a/apps/meteor/client/views/room/contextualBar/UserInfo/UserInfoActions.tsx b/apps/meteor/client/views/room/contextualBar/UserInfo/UserInfoActions.tsx index 25761539f532..0b4f30fb1f29 100644 --- a/apps/meteor/client/views/room/contextualBar/UserInfo/UserInfoActions.tsx +++ b/apps/meteor/client/views/room/contextualBar/UserInfo/UserInfoActions.tsx @@ -1,12 +1,13 @@ /* eslint-disable react/display-name, react/no-multi-comp */ import type { IRoom, IUser } from '@rocket.chat/core-typings'; -import { ButtonGroup, IconButton } from '@rocket.chat/fuselage'; +import { ButtonGroup, IconButton, Skeleton } from '@rocket.chat/fuselage'; import { useTranslation } from '@rocket.chat/ui-contexts'; import type { ReactElement } from 'react'; import React, { useMemo } from 'react'; import GenericMenu from '../../../../components/GenericMenu/GenericMenu'; import { UserInfoAction } from '../../../../components/UserInfo'; +import { useMemberExists } from '../../../hooks/useMemberExists'; import { useUserInfoActions } from '../../hooks/useUserInfoActions'; type UserInfoActionsProps = { @@ -17,10 +18,24 @@ type UserInfoActionsProps = { const UserInfoActions = ({ user, rid, backToList }: UserInfoActionsProps): ReactElement => { const t = useTranslation(); + const { + data: isMemberData, + refetch, + isSuccess: membershipCheckSuccess, + isLoading, + } = useMemberExists({ roomId: rid, username: user.username as string }); + + const isMember = membershipCheckSuccess && isMemberData?.isMember; + const { actions: actionsDefinition, menuActions: menuOptions } = useUserInfoActions( { _id: user._id, username: user.username, name: user.name }, rid, - backToList, + () => { + backToList?.(); + refetch(); + }, + undefined, + isMember, ); const menu = useMemo(() => { @@ -51,6 +66,9 @@ const UserInfoActions = ({ user, rid, backToList }: UserInfoActionsProps): React return [...actionsDefinition.map(mapAction), menu].filter(Boolean); }, [actionsDefinition, menu]); + if (isLoading) { + return ; + } return {actions}; }; diff --git a/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useAddUserAction.tsx b/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useAddUserAction.tsx new file mode 100644 index 000000000000..42388b100d41 --- /dev/null +++ b/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useAddUserAction.tsx @@ -0,0 +1,95 @@ +import type { IRoom, IUser } from '@rocket.chat/core-typings'; +import { isRoomFederated } from '@rocket.chat/core-typings'; +import { useEffectEvent } from '@rocket.chat/fuselage-hooks'; +import { + useTranslation, + useUser, + useUserRoom, + useUserSubscription, + useToastMessageDispatch, + useAtLeastOnePermission, + useEndpoint, +} from '@rocket.chat/ui-contexts'; +import { useMemo } from 'react'; + +import * as Federation from '../../../../../lib/federation/Federation'; +import { useAddMatrixUsers } from '../../../contextualBar/RoomMembers/AddUsers/AddMatrixUsers/useAddMatrixUsers'; +import { getRoomDirectives } from '../../../lib/getRoomDirectives'; +import type { UserInfoAction } from '../useUserInfoActions'; + +const inviteUserEndpoints = { + c: '/v1/channels.invite', + p: '/v1/groups.invite', +} as const; + +export const useAddUserAction = ( + user: Pick, + rid: IRoom['_id'], + reload?: () => void, +): UserInfoAction | undefined => { + const t = useTranslation(); + const room = useUserRoom(rid); + const currentUser = useUser(); + const subscription = useUserSubscription(rid); + const dispatchToastMessage = useToastMessageDispatch(); + + const { username, _id: uid } = user; + + if (!room) { + throw Error('Room not provided'); + } + + const hasPermissionToAddUsers = useAtLeastOnePermission( + useMemo(() => [room?.t === 'p' ? 'add-user-to-any-p-room' : 'add-user-to-any-c-room', 'add-user-to-joined-room'], [room?.t]), + rid, + ); + + const userCanAdd = + room && user && isRoomFederated(room) + ? Federation.isEditableByTheUser(currentUser || undefined, room, subscription) + : hasPermissionToAddUsers; + + const { roomCanInvite } = getRoomDirectives({ room, showingUserId: uid, userSubscription: subscription }); + + const inviteUser = useEndpoint('POST', inviteUserEndpoints[room.t === 'p' ? 'p' : 'c']); + + const handleAddUser = useEffectEvent(async ({ users }) => { + const [username] = users; + await inviteUser({ roomId: rid, username }); + reload?.(); + }); + + const addClickHandler = useAddMatrixUsers(); + + const addUserOptionAction = useEffectEvent(async () => { + try { + const users = [username as string]; + if (isRoomFederated(room)) { + addClickHandler.mutate({ + users, + handleSave: handleAddUser, + }); + } else { + await handleAddUser({ users }); + } + dispatchToastMessage({ type: 'success', message: t('User_added') }); + } catch (error) { + dispatchToastMessage({ type: 'error', message: error as Error }); + } + }); + + const addUserOption = useMemo( + () => + roomCanInvite && userCanAdd && room.archived !== true + ? { + content: t('add-to-room'), + icon: 'user-plus' as const, + onClick: addUserOptionAction, + type: 'management' as const, + } + : undefined, + [roomCanInvite, userCanAdd, room.archived, t, addUserOptionAction], + ); + + return addUserOption; +}; diff --git a/apps/meteor/client/views/room/hooks/useUserInfoActions/useUserInfoActions.ts b/apps/meteor/client/views/room/hooks/useUserInfoActions/useUserInfoActions.ts index a058fb862ad5..2a70ff55ee34 100644 --- a/apps/meteor/client/views/room/hooks/useUserInfoActions/useUserInfoActions.ts +++ b/apps/meteor/client/views/room/hooks/useUserInfoActions/useUserInfoActions.ts @@ -6,6 +6,7 @@ import { useMemo } from 'react'; import type { GenericMenuItemProps } from '../../../../components/GenericMenu/GenericMenuItem'; import { useEmbeddedLayout } from '../../../../hooks/useEmbeddedLayout'; +import { useAddUserAction } from './actions/useAddUserAction'; import { useBlockUserAction } from './actions/useBlockUserAction'; import { useCallAction } from './actions/useCallAction'; import { useChangeLeaderAction } from './actions/useChangeLeaderAction'; @@ -39,7 +40,9 @@ export const useUserInfoActions = ( rid: IRoom['_id'], reload?: () => void, size = 2, + isMember?: boolean, ): { actions: [string, UserInfoAction][]; menuActions: any | undefined } => { + const addUser = useAddUserAction(user, rid, reload); const blockUser = useBlockUserAction(user, rid); const changeLeader = useChangeLeaderAction(user, rid); const changeModerator = useChangeModeratorAction(user, rid); @@ -58,15 +61,16 @@ export const useUserInfoActions = ( () => ({ ...(openDirectMessage && !isLayoutEmbedded && { openDirectMessage }), ...(call && { call }), - ...(changeOwner && { changeOwner }), - ...(changeLeader && { changeLeader }), - ...(changeModerator && { changeModerator }), - ...(openModerationConsole && { openModerationConsole }), - ...(ignoreUser && { ignoreUser }), - ...(muteUser && { muteUser }), + ...(!isMember && addUser && { addUser }), + ...(isMember && changeOwner && { changeOwner }), + ...(isMember && changeLeader && { changeLeader }), + ...(isMember && changeModerator && { changeModerator }), + ...(isMember && openModerationConsole && { openModerationConsole }), + ...(isMember && ignoreUser && { ignoreUser }), + ...(isMember && muteUser && { muteUser }), ...(blockUser && { toggleBlock: blockUser }), ...(reportUserOption && { reportUser: reportUserOption }), - ...(removeUser && { removeUser }), + ...(isMember && removeUser && { removeUser }), }), [ openDirectMessage, @@ -81,6 +85,8 @@ export const useUserInfoActions = ( removeUser, reportUserOption, openModerationConsole, + addUser, + isMember, ], ); diff --git a/apps/meteor/client/views/room/lib/getRoomDirectives.ts b/apps/meteor/client/views/room/lib/getRoomDirectives.ts index f03d41622606..f697fc7b51a6 100644 --- a/apps/meteor/client/views/room/lib/getRoomDirectives.ts +++ b/apps/meteor/client/views/room/lib/getRoomDirectives.ts @@ -11,6 +11,7 @@ type getRoomDirectiesType = { roomCanBlock: boolean; roomCanMute: boolean; roomCanRemove: boolean; + roomCanInvite: boolean; }; export const getRoomDirectives = ({ @@ -24,7 +25,7 @@ export const getRoomDirectives = ({ }): getRoomDirectiesType => { const roomDirectives = room?.t && roomCoordinator.getRoomDirectives(room.t); - const [roomCanSetOwner, roomCanSetLeader, roomCanSetModerator, roomCanIgnore, roomCanBlock, roomCanMute, roomCanRemove] = [ + const [roomCanSetOwner, roomCanSetLeader, roomCanSetModerator, roomCanIgnore, roomCanBlock, roomCanMute, roomCanRemove, roomCanInvite] = [ ...((roomDirectives && [ roomDirectives.allowMemberAction(room, RoomMemberActions.SET_AS_OWNER, showingUserId, userSubscription), roomDirectives.allowMemberAction(room, RoomMemberActions.SET_AS_LEADER, showingUserId, userSubscription), @@ -33,9 +34,10 @@ export const getRoomDirectives = ({ roomDirectives.allowMemberAction(room, RoomMemberActions.BLOCK, showingUserId, userSubscription), roomDirectives.allowMemberAction(room, RoomMemberActions.MUTE, showingUserId, userSubscription), roomDirectives.allowMemberAction(room, RoomMemberActions.REMOVE_USER, showingUserId, userSubscription), + roomDirectives.allowMemberAction(room, RoomMemberActions.INVITE, showingUserId, userSubscription), ]) ?? []), ]; - return { roomCanSetOwner, roomCanSetLeader, roomCanSetModerator, roomCanIgnore, roomCanBlock, roomCanMute, roomCanRemove }; + return { roomCanSetOwner, roomCanSetLeader, roomCanSetModerator, roomCanIgnore, roomCanBlock, roomCanMute, roomCanRemove, roomCanInvite }; }; diff --git a/apps/meteor/definition/externals/meteor/accounts-base.d.ts b/apps/meteor/definition/externals/meteor/accounts-base.d.ts index 3f0b148120e7..31b70f7b7154 100644 --- a/apps/meteor/definition/externals/meteor/accounts-base.d.ts +++ b/apps/meteor/definition/externals/meteor/accounts-base.d.ts @@ -42,6 +42,8 @@ declare module 'meteor/accounts-base' { function _clearAllLoginTokens(userId: string | null): void; + function config(options: { clientStorage: 'session' | 'local' }): void; + class ConfigError extends Error {} class LoginCancelledError extends Error { diff --git a/apps/meteor/definition/externals/meteor/mongo.d.ts b/apps/meteor/definition/externals/meteor/mongo.d.ts index d56ddfd38e42..443c8de2a879 100644 --- a/apps/meteor/definition/externals/meteor/mongo.d.ts +++ b/apps/meteor/definition/externals/meteor/mongo.d.ts @@ -16,6 +16,7 @@ declare module 'meteor/mongo' { interface MongoConnection { db: mongodb.Db; + client: mongodb.MongoClient; _oplogHandle: OplogHandle; rawCollection(name: string): mongodb.Collection; } diff --git a/apps/meteor/ee/app/livechat-enterprise/server/hooks/onCloseLivechat.ts b/apps/meteor/ee/app/livechat-enterprise/server/hooks/onCloseLivechat.ts index 4e76f396617b..bc2d4fb6a3fb 100644 --- a/apps/meteor/ee/app/livechat-enterprise/server/hooks/onCloseLivechat.ts +++ b/apps/meteor/ee/app/livechat-enterprise/server/hooks/onCloseLivechat.ts @@ -1,6 +1,7 @@ import type { IOmnichannelRoom } from '@rocket.chat/core-typings'; import { LivechatRooms, Subscriptions } from '@rocket.chat/models'; +import { notifyOnSubscriptionChangedByRoomId } from '../../../../../app/lib/server/lib/notifyListener'; import { settings } from '../../../../../app/settings/server'; import { callbacks } from '../../../../../lib/callbacks'; import { AutoCloseOnHoldScheduler } from '../lib/AutoCloseOnHoldScheduler'; @@ -16,12 +17,16 @@ const onCloseLivechat = async (params: LivechatCloseCallbackParams) => { room: { _id: roomId }, } = params; - await Promise.all([ + const responses = await Promise.all([ LivechatRooms.unsetOnHoldByRoomId(roomId), Subscriptions.unsetOnHoldByRoomId(roomId), AutoCloseOnHoldScheduler.unscheduleRoom(roomId), ]); + if (responses[1].modifiedCount) { + void notifyOnSubscriptionChangedByRoomId(roomId); + } + if (!settings.get('Livechat_waiting_queue')) { return params; } diff --git a/apps/meteor/ee/app/livechat-enterprise/server/services/omnichannel.internalService.ts b/apps/meteor/ee/app/livechat-enterprise/server/services/omnichannel.internalService.ts index c3133387865d..f2db61ddbc9a 100644 --- a/apps/meteor/ee/app/livechat-enterprise/server/services/omnichannel.internalService.ts +++ b/apps/meteor/ee/app/livechat-enterprise/server/services/omnichannel.internalService.ts @@ -5,7 +5,11 @@ import type { IOmnichannelRoom, IUser, ILivechatInquiryRecord, IOmnichannelSyste import { Logger } from '@rocket.chat/logger'; import { LivechatRooms, Subscriptions, LivechatInquiry } from '@rocket.chat/models'; -import { notifyOnLivechatInquiryChangedById, notifyOnRoomChangedById } from '../../../../../app/lib/server/lib/notifyListener'; +import { + notifyOnSubscriptionChangedByRoomId, + notifyOnLivechatInquiryChangedById, + notifyOnRoomChangedById, +} from '../../../../../app/lib/server/lib/notifyListener'; import { dispatchAgentDelegated } from '../../../../../app/livechat/server/lib/Helper'; import { queueInquiry } from '../../../../../app/livechat/server/lib/QueueManager'; import { RoutingManager } from '../../../../../app/livechat/server/lib/RoutingManager'; @@ -53,15 +57,21 @@ export class OmnichannelEE extends ServiceClassInternal implements IOmnichannelE throw new Error('error-unserved-rooms-cannot-be-placed-onhold'); } - await Promise.all([ + const [roomResult, subsResult] = await Promise.all([ LivechatRooms.setOnHoldByRoomId(roomId), Subscriptions.setOnHoldByRoomId(roomId), Message.saveSystemMessage('omnichannel_placed_chat_on_hold', roomId, '', onHoldBy, { comment }), ]); - await callbacks.run('livechat:afterOnHold', room); + if (roomResult.modifiedCount) { + void notifyOnRoomChangedById(roomId); + } - void notifyOnRoomChangedById(roomId); + if (subsResult.modifiedCount) { + void notifyOnSubscriptionChangedByRoomId(roomId); + } + + await callbacks.run('livechat:afterOnHold', room); } async resumeRoomOnHold( @@ -104,15 +114,21 @@ export class OmnichannelEE extends ServiceClassInternal implements IOmnichannelE clientAction, }); - await Promise.all([ + const [roomResult, subsResult] = await Promise.all([ LivechatRooms.unsetOnHoldByRoomId(roomId), Subscriptions.unsetOnHoldByRoomId(roomId), Message.saveSystemMessage('omnichannel_on_hold_chat_resumed', roomId, '', resumeBy, { comment }), ]); - await callbacks.run('livechat:afterOnHoldChatResumed', room); + if (roomResult.modifiedCount) { + void notifyOnRoomChangedById(roomId); + } - void notifyOnRoomChangedById(roomId); + if (subsResult.modifiedCount) { + void notifyOnSubscriptionChangedByRoomId(roomId); + } + + await callbacks.run('livechat:afterOnHoldChatResumed', room); } private async attemptToAssignRoomToServingAgentElseQueueIt({ diff --git a/apps/meteor/ee/app/message-read-receipt/server/hooks/afterSaveMessage.ts b/apps/meteor/ee/app/message-read-receipt/server/hooks/afterSaveMessage.ts index 9180632768af..62623b1a4a1c 100644 --- a/apps/meteor/ee/app/message-read-receipt/server/hooks/afterSaveMessage.ts +++ b/apps/meteor/ee/app/message-read-receipt/server/hooks/afterSaveMessage.ts @@ -1,5 +1,4 @@ -import { isEditedMessage, isOmnichannelRoom } from '@rocket.chat/core-typings'; -import { Subscriptions } from '@rocket.chat/models'; +import { isEditedMessage } from '@rocket.chat/core-typings'; import { callbacks } from '../../../../../lib/callbacks'; import { ReadReceipt } from '../../../../server/lib/message-read-receipt/ReadReceipt'; @@ -12,11 +11,6 @@ callbacks.add( return message; } - if (!isOmnichannelRoom(room) || !room.closedAt) { - // set subscription as read right after message was sent - await Subscriptions.setAsReadByRoomIdAndUserId(room._id, message.u._id); - } - // mark message as read as well await ReadReceipt.markMessageAsReadBySender(message, room, message.u._id); diff --git a/apps/meteor/ee/server/local-services/federation/infrastructure/rocket-chat/hooks/index.ts b/apps/meteor/ee/server/local-services/federation/infrastructure/rocket-chat/hooks/index.ts index 13519e873dfb..760c8281af38 100644 --- a/apps/meteor/ee/server/local-services/federation/infrastructure/rocket-chat/hooks/index.ts +++ b/apps/meteor/ee/server/local-services/federation/infrastructure/rocket-chat/hooks/index.ts @@ -1,24 +1,20 @@ import type { IRoom, IUser, Username } from '@rocket.chat/core-typings'; import { isRoomFederated } from '@rocket.chat/core-typings'; -import { settings } from '../../../../../../../app/settings/server'; import { callbacks } from '../../../../../../../lib/callbacks'; +import { throwIfFederationNotEnabledOrNotReady } from '../../../../../../../server/services/federation/utils'; export class FederationHooksEE { public static onFederatedRoomCreated(callback: (room: IRoom, owner: IUser, originalMemberList: string[]) => Promise): void { callbacks.add( 'federation.afterCreateFederatedRoom', async (room: IRoom, params: { owner: IUser; originalMemberList: string[] }) => { - if ( - !room || - !isRoomFederated(room) || - !params || - !params.owner || - !params.originalMemberList || - !settings.get('Federation_Matrix_enabled') - ) { + if (!room || !isRoomFederated(room) || !params || !params.owner || !params.originalMemberList) { return; } + + throwIfFederationNotEnabledOrNotReady(); + await callback(room, params.owner, params.originalMemberList); }, callbacks.priority.HIGH, @@ -30,16 +26,12 @@ export class FederationHooksEE { callbacks.add( 'federation.onAddUsersToARoom', async (params: { invitees: IUser[] | Username[]; inviter: IUser }, room: IRoom) => { - if ( - !room || - !isRoomFederated(room) || - !params || - !params.invitees || - !params.inviter || - !settings.get('Federation_Matrix_enabled') - ) { + if (!room || !isRoomFederated(room) || !params || !params.invitees || !params.inviter) { return; } + + throwIfFederationNotEnabledOrNotReady(); + await callback(room, params.invitees, params.inviter); }, callbacks.priority.HIGH, @@ -48,9 +40,12 @@ export class FederationHooksEE { callbacks.add( 'afterAddedToRoom', async (params: { user: IUser; inviter?: IUser }, room: IRoom) => { - if (!room || !isRoomFederated(room) || !params || !params.user || !settings.get('Federation_Matrix_enabled')) { + if (!room || !isRoomFederated(room) || !params || !params.user) { return; } + + throwIfFederationNotEnabledOrNotReady(); + await callback(room, [params.user], params?.inviter); }, callbacks.priority.HIGH, @@ -62,9 +57,10 @@ export class FederationHooksEE { callbacks.add( 'afterCreateDirectRoom', async (room: IRoom, params: { members: IUser[]; creatorId: IUser['_id'] }) => { - if (!room || !params || !params.creatorId || !params.creatorId || !settings.get('Federation_Matrix_enabled')) { + if (!room || !params || !params.creatorId || !params.creatorId) { return; } + throwIfFederationNotEnabledOrNotReady(); await callback(room, params.creatorId, params.members); }, callbacks.priority.HIGH, @@ -76,9 +72,10 @@ export class FederationHooksEE { callbacks.add( 'beforeCreateDirectRoom', async (members: IUser[]) => { - if (!members || !settings.get('Federation_Matrix_enabled')) { + if (!members) { return; } + throwIfFederationNotEnabledOrNotReady(); await callback(members); }, callbacks.priority.HIGH, @@ -90,9 +87,10 @@ export class FederationHooksEE { callbacks.add( 'federation.beforeAddUserToARoom', async (params: { user: IUser | string; inviter?: IUser }, room: IRoom) => { - if (!room || !isRoomFederated(room) || !params || !params.user || !settings.get('Federation_Matrix_enabled')) { + if (!room || !isRoomFederated(room) || !params || !params.user) { return; } + throwIfFederationNotEnabledOrNotReady(); await callback(params.user, room, params.inviter); }, callbacks.priority.HIGH, diff --git a/apps/meteor/ee/server/local-services/federation/service.ts b/apps/meteor/ee/server/local-services/federation/service.ts index 6397f01ee9ac..5c6e210aefb8 100644 --- a/apps/meteor/ee/server/local-services/federation/service.ts +++ b/apps/meteor/ee/server/local-services/federation/service.ts @@ -1,4 +1,9 @@ -import type { IFederationServiceEE, IFederationJoinExternalPublicRoomInput } from '@rocket.chat/core-services'; +import type { + IFederationServiceEE, + IFederationJoinExternalPublicRoomInput, + FederationConfigurationStatus, +} from '@rocket.chat/core-services'; +import type { IRoom } from '@rocket.chat/core-typings'; import type { FederationPaginatedResult, IFederationPublicRooms } from '@rocket.chat/rest-typings'; import { AbstractFederationService } from '../../../../server/services/federation/service'; @@ -216,7 +221,27 @@ export class FederationServiceEE extends AbstractBaseFederationServiceEE impleme return super.stopped(); } - async deactivateRemoteUser(userId: string) { + public async verifyConfiguration(): Promise { + return super.verifyConfiguration(); + } + + public async markConfigurationValid(): Promise { + return super.markConfigurationValid(); + } + + public async markConfigurationInvalid(): Promise { + return super.markConfigurationInvalid(); + } + + public async configurationStatus(): Promise { + return super.configurationStatus(); + } + + public async beforeCreateRoom(room: Partial): Promise { + return super.beforeCreateRoom(room); + } + + async deactivateRemoteUser(userId: string): Promise { return super.deactivateRemoteUser(userId); } } diff --git a/apps/meteor/ee/server/services/package.json b/apps/meteor/ee/server/services/package.json index 76a0c59d54e6..58b73351c4e0 100644 --- a/apps/meteor/ee/server/services/package.json +++ b/apps/meteor/ee/server/services/package.json @@ -18,7 +18,7 @@ "author": "Rocket.Chat", "license": "MIT", "dependencies": { - "@rocket.chat/apps-engine": "1.44.0", + "@rocket.chat/apps-engine": "1.45.0-alpha.866", "@rocket.chat/core-services": "workspace:^", "@rocket.chat/core-typings": "workspace:^", "@rocket.chat/emitter": "~0.31.25", @@ -50,7 +50,7 @@ "ws": "^8.8.1" }, "devDependencies": { - "@rocket.chat/icons": "^0.36.0", + "@rocket.chat/icons": "~0.38.0", "@types/cookie": "^0.5.3", "@types/cookie-parser": "^1.4.5", "@types/ejson": "^2.2.1", diff --git a/apps/meteor/ee/tests/unit/server/federation/server/infrastructure/rocket-chat/hooks/hooks.spec.ts b/apps/meteor/ee/tests/unit/server/federation/server/infrastructure/rocket-chat/hooks/hooks.spec.ts index 67294ca2f7c7..86ab9df628d8 100644 --- a/apps/meteor/ee/tests/unit/server/federation/server/infrastructure/rocket-chat/hooks/hooks.spec.ts +++ b/apps/meteor/ee/tests/unit/server/federation/server/infrastructure/rocket-chat/hooks/hooks.spec.ts @@ -3,7 +3,7 @@ import proxyquire from 'proxyquire'; import sinon from 'sinon'; const remove = sinon.stub(); -const get = sinon.stub(); +const throwIfFederationNotEnabledOrNotReady = sinon.stub(); const hooks: Record = {}; const { FederationHooksEE } = proxyquire @@ -28,20 +28,19 @@ const { FederationHooksEE } = proxyquire }, }, }, - '../../../../../../../app/settings/server': { - settings: { get }, + '../../../../../../../server/services/federation/utils': { + throwIfFederationNotEnabledOrNotReady, }, }); describe('FederationEE - Infrastructure - RocketChat - Hooks', () => { afterEach(() => { remove.reset(); - get.reset(); + throwIfFederationNotEnabledOrNotReady.reset(); }); describe('#onFederatedRoomCreated()', () => { it('should NOT execute the callback if no room was provided', () => { - get.returns(true); const stub = sinon.stub(); FederationHooksEE.onFederatedRoomCreated(stub); hooks['federation-v2-after-create-room'](); @@ -49,7 +48,6 @@ describe('FederationEE - Infrastructure - RocketChat - Hooks', () => { }); it('should NOT execute the callback if the provided room is not federated', () => { - get.returns(true); const stub = sinon.stub(); FederationHooksEE.onFederatedRoomCreated(stub); hooks['federation-v2-after-create-room']({}); @@ -57,7 +55,6 @@ describe('FederationEE - Infrastructure - RocketChat - Hooks', () => { }); it('should NOT execute the callback if no params were provided', () => { - get.returns(true); const stub = sinon.stub(); FederationHooksEE.onFederatedRoomCreated(stub); hooks['federation-v2-after-create-room']({ federated: true }); @@ -65,7 +62,6 @@ describe('FederationEE - Infrastructure - RocketChat - Hooks', () => { }); it('should NOT execute the callback if no owner was provided', () => { - get.returns(true); const stub = sinon.stub(); FederationHooksEE.onFederatedRoomCreated(stub); hooks['federation-v2-after-create-room']({ federated: true }, {}); @@ -73,7 +69,6 @@ describe('FederationEE - Infrastructure - RocketChat - Hooks', () => { }); it('should NOT execute the callback if no member list was provided', () => { - get.returns(true); const stub = sinon.stub(); FederationHooksEE.onFederatedRoomCreated(stub); hooks['federation-v2-after-create-room']({ federated: true }, { owner: 'owner' }); @@ -81,15 +76,18 @@ describe('FederationEE - Infrastructure - RocketChat - Hooks', () => { }); it('should NOT execute the callback if federation module was disabled', () => { - get.returns(false); + const error = new Error(); + throwIfFederationNotEnabledOrNotReady.throws(error); const stub = sinon.stub(); FederationHooksEE.onFederatedRoomCreated(stub); - hooks['federation-v2-after-create-room']({ federated: true }, { owner: 'owner', originalMemberList: [] }); + // eslint-disable-next-line @typescript-eslint/no-floating-promises + expect( + hooks['federation-v2-after-create-room']({ federated: true }, { owner: 'owner', originalMemberList: [] }), + ).to.have.rejectedWith(error); expect(stub.called).to.be.false; }); it('should execute the callback when everything is correct', () => { - get.returns(true); const stub = sinon.stub(); FederationHooksEE.onFederatedRoomCreated(stub); hooks['federation-v2-after-create-room']({ federated: true }, { owner: 'owner', originalMemberList: [] }); @@ -99,7 +97,6 @@ describe('FederationEE - Infrastructure - RocketChat - Hooks', () => { describe('#onUsersAddedToARoom() - afterAddedToRoom', () => { it('should NOT execute the callback if no room was provided', () => { - get.returns(true); const stub = sinon.stub(); FederationHooksEE.onUsersAddedToARoom(stub); hooks['federation-v2-after-add-user-to-a-room'](); @@ -107,7 +104,6 @@ describe('FederationEE - Infrastructure - RocketChat - Hooks', () => { }); it('should NOT execute the callback if the provided room is not federated', () => { - get.returns(true); const stub = sinon.stub(); FederationHooksEE.onUsersAddedToARoom(stub); hooks['federation-v2-after-add-user-to-a-room']({}, {}); @@ -115,7 +111,6 @@ describe('FederationEE - Infrastructure - RocketChat - Hooks', () => { }); it('should NOT execute the callback if no params were provided', () => { - get.returns(true); const stub = sinon.stub(); FederationHooksEE.onUsersAddedToARoom(stub); hooks['federation-v2-after-add-user-to-a-room']({}, { federated: true }); @@ -123,7 +118,6 @@ describe('FederationEE - Infrastructure - RocketChat - Hooks', () => { }); it('should NOT execute the callback if no user was provided', () => { - get.returns(true); const stub = sinon.stub(); FederationHooksEE.onUsersAddedToARoom(stub); hooks['federation-v2-after-add-user-to-a-room']({}, { federated: true }, {}); @@ -131,15 +125,18 @@ describe('FederationEE - Infrastructure - RocketChat - Hooks', () => { }); it('should NOT execute the callback if federation module was disabled', () => { - get.returns(false); + const error = new Error(); + throwIfFederationNotEnabledOrNotReady.throws(error); const stub = sinon.stub(); FederationHooksEE.onUsersAddedToARoom(stub); - hooks['federation-v2-after-add-user-to-a-room']({ user: 'user', inviter: 'inviter' }, { federated: true }); + // eslint-disable-next-line @typescript-eslint/no-floating-promises + expect( + hooks['federation-v2-after-add-user-to-a-room']({ user: 'user', inviter: 'inviter' }, { federated: true }), + ).to.have.rejectedWith(error); expect(stub.called).to.be.false; }); it('should execute the callback when everything is correct', () => { - get.returns(true); const stub = sinon.stub(); FederationHooksEE.onUsersAddedToARoom(stub); hooks['federation-v2-after-add-user-to-a-room']({ user: 'user', inviter: 'inviter' }, { federated: true }); @@ -147,7 +144,6 @@ describe('FederationEE - Infrastructure - RocketChat - Hooks', () => { }); it('should execute the callback even if there is no inviter (when auto-joining)', () => { - get.returns(true); const stub = sinon.stub(); FederationHooksEE.onUsersAddedToARoom(stub); hooks['federation-v2-after-add-user-to-a-room']({ user: 'user' }, { federated: true }); @@ -156,7 +152,6 @@ describe('FederationEE - Infrastructure - RocketChat - Hooks', () => { }); describe('#onUsersAddedToARoom() - federation.onAddUsersToARoom', () => { it('should NOT execute the callback if no room was provided', () => { - get.returns(true); const stub = sinon.stub(); FederationHooksEE.onUsersAddedToARoom(stub); hooks['federation-v2-on-add-users-to-a-room'](); @@ -164,7 +159,6 @@ describe('FederationEE - Infrastructure - RocketChat - Hooks', () => { }); it('should NOT execute the callback if the provided room is not federated', () => { - get.returns(true); const stub = sinon.stub(); FederationHooksEE.onUsersAddedToARoom(stub); hooks['federation-v2-on-add-users-to-a-room']({}, {}); @@ -172,7 +166,6 @@ describe('FederationEE - Infrastructure - RocketChat - Hooks', () => { }); it('should NOT execute the callback if no params were provided', () => { - get.returns(true); const stub = sinon.stub(); FederationHooksEE.onUsersAddedToARoom(stub); hooks['federation-v2-on-add-users-to-a-room']({}, { federated: true }); @@ -180,7 +173,6 @@ describe('FederationEE - Infrastructure - RocketChat - Hooks', () => { }); it('should NOT execute the callback if no user was provided', () => { - get.returns(true); const stub = sinon.stub(); FederationHooksEE.onUsersAddedToARoom(stub); hooks['federation-v2-on-add-users-to-a-room']({}, { federated: true }, {}); @@ -188,7 +180,6 @@ describe('FederationEE - Infrastructure - RocketChat - Hooks', () => { }); it('should NOT execute the callback if no inviter was provided', () => { - get.returns(true); const stub = sinon.stub(); FederationHooksEE.onUsersAddedToARoom(stub); hooks['federation-v2-on-add-users-to-a-room']({ invitees: ['user'] }, { federated: true }); @@ -196,15 +187,18 @@ describe('FederationEE - Infrastructure - RocketChat - Hooks', () => { }); it('should NOT execute the callback if federation module was disabled', () => { - get.returns(false); + const error = new Error(); + throwIfFederationNotEnabledOrNotReady.throws(error); const stub = sinon.stub(); FederationHooksEE.onUsersAddedToARoom(stub); - hooks['federation-v2-on-add-users-to-a-room']({ invitees: ['user'], inviter: 'inviter' }, { federated: true }); + // eslint-disable-next-line @typescript-eslint/no-floating-promises + expect( + hooks['federation-v2-on-add-users-to-a-room']({ invitees: ['user'], inviter: 'inviter' }, { federated: true }), + ).to.have.rejectedWith(error); expect(stub.called).to.be.false; }); it('should execute the callback when everything is correct', () => { - get.returns(true); const stub = sinon.stub(); FederationHooksEE.onUsersAddedToARoom(stub); hooks['federation-v2-on-add-users-to-a-room']({ invitees: ['user'], inviter: 'inviter' }, { federated: true }); @@ -214,7 +208,6 @@ describe('FederationEE - Infrastructure - RocketChat - Hooks', () => { describe('#onDirectMessageRoomCreated()', () => { it('should NOT execute the callback if no room was provided', () => { - get.returns(true); const stub = sinon.stub(); FederationHooksEE.onDirectMessageRoomCreated(stub); hooks['federation-v2-after-create-direct-message-room'](); @@ -222,7 +215,6 @@ describe('FederationEE - Infrastructure - RocketChat - Hooks', () => { }); it('should NOT execute the callback if the provided room is not federated', () => { - get.returns(true); const stub = sinon.stub(); FederationHooksEE.onDirectMessageRoomCreated(stub); hooks['federation-v2-after-create-direct-message-room']({}, {}); @@ -230,7 +222,6 @@ describe('FederationEE - Infrastructure - RocketChat - Hooks', () => { }); it('should NOT execute the callback if no params were provided', () => { - get.returns(true); const stub = sinon.stub(); FederationHooksEE.onDirectMessageRoomCreated(stub); hooks['federation-v2-after-create-direct-message-room']({ federated: true }); @@ -238,7 +229,6 @@ describe('FederationEE - Infrastructure - RocketChat - Hooks', () => { }); it('should NOT execute the callback if no members was provided', () => { - get.returns(true); const stub = sinon.stub(); FederationHooksEE.onDirectMessageRoomCreated(stub); hooks['federation-v2-after-create-direct-message-room']({ federated: true }); @@ -246,7 +236,6 @@ describe('FederationEE - Infrastructure - RocketChat - Hooks', () => { }); it('should NOT execute the callback if no creatorId was provided', () => { - get.returns(true); const stub = sinon.stub(); FederationHooksEE.onDirectMessageRoomCreated(stub); hooks['federation-v2-after-create-direct-message-room']({ federated: true }, { members: [] }); @@ -254,15 +243,18 @@ describe('FederationEE - Infrastructure - RocketChat - Hooks', () => { }); it('should NOT execute the callback if federation module was disabled', () => { - get.returns(false); + const error = new Error(); + throwIfFederationNotEnabledOrNotReady.throws(error); const stub = sinon.stub(); FederationHooksEE.onDirectMessageRoomCreated(stub); - hooks['federation-v2-after-create-direct-message-room']({ federated: true }, { creatorId: 'creatorId', members: [] }); + // eslint-disable-next-line @typescript-eslint/no-floating-promises + expect( + hooks['federation-v2-after-create-direct-message-room']({ federated: true }, { creatorId: 'creatorId', members: [] }), + ).to.have.rejectedWith(error); expect(stub.called).to.be.false; }); it('should execute the callback when everything is correct', () => { - get.returns(true); const stub = sinon.stub(); FederationHooksEE.onDirectMessageRoomCreated(stub); hooks['federation-v2-after-create-direct-message-room']({ federated: true }, { creatorId: 'creatorId', members: [] }); @@ -272,7 +264,6 @@ describe('FederationEE - Infrastructure - RocketChat - Hooks', () => { describe('#beforeDirectMessageRoomCreate()', () => { it('should NOT execute the callback if no members was provided', () => { - get.returns(true); const stub = sinon.stub(); FederationHooksEE.beforeDirectMessageRoomCreate(stub); hooks['federation-v2-before-create-direct-message-room'](); @@ -280,15 +271,16 @@ describe('FederationEE - Infrastructure - RocketChat - Hooks', () => { }); it('should NOT execute the callback if federation module was disabled', () => { - get.returns(false); + const error = new Error(); + throwIfFederationNotEnabledOrNotReady.throws(error); const stub = sinon.stub(); FederationHooksEE.beforeDirectMessageRoomCreate(stub); - hooks['federation-v2-before-create-direct-message-room']([]); + // eslint-disable-next-line @typescript-eslint/no-floating-promises + expect(hooks['federation-v2-before-create-direct-message-room']([])).to.have.rejectedWith(error); expect(stub.called).to.be.false; }); it('should execute the callback when everything is correct', () => { - get.returns(true); const stub = sinon.stub(); FederationHooksEE.beforeDirectMessageRoomCreate(stub); hooks['federation-v2-before-create-direct-message-room']([]); @@ -298,7 +290,6 @@ describe('FederationEE - Infrastructure - RocketChat - Hooks', () => { describe('#beforeAddUserToARoom()', () => { it('should NOT execute the callback if no room was provided', () => { - get.returns(true); const stub = sinon.stub(); FederationHooksEE.beforeAddUserToARoom(stub); hooks['federation-v2-before-add-user-to-the-room'](); @@ -306,7 +297,6 @@ describe('FederationEE - Infrastructure - RocketChat - Hooks', () => { }); it('should NOT execute the callback if the provided room is not federated', () => { - get.returns(true); const stub = sinon.stub(); FederationHooksEE.beforeAddUserToARoom(stub); hooks['federation-v2-before-add-user-to-the-room']({}, {}); @@ -314,7 +304,6 @@ describe('FederationEE - Infrastructure - RocketChat - Hooks', () => { }); it('should NOT execute the callback if no params were provided', () => { - get.returns(true); const stub = sinon.stub(); FederationHooksEE.beforeAddUserToARoom(stub); hooks['federation-v2-before-add-user-to-the-room']({}, { federated: true }); @@ -322,7 +311,6 @@ describe('FederationEE - Infrastructure - RocketChat - Hooks', () => { }); it('should NOT execute the callback if no user was provided', () => { - get.returns(true); const stub = sinon.stub(); FederationHooksEE.beforeAddUserToARoom(stub); hooks['federation-v2-before-add-user-to-the-room']({}, { federated: true }, {}); @@ -330,15 +318,18 @@ describe('FederationEE - Infrastructure - RocketChat - Hooks', () => { }); it('should NOT execute the callback if federation module was disabled', () => { - get.returns(false); + const error = new Error(); + throwIfFederationNotEnabledOrNotReady.throws(error); const stub = sinon.stub(); FederationHooksEE.beforeAddUserToARoom(stub); - hooks['federation-v2-before-add-user-to-the-room']({ user: 'user', inviter: 'inviter' }, { federated: true }); + // eslint-disable-next-line @typescript-eslint/no-floating-promises + expect( + hooks['federation-v2-before-add-user-to-the-room']({ user: 'user', inviter: 'inviter' }, { federated: true }), + ).to.have.rejectedWith(error); expect(stub.called).to.be.false; }); it('should execute the callback when everything is correct', () => { - get.returns(true); const stub = sinon.stub(); FederationHooksEE.beforeAddUserToARoom(stub); hooks['federation-v2-before-add-user-to-the-room']({ user: 'user', inviter: 'inviter' }, { federated: true }); diff --git a/apps/meteor/lib/publishFields.ts b/apps/meteor/lib/publishFields.ts index 1e9274526ef5..c1483ea86cd1 100644 --- a/apps/meteor/lib/publishFields.ts +++ b/apps/meteor/lib/publishFields.ts @@ -86,7 +86,6 @@ export const roomFields = { reactWhenReadOnly: 1, sysMes: 1, sentiment: 1, - streamingOptions: 1, broadcast: 1, encrypted: 1, e2eKeyId: 1, diff --git a/apps/meteor/package.json b/apps/meteor/package.json index 5addaf756f8a..9c49c2cb33f8 100644 --- a/apps/meteor/package.json +++ b/apps/meteor/package.json @@ -230,7 +230,7 @@ "@rocket.chat/agenda": "workspace:^", "@rocket.chat/api-client": "workspace:^", "@rocket.chat/apps": "workspace:^", - "@rocket.chat/apps-engine": "1.44.0", + "@rocket.chat/apps-engine": "1.45.0-alpha.866", "@rocket.chat/base64": "workspace:^", "@rocket.chat/cas-validate": "workspace:^", "@rocket.chat/core-services": "workspace:^", @@ -249,7 +249,7 @@ "@rocket.chat/fuselage-ui-kit": "workspace:^", "@rocket.chat/gazzodown": "workspace:^", "@rocket.chat/i18n": "workspace:^", - "@rocket.chat/icons": "^0.36.0", + "@rocket.chat/icons": "~0.38.0", "@rocket.chat/instance-status": "workspace:^", "@rocket.chat/jwt": "workspace:^", "@rocket.chat/layout": "~0.31.26", @@ -343,7 +343,6 @@ "filesize": "9.0.11", "generate-password": "^1.7.1", "google-libphonenumber": "^3.2.33", - "googleapis": "^104.0.0", "gravatar": "^1.8.2", "he": "^1.2.0", "highlight.js": "^11.6.0", diff --git a/apps/meteor/server/database/utils.ts b/apps/meteor/server/database/utils.ts index f42b15f926a8..ec3864586924 100644 --- a/apps/meteor/server/database/utils.ts +++ b/apps/meteor/server/database/utils.ts @@ -1,3 +1,3 @@ import { MongoInternals } from 'meteor/mongo'; -export const { db } = MongoInternals.defaultRemoteCollectionDriver().mongo; +export const { db, client } = MongoInternals.defaultRemoteCollectionDriver().mongo; diff --git a/apps/meteor/server/database/watchCollections.ts b/apps/meteor/server/database/watchCollections.ts index 5cd56af62fd6..6dd173d5d323 100644 --- a/apps/meteor/server/database/watchCollections.ts +++ b/apps/meteor/server/database/watchCollections.ts @@ -29,7 +29,7 @@ const onlyCollections = DBWATCHER_ONLY_COLLECTIONS.split(',') .filter(Boolean); export function getWatchCollections(): string[] { - const collections = [InstanceStatus.getCollectionName(), Subscriptions.getCollectionName()]; + const collections = [InstanceStatus.getCollectionName()]; // add back to the list of collections in case db watchers are enabled if (!dbWatchersDisabled) { @@ -45,6 +45,7 @@ export function getWatchCollections(): string[] { collections.push(LoginServiceConfiguration.getCollectionName()); collections.push(EmailInbox.getCollectionName()); collections.push(IntegrationHistory.getCollectionName()); + collections.push(Subscriptions.getCollectionName()); collections.push(Settings.getCollectionName()); collections.push(LivechatDepartmentAgents.getCollectionName()); } diff --git a/apps/meteor/server/lib/readMessages.ts b/apps/meteor/server/lib/readMessages.ts index d7c8cf559288..3be43a875fac 100644 --- a/apps/meteor/server/lib/readMessages.ts +++ b/apps/meteor/server/lib/readMessages.ts @@ -1,6 +1,7 @@ import type { IRoom, IUser } from '@rocket.chat/core-typings'; import { NotificationQueue, Subscriptions } from '@rocket.chat/models'; +import { notifyOnSubscriptionChangedByRoomIdAndUserId } from '../../app/lib/server/lib/notifyListener'; import { callbacks } from '../../lib/callbacks'; export async function readMessages(rid: IRoom['_id'], uid: IUser['_id'], readThreads: boolean): Promise { @@ -15,7 +16,10 @@ export async function readMessages(rid: IRoom['_id'], uid: IUser['_id'], readThr // do not mark room as read if there are still unread threads const alert = !!(sub.alert && !readThreads && sub.tunread && sub.tunread.length > 0); - await Subscriptions.setAsReadByRoomIdAndUserId(rid, uid, readThreads, alert); + const setAsReadResponse = await Subscriptions.setAsReadByRoomIdAndUserId(rid, uid, readThreads, alert); + if (setAsReadResponse.modifiedCount) { + void notifyOnSubscriptionChangedByRoomIdAndUserId(rid, uid); + } await NotificationQueue.clearQueueByUserId(uid); diff --git a/apps/meteor/server/lib/resetUserE2EKey.ts b/apps/meteor/server/lib/resetUserE2EKey.ts index 8535eee9a2cd..85da6e59cf60 100644 --- a/apps/meteor/server/lib/resetUserE2EKey.ts +++ b/apps/meteor/server/lib/resetUserE2EKey.ts @@ -2,7 +2,7 @@ import { api } from '@rocket.chat/core-services'; import { Subscriptions, Users, Rooms } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; -import { notifyOnUserChange } from '../../app/lib/server/lib/notifyListener'; +import { notifyOnUserChange, notifyOnSubscriptionChangedByUserId } from '../../app/lib/server/lib/notifyListener'; import * as Mailer from '../../app/mailer/server/api'; import { settings } from '../../app/settings/server'; import { i18n } from './i18n'; @@ -67,12 +67,13 @@ export async function resetUserE2EEncriptionKey(uid: string, notifyUser: boolean } // force logout the live sessions - await api.broadcast('user.forceLogout', uid); - await Users.resetE2EKey(uid); - await Subscriptions.resetUserE2EKey(uid); - await Rooms.removeUserFromE2EEQueue(uid); + const responses = await Promise.all([Users.resetE2EKey(uid), Subscriptions.resetUserE2EKey(uid), Rooms.removeUserFromE2EEQueue(uid)]); + + if (responses[1]?.modifiedCount) { + void notifyOnSubscriptionChangedByUserId(uid); + } // Force the user to logout, so that the keys can be generated again await Users.unsetLoginTokens(uid); diff --git a/apps/meteor/server/methods/addAllUserToRoom.ts b/apps/meteor/server/methods/addAllUserToRoom.ts index c07bdc48040a..6b1b690b4bfd 100644 --- a/apps/meteor/server/methods/addAllUserToRoom.ts +++ b/apps/meteor/server/methods/addAllUserToRoom.ts @@ -6,6 +6,7 @@ import { check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; import { hasPermissionAsync } from '../../app/authorization/server/functions/hasPermission'; +import { notifyOnSubscriptionChangedById } from '../../app/lib/server/lib/notifyListener'; import { settings } from '../../app/settings/server'; import { getDefaultSubscriptionPref } from '../../app/utils/lib/getDefaultSubscriptionPref'; import { callbacks } from '../../lib/callbacks'; @@ -58,7 +59,7 @@ Meteor.methods({ } await callbacks.run('beforeJoinRoom', user, room); const autoTranslateConfig = getSubscriptionAutotranslateDefaultConfig(user); - await Subscriptions.createWithRoomAndUser(room, user, { + const { insertedId } = await Subscriptions.createWithRoomAndUser(room, user, { ts: now, open: true, alert: true, @@ -68,6 +69,9 @@ Meteor.methods({ ...autoTranslateConfig, ...getDefaultSubscriptionPref(user), }); + if (insertedId) { + void notifyOnSubscriptionChangedById(insertedId, 'inserted'); + } await Message.saveSystemMessage('uj', rid, user.username || '', user, { ts: now }); await callbacks.run('afterJoinRoom', user, room); } diff --git a/apps/meteor/server/methods/addRoomLeader.ts b/apps/meteor/server/methods/addRoomLeader.ts index b8e09b44065a..64240bff65f0 100644 --- a/apps/meteor/server/methods/addRoomLeader.ts +++ b/apps/meteor/server/methods/addRoomLeader.ts @@ -6,6 +6,7 @@ import { check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; import { hasPermissionAsync } from '../../app/authorization/server/functions/hasPermission'; +import { notifyOnSubscriptionChangedById } from '../../app/lib/server/lib/notifyListener'; import { settings } from '../../app/settings/server'; declare module '@rocket.chat/ddp-client' { @@ -56,7 +57,10 @@ Meteor.methods({ }); } - await Subscriptions.addRoleById(subscription._id, 'leader'); + const addRoleResponse = await Subscriptions.addRoleById(subscription._id, 'leader'); + if (addRoleResponse.modifiedCount) { + void notifyOnSubscriptionChangedById(subscription._id); + } const fromUser = await Users.findOneById(uid); diff --git a/apps/meteor/server/methods/addRoomModerator.ts b/apps/meteor/server/methods/addRoomModerator.ts index ef64ced09423..da75038a3688 100644 --- a/apps/meteor/server/methods/addRoomModerator.ts +++ b/apps/meteor/server/methods/addRoomModerator.ts @@ -7,7 +7,9 @@ import { check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; import { hasPermissionAsync } from '../../app/authorization/server/functions/hasPermission'; +import { notifyOnSubscriptionChangedById } from '../../app/lib/server/lib/notifyListener'; import { settings } from '../../app/settings/server'; +import { isFederationEnabled, isFederationReady, FederationMatrixInvalidConfigurationError } from '../services/federation/utils'; declare module '@rocket.chat/ddp-client' { // eslint-disable-next-line @typescript-eslint/naming-convention @@ -36,12 +38,18 @@ Meteor.methods({ }); } - if (!(await hasPermissionAsync(uid, 'set-moderator', rid)) && !isRoomFederated(room)) { + const isFederated = isRoomFederated(room); + + if (!(await hasPermissionAsync(uid, 'set-moderator', rid)) && !isFederated) { throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'addRoomModerator', }); } + if (isFederated && (!isFederationEnabled() || !isFederationReady())) { + throw new FederationMatrixInvalidConfigurationError('unable to change room owners'); + } + const user = await Users.findOneById(userId); if (!user?.username) { @@ -64,7 +72,10 @@ Meteor.methods({ }); } - await Subscriptions.addRoleById(subscription._id, 'moderator'); + const addRoleResponse = await Subscriptions.addRoleById(subscription._id, 'moderator'); + if (addRoleResponse.modifiedCount) { + void notifyOnSubscriptionChangedById(subscription._id); + } const fromUser = await Users.findOneById(uid); if (!fromUser) { diff --git a/apps/meteor/server/methods/addRoomOwner.ts b/apps/meteor/server/methods/addRoomOwner.ts index f64e6699a4cb..d0a23efea024 100644 --- a/apps/meteor/server/methods/addRoomOwner.ts +++ b/apps/meteor/server/methods/addRoomOwner.ts @@ -7,7 +7,9 @@ import { check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; import { hasPermissionAsync } from '../../app/authorization/server/functions/hasPermission'; +import { notifyOnSubscriptionChangedById } from '../../app/lib/server/lib/notifyListener'; import { settings } from '../../app/settings/server'; +import { isFederationReady, isFederationEnabled, FederationMatrixInvalidConfigurationError } from '../services/federation/utils'; declare module '@rocket.chat/ddp-client' { // eslint-disable-next-line @typescript-eslint/naming-convention @@ -36,12 +38,18 @@ Meteor.methods({ }); } - if (!(await hasPermissionAsync(uid, 'set-owner', rid)) && !isRoomFederated(room)) { + const isFederated = isRoomFederated(room); + + if (!(await hasPermissionAsync(uid, 'set-owner', rid)) && !isFederated) { throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'addRoomOwner', }); } + if (isFederated && (!isFederationEnabled() || !isFederationReady())) { + throw new FederationMatrixInvalidConfigurationError('unable to change room owners'); + } + const user = await Users.findOneById(userId); if (!user?.username) { @@ -64,7 +72,10 @@ Meteor.methods({ }); } - await Subscriptions.addRoleById(subscription._id, 'owner'); + const addRoleResponse = await Subscriptions.addRoleById(subscription._id, 'owner'); + if (addRoleResponse.modifiedCount) { + void notifyOnSubscriptionChangedById(subscription._id); + } const fromUser = await Users.findOneById(uid); if (!fromUser) { diff --git a/apps/meteor/server/methods/hideRoom.ts b/apps/meteor/server/methods/hideRoom.ts index a53a328d549a..1fd4c6b4657e 100644 --- a/apps/meteor/server/methods/hideRoom.ts +++ b/apps/meteor/server/methods/hideRoom.ts @@ -3,6 +3,8 @@ import { Subscriptions } from '@rocket.chat/models'; import { check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; +import { notifyOnSubscriptionChangedByRoomIdAndUserId } from '../../app/lib/server/lib/notifyListener'; + declare module '@rocket.chat/ddp-client' { // eslint-disable-next-line @typescript-eslint/naming-convention interface ServerMethods { @@ -19,7 +21,13 @@ export const hideRoomMethod = async (userId: string, rid: string): Promise({ diff --git a/apps/meteor/server/methods/ignoreUser.ts b/apps/meteor/server/methods/ignoreUser.ts index 358fc3be3d8f..a8739a910b37 100644 --- a/apps/meteor/server/methods/ignoreUser.ts +++ b/apps/meteor/server/methods/ignoreUser.ts @@ -3,6 +3,8 @@ import { Subscriptions } from '@rocket.chat/models'; import { check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; +import { notifyOnSubscriptionChangedById } from '../../app/lib/server/lib/notifyListener'; + declare module '@rocket.chat/ddp-client' { // eslint-disable-next-line @typescript-eslint/naming-convention interface ServerMethods { @@ -23,7 +25,10 @@ Meteor.methods({ }); } - const subscription = await Subscriptions.findOneByRoomIdAndUserId(rid, userId); + const [subscription, subscriptionIgnoredUser] = await Promise.all([ + Subscriptions.findOneByRoomIdAndUserId(rid, userId), + Subscriptions.findOneByRoomIdAndUserId(rid, ignoredUser), + ]); if (!subscription) { throw new Meteor.Error('error-invalid-subscription', 'Invalid subscription', { @@ -31,14 +36,18 @@ Meteor.methods({ }); } - const subscriptionIgnoredUser = await Subscriptions.findOneByRoomIdAndUserId(rid, ignoredUser); - if (!subscriptionIgnoredUser) { throw new Meteor.Error('error-invalid-subscription', 'Invalid subscription', { method: 'ignoreUser', }); } - return !!(await Subscriptions.ignoreUser({ _id: subscription._id, ignoredUser, ignore })); + const result = await Subscriptions.ignoreUser({ _id: subscription._id, ignoredUser, ignore }); + + if (result.modifiedCount) { + void notifyOnSubscriptionChangedById(subscription._id); + } + + return !!result; }, }); diff --git a/apps/meteor/server/methods/openRoom.ts b/apps/meteor/server/methods/openRoom.ts index b2957768f237..440de52b87fb 100644 --- a/apps/meteor/server/methods/openRoom.ts +++ b/apps/meteor/server/methods/openRoom.ts @@ -4,6 +4,8 @@ import { Subscriptions } from '@rocket.chat/models'; import { check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; +import { notifyOnSubscriptionChangedByRoomIdAndUserId } from '../../app/lib/server/lib/notifyListener'; + declare module '@rocket.chat/ddp-client' { // eslint-disable-next-line @typescript-eslint/naming-convention interface ServerMethods { @@ -23,6 +25,12 @@ Meteor.methods({ }); } - return (await Subscriptions.openByRoomIdAndUserId(rid, uid)).modifiedCount; + const openByRoomResponse = await Subscriptions.openByRoomIdAndUserId(rid, uid); + + if (openByRoomResponse.modifiedCount) { + void notifyOnSubscriptionChangedByRoomIdAndUserId(rid, uid); + } + + return openByRoomResponse.modifiedCount; }, }); diff --git a/apps/meteor/server/methods/removeRoomLeader.ts b/apps/meteor/server/methods/removeRoomLeader.ts index 754d68960a4a..8a8f92d08fa0 100644 --- a/apps/meteor/server/methods/removeRoomLeader.ts +++ b/apps/meteor/server/methods/removeRoomLeader.ts @@ -6,6 +6,7 @@ import { check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; import { hasPermissionAsync } from '../../app/authorization/server/functions/hasPermission'; +import { notifyOnSubscriptionChangedById } from '../../app/lib/server/lib/notifyListener'; import { settings } from '../../app/settings/server'; declare module '@rocket.chat/ddp-client' { @@ -56,7 +57,10 @@ Meteor.methods({ }); } - await Subscriptions.removeRoleById(subscription._id, 'leader'); + const removeRoleResponse = await Subscriptions.removeRoleById(subscription._id, 'leader'); + if (removeRoleResponse.modifiedCount) { + void notifyOnSubscriptionChangedById(subscription._id); + } const fromUser = await Users.findOneById(uid); if (!fromUser) { diff --git a/apps/meteor/server/methods/removeRoomModerator.ts b/apps/meteor/server/methods/removeRoomModerator.ts index 291cc294a5fa..bcb50076c834 100644 --- a/apps/meteor/server/methods/removeRoomModerator.ts +++ b/apps/meteor/server/methods/removeRoomModerator.ts @@ -7,6 +7,7 @@ import { check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; import { hasPermissionAsync } from '../../app/authorization/server/functions/hasPermission'; +import { notifyOnSubscriptionChangedById } from '../../app/lib/server/lib/notifyListener'; import { settings } from '../../app/settings/server'; declare module '@rocket.chat/ddp-client' { @@ -64,7 +65,10 @@ Meteor.methods({ }); } - await Subscriptions.removeRoleById(subscription._id, 'moderator'); + const removeRoleResponse = await Subscriptions.removeRoleById(subscription._id, 'moderator'); + if (removeRoleResponse.modifiedCount) { + void notifyOnSubscriptionChangedById(subscription._id); + } const fromUser = await Users.findOneById(uid); if (!fromUser) { diff --git a/apps/meteor/server/methods/removeRoomOwner.ts b/apps/meteor/server/methods/removeRoomOwner.ts index 82ee2c37f9b8..91046655a4a6 100644 --- a/apps/meteor/server/methods/removeRoomOwner.ts +++ b/apps/meteor/server/methods/removeRoomOwner.ts @@ -7,6 +7,7 @@ import { Meteor } from 'meteor/meteor'; import { getUsersInRole } from '../../app/authorization/server'; import { hasPermissionAsync } from '../../app/authorization/server/functions/hasPermission'; +import { notifyOnSubscriptionChangedById } from '../../app/lib/server/lib/notifyListener'; import { settings } from '../../app/settings/server'; declare module '@rocket.chat/ddp-client' { @@ -71,7 +72,10 @@ Meteor.methods({ }); } - await Subscriptions.removeRoleById(subscription._id, 'owner'); + const removeRoleResponse = await Subscriptions.removeRoleById(subscription._id, 'owner'); + if (removeRoleResponse.modifiedCount) { + void notifyOnSubscriptionChangedById(subscription._id); + } const fromUser = await Users.findOneById(uid); if (!fromUser) { diff --git a/apps/meteor/server/methods/removeUserFromRoom.ts b/apps/meteor/server/methods/removeUserFromRoom.ts index 2f0e703a3b66..781ffe3a2671 100644 --- a/apps/meteor/server/methods/removeUserFromRoom.ts +++ b/apps/meteor/server/methods/removeUserFromRoom.ts @@ -1,6 +1,6 @@ import { Apps, AppEvents } from '@rocket.chat/apps'; import { AppsEngineException } from '@rocket.chat/apps-engine/definition/exceptions'; -import { Message, Team } from '@rocket.chat/core-services'; +import { Message, Team, Room } from '@rocket.chat/core-services'; import type { ServerMethods } from '@rocket.chat/ddp-client'; import { Subscriptions, Rooms, Users } from '@rocket.chat/models'; import { Match, check } from 'meteor/check'; @@ -9,7 +9,7 @@ import { Meteor } from 'meteor/meteor'; import { canAccessRoomAsync, getUsersInRole } from '../../app/authorization/server'; import { hasPermissionAsync } from '../../app/authorization/server/functions/hasPermission'; import { hasRoleAsync } from '../../app/authorization/server/functions/hasRole'; -import { notifyOnRoomChanged } from '../../app/lib/server/lib/notifyListener'; +import { notifyOnRoomChanged, notifyOnSubscriptionChanged } from '../../app/lib/server/lib/notifyListener'; import { settings } from '../../app/settings/server'; import { RoomMemberActions } from '../../definition/IRoomTypeConfig'; import { callbacks } from '../../lib/callbacks'; @@ -56,6 +56,8 @@ export const removeUserFromRoomMethod = async (fromId: string, data: { rid: stri const removedUser = await Users.findOneByUsernameIgnoringCase(data.username); + await Room.beforeUserRemoved(room); + if (!canKickAnyUser) { const subscription = await Subscriptions.findOneByRoomIdAndUserId(data.rid, removedUser._id, { projection: { _id: 1 }, @@ -89,7 +91,10 @@ export const removeUserFromRoomMethod = async (fromId: string, data: { rid: stri await callbacks.run('beforeRemoveFromRoom', { removedUser, userWhoRemoved: fromUser }, room); - await Subscriptions.removeByRoomIdAndUserId(data.rid, removedUser._id); + const deletedSubscription = await Subscriptions.removeByRoomIdAndUserId(data.rid, removedUser._id); + if (deletedSubscription) { + void notifyOnSubscriptionChanged(deletedSubscription, 'removed'); + } if (['c', 'p'].includes(room.t) === true) { await removeUserFromRolesAsync(removedUser._id, ['moderator', 'owner'], data.rid); diff --git a/apps/meteor/server/methods/saveUserPreferences.ts b/apps/meteor/server/methods/saveUserPreferences.ts index f19e653f7ccc..70e3a5cb9ea7 100644 --- a/apps/meteor/server/methods/saveUserPreferences.ts +++ b/apps/meteor/server/methods/saveUserPreferences.ts @@ -1,11 +1,16 @@ -import type { ThemePreference } from '@rocket.chat/core-typings'; +import type { ISubscription, ThemePreference } from '@rocket.chat/core-typings'; import type { ServerMethods } from '@rocket.chat/ddp-client'; import { Subscriptions, Users } from '@rocket.chat/models'; import type { FontSize } from '@rocket.chat/rest-typings'; import { Match, check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; -import { notifyOnUserChange } from '../../app/lib/server/lib/notifyListener'; +import { + notifyOnSubscriptionChangedByAutoTranslateAndUserId, + notifyOnSubscriptionChangedByUserId, + notifyOnSubscriptionChangedByUserPreferences, + notifyOnUserChange, +} from '../../app/lib/server/lib/notifyListener'; import { settings as rcSettings } from '../../app/settings/server'; type UserPreferences = { @@ -54,6 +59,31 @@ declare module '@rocket.chat/ddp-client' { } } +async function updateNotificationPreferences( + userId: ISubscription['u']['_id'], + setting: keyof ISubscription, + newValue: string, + oldValue: string, + preferenceType: keyof ISubscription, +) { + if (newValue === oldValue) { + return; + } + + if (newValue === 'default') { + const clearNotificationResponse = await Subscriptions.clearNotificationUserPreferences(userId, setting, preferenceType); + if (clearNotificationResponse.modifiedCount) { + void notifyOnSubscriptionChangedByUserPreferences(userId, preferenceType, 'user'); + } + return; + } + + const updateNotificationResponse = await Subscriptions.updateNotificationUserPreferences(userId, newValue, setting, preferenceType); + if (updateNotificationResponse.modifiedCount) { + void notifyOnSubscriptionChangedByUserPreferences(userId, preferenceType, 'subscription'); + } +} + export const saveUserPreferences = async (settings: Partial, userId: string): Promise => { const keys = { language: Match.Optional(String), @@ -146,51 +176,41 @@ export const saveUserPreferences = async (settings: Partial, us // propagate changed notification preferences setImmediate(async () => { - if (settings.desktopNotifications && oldDesktopNotifications !== settings.desktopNotifications) { - if (settings.desktopNotifications === 'default') { - await Subscriptions.clearNotificationUserPreferences(user._id, 'desktopNotifications', 'desktopPrefOrigin'); - } else { - await Subscriptions.updateNotificationUserPreferences( - user._id, - settings.desktopNotifications, - 'desktopNotifications', - 'desktopPrefOrigin', - ); - } + const { desktopNotifications, pushNotifications, emailNotificationMode, highlights, language } = settings; + const promises = []; + + if (desktopNotifications) { + promises.push( + updateNotificationPreferences(user._id, 'desktopNotifications', desktopNotifications, oldDesktopNotifications, 'desktopPrefOrigin'), + ); } - if (settings.pushNotifications && oldMobileNotifications !== settings.pushNotifications) { - if (settings.pushNotifications === 'default') { - await Subscriptions.clearNotificationUserPreferences(user._id, 'mobilePushNotifications', 'mobilePrefOrigin'); - } else { - await Subscriptions.updateNotificationUserPreferences( - user._id, - settings.pushNotifications, - 'mobilePushNotifications', - 'mobilePrefOrigin', - ); - } + if (pushNotifications) { + promises.push( + updateNotificationPreferences(user._id, 'mobilePushNotifications', pushNotifications, oldMobileNotifications, 'mobilePrefOrigin'), + ); } - if (settings.emailNotificationMode && oldEmailNotifications !== settings.emailNotificationMode) { - if (settings.emailNotificationMode === 'default') { - await Subscriptions.clearNotificationUserPreferences(user._id, 'emailNotifications', 'emailPrefOrigin'); - } else { - await Subscriptions.updateNotificationUserPreferences( - user._id, - settings.emailNotificationMode, - 'emailNotifications', - 'emailPrefOrigin', - ); - } + if (emailNotificationMode) { + promises.push( + updateNotificationPreferences(user._id, 'emailNotifications', emailNotificationMode, oldEmailNotifications, 'emailPrefOrigin'), + ); } - if (Array.isArray(settings.highlights)) { - await Subscriptions.updateUserHighlights(user._id, settings.highlights); + await Promise.allSettled(promises); + + if (Array.isArray(highlights)) { + const response = await Subscriptions.updateUserHighlights(user._id, highlights); + if (response.modifiedCount) { + void notifyOnSubscriptionChangedByUserId(user._id); + } } - if (settings.language && oldLanguage !== settings.language && rcSettings.get('AutoTranslate_AutoEnableOnJoinRoom')) { - await Subscriptions.updateAllAutoTranslateLanguagesByUserId(user._id, settings.language); + if (language && oldLanguage !== language && rcSettings.get('AutoTranslate_AutoEnableOnJoinRoom')) { + const response = await Subscriptions.updateAllAutoTranslateLanguagesByUserId(user._id, language); + if (response.modifiedCount) { + void notifyOnSubscriptionChangedByAutoTranslateAndUserId(user._id); + } } }); }; diff --git a/apps/meteor/server/methods/toggleFavorite.ts b/apps/meteor/server/methods/toggleFavorite.ts index 36555a4566db..912b9a8f3e5c 100644 --- a/apps/meteor/server/methods/toggleFavorite.ts +++ b/apps/meteor/server/methods/toggleFavorite.ts @@ -4,6 +4,8 @@ import { Subscriptions } from '@rocket.chat/models'; import { Match, check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; +import { notifyOnSubscriptionChangedByRoomIdAndUserId } from '../../app/lib/server/lib/notifyListener'; + declare module '@rocket.chat/ddp-client' { // eslint-disable-next-line @typescript-eslint/naming-convention interface ServerMethods { @@ -28,6 +30,12 @@ Meteor.methods({ throw new Meteor.Error('error-invalid-subscription', 'You must be part of a room to favorite it', { method: 'toggleFavorite' }); } - return (await Subscriptions.setFavoriteByRoomIdAndUserId(rid, userId, f)).modifiedCount; + const { modifiedCount } = await Subscriptions.setFavoriteByRoomIdAndUserId(rid, userId, f); + + if (modifiedCount) { + void notifyOnSubscriptionChangedByRoomIdAndUserId(rid, userId); + } + + return modifiedCount; }, }); diff --git a/apps/meteor/server/models/LivechatContacts.ts b/apps/meteor/server/models/LivechatContacts.ts new file mode 100644 index 000000000000..d341ae87b021 --- /dev/null +++ b/apps/meteor/server/models/LivechatContacts.ts @@ -0,0 +1,6 @@ +import { registerModel } from '@rocket.chat/models'; + +import { db } from '../database/utils'; +import { LivechatContactsRaw } from './raw/LivechatContacts'; + +registerModel('ILivechatContactsModel', new LivechatContactsRaw(db)); diff --git a/apps/meteor/server/models/dummy/BaseDummy.ts b/apps/meteor/server/models/dummy/BaseDummy.ts index c3052ede9487..049295c1a28a 100644 --- a/apps/meteor/server/models/dummy/BaseDummy.ts +++ b/apps/meteor/server/models/dummy/BaseDummy.ts @@ -53,6 +53,13 @@ export class BaseDummy< return this.collectionName; } + async findOneAndDelete(): Promise> { + return { + value: null, + ok: 1, + }; + } + async findOneAndUpdate(): Promise> { return { value: null, diff --git a/apps/meteor/server/models/raw/BaseRaw.ts b/apps/meteor/server/models/raw/BaseRaw.ts index 1a3dd1a3eb4c..d822038b177e 100644 --- a/apps/meteor/server/models/raw/BaseRaw.ts +++ b/apps/meteor/server/models/raw/BaseRaw.ts @@ -26,6 +26,7 @@ import type { InsertOneResult, DeleteResult, DeleteOptions, + FindOneAndDeleteOptions, } from 'mongodb'; import { setUpdatedAt } from './setUpdatedAt'; @@ -315,7 +316,38 @@ export abstract class BaseRaw< return this.col.deleteOne(filter); } - async deleteMany(filter: Filter, options?: DeleteOptions): Promise { + async findOneAndDelete(filter: Filter, options?: FindOneAndDeleteOptions): Promise> { + if (!this.trash) { + if (options) { + return this.col.findOneAndDelete(filter, options); + } + return this.col.findOneAndDelete(filter); + } + + const result = await this.col.findOneAndDelete(filter); + + const { value: doc } = result; + if (!doc) { + return result; + } + + const { _id, ...record } = doc; + + const trash: TDeleted = { + ...record, + _deletedAt: new Date(), + __collection__: this.name, + } as unknown as TDeleted; + + // since the operation is not atomic, we need to make sure that the record is not already deleted/inserted + await this.trash?.updateOne({ _id } as Filter, { $set: trash } as UpdateFilter, { + upsert: true, + }); + + return result; + } + + async deleteMany(filter: Filter, options?: DeleteOptions & { onTrash?: (record: ResultFields) => void }): Promise { if (!this.trash) { if (options) { return this.col.deleteMany(filter, options); @@ -323,7 +355,7 @@ export abstract class BaseRaw< return this.col.deleteMany(filter); } - const cursor = this.find(filter); + const cursor = this.find>(filter, { session: options?.session }); const ids: T['_id'][] = []; for await (const doc of cursor) { @@ -340,7 +372,10 @@ export abstract class BaseRaw< // since the operation is not atomic, we need to make sure that the record is not already deleted/inserted await this.trash?.updateOne({ _id } as Filter, { $set: trash } as UpdateFilter, { upsert: true, + session: options?.session, }); + + void options?.onTrash?.(doc); } if (options) { diff --git a/apps/meteor/server/models/raw/LivechatContacts.ts b/apps/meteor/server/models/raw/LivechatContacts.ts new file mode 100644 index 000000000000..1f5f29a3cc78 --- /dev/null +++ b/apps/meteor/server/models/raw/LivechatContacts.ts @@ -0,0 +1,11 @@ +import type { ILivechatContact, RocketChatRecordDeleted } from '@rocket.chat/core-typings'; +import type { ILivechatContactsModel } from '@rocket.chat/model-typings'; +import type { Collection, Db } from 'mongodb'; + +import { BaseRaw } from './BaseRaw'; + +export class LivechatContactsRaw extends BaseRaw implements ILivechatContactsModel { + constructor(db: Db, trash?: Collection>) { + super(db, 'livechat_contact', trash); + } +} diff --git a/apps/meteor/server/models/raw/LivechatInquiry.ts b/apps/meteor/server/models/raw/LivechatInquiry.ts index 52aedd0585f8..6746d6f87926 100644 --- a/apps/meteor/server/models/raw/LivechatInquiry.ts +++ b/apps/meteor/server/models/raw/LivechatInquiry.ts @@ -20,6 +20,7 @@ import type { IndexDescription, FindCursor, UpdateFilter, + DeleteOptions, } from 'mongodb'; import { getOmniChatSortQuery } from '../../../app/livechat/lib/inquiries'; @@ -274,8 +275,8 @@ export class LivechatInquiryRaw extends BaseRaw implemen throw new Error('Method not implemented on the community edition.'); } - async removeByRoomId(rid: string): Promise { - return this.deleteOne({ rid }); + async removeByRoomId(rid: string, options?: DeleteOptions): Promise { + return this.deleteOne({ rid }, options); } getQueuedInquiries(options?: FindOptions): FindCursor { diff --git a/apps/meteor/server/models/raw/LivechatRooms.ts b/apps/meteor/server/models/raw/LivechatRooms.ts index 731cbcebf593..b01d7e62c5ff 100644 --- a/apps/meteor/server/models/raw/LivechatRooms.ts +++ b/apps/meteor/server/models/raw/LivechatRooms.ts @@ -26,6 +26,7 @@ import type { FindCursor, UpdateResult, AggregationCursor, + UpdateOptions, } from 'mongodb'; import { getValue } from '../../../app/settings/server/raw'; @@ -1589,7 +1590,7 @@ export class LivechatRoomsRaw extends BaseRaw implements ILive ); } - closeRoomById(roomId: string, closeInfo: IOmnichannelRoomClosingInfo) { + closeRoomById(roomId: string, closeInfo: IOmnichannelRoomClosingInfo, options?: UpdateOptions) { const { closer, closedBy, closedAt, chatDuration, serviceTimeDuration, tags } = closeInfo; return this.updateOne( @@ -1611,6 +1612,7 @@ export class LivechatRoomsRaw extends BaseRaw implements ILive open: 1, }, }, + options, ); } @@ -2044,12 +2046,12 @@ export class LivechatRoomsRaw extends BaseRaw implements ILive return updater; } - getTotalConversationsBetweenDate(t: 'l', date: { gte: Date; lt: Date }, { departmentId }: { departmentId?: string } = {}) { + getTotalConversationsBetweenDate(t: 'l', date: { gte: Date; lte: Date }, { departmentId }: { departmentId?: string } = {}) { const query: Filter = { t, ts: { $gte: new Date(date.gte), // ISO Date, ts >= date.gte - $lt: new Date(date.lt), // ISODate, ts < date.lt + $lte: new Date(date.lte), // ISODate, ts <= date.lte }, ...(departmentId && departmentId !== 'undefined' && { departmentId }), }; @@ -2059,7 +2061,7 @@ export class LivechatRoomsRaw extends BaseRaw implements ILive getAnalyticsMetricsBetweenDate( t: 'l', - date: { gte: Date; lt: Date }, + date: { gte: Date; lte: Date }, { departmentId }: { departmentId?: string } = {}, extraQuery: Document = {}, ) { @@ -2067,7 +2069,7 @@ export class LivechatRoomsRaw extends BaseRaw implements ILive t, ts: { $gte: new Date(date.gte), // ISO Date, ts >= date.gte - $lt: new Date(date.lt), // ISODate, ts < date.lt + $lte: new Date(date.lte), // ISODate, ts <= date.lte }, ...(departmentId && departmentId !== 'undefined' && { departmentId }), ...extraQuery, @@ -2080,7 +2082,7 @@ export class LivechatRoomsRaw extends BaseRaw implements ILive getAnalyticsMetricsBetweenDateWithMessages( t: string, - date: { gte: Date; lt: Date }, + date: { gte: Date; lte: Date }, { departmentId }: { departmentId?: string } = {}, extraQuery: Document = {}, extraMatchers: Document = {}, @@ -2092,7 +2094,7 @@ export class LivechatRoomsRaw extends BaseRaw implements ILive t, ts: { $gte: new Date(date.gte), // ISO Date, ts >= date.gte - $lt: new Date(date.lt), // ISODate, ts < date.lt + $lte: new Date(date.lte), // ISODate, ts <= date.lte }, ...(departmentId && departmentId !== 'undefined' && { departmentId }), ...extraMatchers, @@ -2162,7 +2164,7 @@ export class LivechatRoomsRaw extends BaseRaw implements ILive ); } - getAnalyticsBetweenDate(date: { gte: Date; lt: Date }, { departmentId }: { departmentId?: string } = {}) { + getAnalyticsBetweenDate(date: { gte: Date; lte: Date }, { departmentId }: { departmentId?: string } = {}) { return this.col.aggregate>( [ { @@ -2170,7 +2172,7 @@ export class LivechatRoomsRaw extends BaseRaw implements ILive t: 'l', ts: { $gte: new Date(date.gte), // ISO Date, ts >= date.gte - $lt: new Date(date.lt), // ISODate, ts < date.lt + $lte: new Date(date.lte), // ISODate, ts <= date.lte }, ...(departmentId && departmentId !== 'undefined' && { departmentId }), }, diff --git a/apps/meteor/server/models/raw/LivechatVisitors.ts b/apps/meteor/server/models/raw/LivechatVisitors.ts index 502992ea75f8..396b728159ff 100644 --- a/apps/meteor/server/models/raw/LivechatVisitors.ts +++ b/apps/meteor/server/models/raw/LivechatVisitors.ts @@ -459,6 +459,17 @@ export class LivechatVisitorsRaw extends BaseRaw implements IL activity: period, }); } + + setLastChatById(_id: string, lastChat: Required): Promise { + return this.updateOne( + { _id }, + { + $set: { + lastChat, + }, + }, + ); + } } type DeepWriteable = { -readonly [P in keyof T]: DeepWriteable }; diff --git a/apps/meteor/server/models/raw/OAuthApps.ts b/apps/meteor/server/models/raw/OAuthApps.ts index dc9ce4f95159..c8650a407f6e 100644 --- a/apps/meteor/server/models/raw/OAuthApps.ts +++ b/apps/meteor/server/models/raw/OAuthApps.ts @@ -13,12 +13,18 @@ export class OAuthAppsRaw extends BaseRaw implements IOAuthAppsModel return [{ key: { clientId: 1, clientSecret: 1 } }, { key: { appId: 1 } }]; } - findOneAuthAppByIdOrClientId(props: { clientId: string } | { appId: string } | { _id: string }): Promise { - return this.findOne({ - ...('_id' in props && { _id: props._id }), - ...('appId' in props && { _id: props.appId }), - ...('clientId' in props && { clientId: props.clientId }), - }); + findOneAuthAppByIdOrClientId( + props: { clientId: string } | { appId: string } | { _id: string }, + options?: FindOptions, + ): Promise { + return this.findOne( + { + ...('_id' in props && { _id: props._id }), + ...('appId' in props && { _id: props.appId }), + ...('clientId' in props && { clientId: props.clientId }), + }, + options, + ); } findOneActiveByClientId(clientId: string, options?: FindOptions): Promise { diff --git a/apps/meteor/server/models/raw/Roles.ts b/apps/meteor/server/models/raw/Roles.ts index 84a5b088ea30..4e1cb09348c4 100644 --- a/apps/meteor/server/models/raw/Roles.ts +++ b/apps/meteor/server/models/raw/Roles.ts @@ -3,6 +3,7 @@ import type { IRolesModel } from '@rocket.chat/model-typings'; import { Subscriptions, Users } from '@rocket.chat/models'; import type { Collection, FindCursor, Db, Filter, FindOptions, Document } from 'mongodb'; +import { notifyOnSubscriptionChangedByRoomIdAndUserId } from '../../../app/lib/server/lib/notifyListener'; import { BaseRaw } from './BaseRaw'; export class RolesRaw extends BaseRaw implements IRolesModel { @@ -35,14 +36,15 @@ export class RolesRaw extends BaseRaw implements IRolesModel { process.env.NODE_ENV === 'development' && console.warn(`[WARN] RolesRaw.addUserRoles: role: ${roleId} not found`); continue; } - switch (role.scope) { - case 'Subscriptions': - // TODO remove dependency from other models - this logic should be inside a function/service - await Subscriptions.addRolesByUserId(userId, [role._id], scope); - break; - case 'Users': - default: - await Users.addRolesByUserId(userId, [role._id]); + + if (role.scope === 'Subscriptions' && scope) { + // TODO remove dependency from other models - this logic should be inside a function/service + const addRolesResponse = await Subscriptions.addRolesByUserId(userId, [role._id], scope); + if (addRolesResponse.modifiedCount) { + void notifyOnSubscriptionChangedByRoomIdAndUserId(scope, userId); + } + } else { + await Users.addRolesByUserId(userId, [role._id]); } } return true; @@ -88,13 +90,13 @@ export class RolesRaw extends BaseRaw implements IRolesModel { continue; } - switch (role.scope) { - case 'Subscriptions': - scope && (await Subscriptions.removeRolesByUserId(userId, [roleId], scope)); - break; - case 'Users': - default: - await Users.removeRolesByUserId(userId, [roleId]); + if (role.scope === 'Subscriptions' && scope) { + const removeRolesResponse = await Subscriptions.removeRolesByUserId(userId, [roleId], scope); + if (removeRolesResponse.modifiedCount) { + void notifyOnSubscriptionChangedByRoomIdAndUserId(scope, userId); + } + } else { + await Users.removeRolesByUserId(userId, [roleId]); } } return true; diff --git a/apps/meteor/server/models/raw/Rooms.ts b/apps/meteor/server/models/raw/Rooms.ts index bb496bf79971..96cd5a3a3acf 100644 --- a/apps/meteor/server/models/raw/Rooms.ts +++ b/apps/meteor/server/models/raw/Rooms.ts @@ -598,7 +598,7 @@ export class RoomsRaw extends BaseRaw implements IRoomsModel { }); } - incUsersCountByIds(ids: Array, inc = 1): Promise { + incUsersCountByIds(ids: Array, inc = 1, options?: UpdateOptions): Promise { const query: Filter = { _id: { $in: ids, @@ -611,7 +611,7 @@ export class RoomsRaw extends BaseRaw implements IRoomsModel { }, }; - return this.updateMany(query, update); + return this.updateMany(query, update, options); } allRoomSourcesCount(): AggregationCursor<{ _id: Required; count: number }> { @@ -642,15 +642,6 @@ export class RoomsRaw extends BaseRaw implements IRoomsModel { ); } - findByActiveLivestream(options: FindOptions = {}): FindCursor { - return this.find( - { - 'streamingOptions.type': 'livestream', - }, - options, - ); - } - setAsFederated(roomId: IRoom['_id']): Promise { return this.updateOne({ _id: roomId }, { $set: { federated: true } }); } @@ -1030,15 +1021,6 @@ export class RoomsRaw extends BaseRaw implements IRoomsModel { return this.updateOne(query, update); } - setStreamingOptionsById(_id: IRoom['_id'], streamingOptions: IRoom['streamingOptions']): Promise { - const update: UpdateFilter = { - $set: { - streamingOptions, - }, - }; - return this.updateOne({ _id }, update); - } - setReadOnlyById(_id: IRoom['_id'], readOnly: NonNullable): Promise { const query: Filter = { _id, diff --git a/apps/meteor/server/models/raw/Subscriptions.ts b/apps/meteor/server/models/raw/Subscriptions.ts index 01440a179c7f..fdefd36bd44b 100644 --- a/apps/meteor/server/models/raw/Subscriptions.ts +++ b/apps/meteor/server/models/raw/Subscriptions.ts @@ -28,6 +28,8 @@ import type { InsertOneResult, InsertManyResult, AggregationCursor, + CountDocumentsOptions, + DeleteOptions, } from 'mongodb'; import { getDefaultSubscriptionPref } from '../../../app/utils/lib/getDefaultSubscriptionPref'; @@ -191,7 +193,7 @@ export class SubscriptionsRaw extends BaseRaw implements ISubscri readThreads = false, alert = false, options: FindOptions = {}, - ): ReturnType['update']> { + ): ReturnType['updateOne']> { const query: Filter = { rid, 'u._id': uid, @@ -327,20 +329,65 @@ export class SubscriptionsRaw extends BaseRaw implements ISubscri return this.find(query, options || {}); } - async removeByRoomId(roomId: string): Promise { + findByRoomIdAndNotAlertOrOpenExcludingUserIds( + { + roomId, + uidsExclude, + uidsInclude, + onlyRead, + }: { + roomId: ISubscription['rid']; + uidsExclude?: ISubscription['u']['_id'][]; + uidsInclude?: ISubscription['u']['_id'][]; + onlyRead: boolean; + }, + options?: FindOptions, + ) { const query = { rid: roomId, + ...(uidsExclude?.length && { + 'u._id': { $nin: uidsExclude }, + }), + ...(onlyRead && { + $or: [...(uidsInclude?.length ? [{ 'u._id': { $in: uidsInclude } }] : []), { alert: { $ne: true } }, { open: { $ne: true } }], + }), }; - const result = (await this.deleteMany(query)).deletedCount; + return this.find(query, options || {}); + } - if (typeof result === 'number' && result > 0) { - await Rooms.incUsersCountByIds([roomId], -result); + async removeByRoomId( + roomId: ISubscription['rid'], + options?: DeleteOptions & { onTrash: (doc: ISubscription) => void }, + ): Promise { + const query = { + rid: roomId, + }; + + const deleteResult = await this.deleteMany(query, options); + + if (deleteResult?.deletedCount) { + await Rooms.incUsersCountByIds([roomId], -deleteResult.deletedCount, { session: options?.session }); } - await Users.removeRoomByRoomId(roomId); + await Users.removeRoomByRoomId(roomId, { session: options?.session }); - return result; + return deleteResult; + } + + findByRoomIdExcludingUserIds( + roomId: ISubscription['rid'], + userIds: ISubscription['u']['_id'][], + options: FindOptions = {}, + ): FindCursor { + const query = { + 'rid': roomId, + 'u._id': { + $nin: userIds, + }, + }; + + return this.find(query, options); } async findConnectedUsersExcept( @@ -532,11 +579,10 @@ export class SubscriptionsRaw extends BaseRaw implements ISubscri return this.updateMany(query, update); } - async setGroupE2EKey(_id: string, key: string): Promise { + async setGroupE2EKey(_id: string, key: string): Promise { const query = { _id }; const update = { $set: { E2EKey: key } }; - await this.updateOne(query, update); - return this.findOneById(_id); + return this.updateOne(query, update); } setGroupE2ESuggestedKey(uid: string, rid: string, key: string): Promise { @@ -558,18 +604,12 @@ export class SubscriptionsRaw extends BaseRaw implements ISubscri return this.updateOne({ rid }, { $unset: { onHold: 1 } }); } - findByRoomIds(roomIds: string[]): FindCursor { + findByRoomIds(roomIds: ISubscription['u']['_id'][], options?: FindOptions): FindCursor { const query = { rid: { $in: roomIds, }, }; - const options = { - projection: { - 'u._id': 1, - 'rid': 1, - }, - }; return this.find(query, options); } @@ -582,6 +622,14 @@ export class SubscriptionsRaw extends BaseRaw implements ISubscri return this.deleteMany(query); } + findByToken(token: string, options?: FindOptions): FindCursor { + const query = { + 'v.token': token, + }; + + return this.find(query, options); + } + updateAutoTranslateById(_id: string, autoTranslate: boolean): Promise { const query = { _id, @@ -620,6 +668,19 @@ export class SubscriptionsRaw extends BaseRaw implements ISubscri return this.updateMany(query, update); } + findByAutoTranslateAndUserId( + userId: ISubscription['u']['_id'], + autoTranslate: ISubscription['autoTranslate'] = true, + options?: FindOptions, + ): FindCursor { + const query = { + 'u._id': userId, + autoTranslate, + }; + + return this.find(query, options); + } + disableAutoTranslateByRoomId(roomId: IRoom['_id']): Promise { const query = { rid: roomId, @@ -1049,11 +1110,15 @@ export class SubscriptionsRaw extends BaseRaw implements ISubscri return this.col.countDocuments(query); } - countByRoomId(roomId: string): Promise { + countByRoomId(roomId: string, options?: CountDocumentsOptions): Promise { const query = { rid: roomId, }; + if (options) { + return this.col.countDocuments(query, options); + } + return this.col.countDocuments(query); } @@ -1092,7 +1157,11 @@ export class SubscriptionsRaw extends BaseRaw implements ISubscri return subscription?.ls; } - findByRoomIdAndUserIds(roomId: string, userIds: string[], options?: FindOptions): FindCursor { + findByRoomIdAndUserIds( + roomId: ISubscription['rid'], + userIds: ISubscription['u']['_id'][], + options?: FindOptions, + ): FindCursor { const query = { 'rid': roomId, 'u._id': { @@ -1230,6 +1299,33 @@ export class SubscriptionsRaw extends BaseRaw implements ISubscri return this.updateMany(query, update); } + findByUserIdAndRoomType( + userId: ISubscription['u']['_id'], + type: ISubscription['t'], + options?: FindOptions, + ): FindCursor { + const query = { + 'u._id': userId, + 't': type, + }; + + return this.find(query, options); + } + + findByNameAndRoomType( + filter: Partial>, + options?: FindOptions, + ): FindCursor { + if (!filter.name && !filter.t) { + throw new Error('invalid filter'); + } + const query: Filter = { + ...(filter.name && { name: filter.name }), + ...(filter.t && { t: filter.t }), + }; + return this.find(query, options); + } + setFavoriteByRoomIdAndUserId(roomId: string, userId: string, favorite?: boolean): Promise { if (favorite == null) { favorite = true; @@ -1411,7 +1507,7 @@ export class SubscriptionsRaw extends BaseRaw implements ISubscri return this.updateOne(query, update); } - setAlertForRoomIdAndUserIds(roomId: string, uids: string[]): Promise { + setAlertForRoomIdAndUserIds(roomId: ISubscription['rid'], uids: ISubscription['u']['_id'][]): Promise { const query = { 'rid': roomId, 'u._id': { $in: uids }, @@ -1423,6 +1519,7 @@ export class SubscriptionsRaw extends BaseRaw implements ISubscri alert: true, }, }; + return this.updateMany(query, update); } @@ -1621,6 +1718,22 @@ export class SubscriptionsRaw extends BaseRaw implements ISubscri return this.updateMany(query, update); } + findByUserPreferences( + userId: string, + notificationOriginField: keyof ISubscription, + notificationOriginValue: 'user' | 'subscription', + options?: FindOptions, + ): FindCursor { + const value = notificationOriginValue === 'user' ? 'user' : { $ne: 'subscription' }; + + const query: Filter = { + 'u._id': userId, + [notificationOriginField]: value, + }; + + return this.find(query, options); + } + updateUserHighlights(userId: string, userHighlights: any): Promise { const query: Filter = { 'u._id': userId, @@ -1722,9 +1835,7 @@ export class SubscriptionsRaw extends BaseRaw implements ISubscri })); // @ts-expect-error - types not good :( - const result = await this.insertMany(subscriptions); - - return result; + return this.insertMany(subscriptions); } // REMOVE @@ -1746,25 +1857,25 @@ export class SubscriptionsRaw extends BaseRaw implements ISubscri return result; } - async removeByRoomIdAndUserId(roomId: string, userId: string): Promise { + async removeByRoomIdAndUserId(roomId: string, userId: string): Promise { const query = { 'rid': roomId, 'u._id': userId, }; - const result = (await this.deleteMany(query)).deletedCount; + const { value: doc } = await this.findOneAndDelete(query); - if (typeof result === 'number' && result > 0) { - await Rooms.incUsersCountById(roomId, -result); + if (doc) { + await Rooms.incUsersCountById(roomId, -1); } await Users.removeRoomByUserId(userId, roomId); - return result; + return doc; } - async removeByRoomIds(rids: string[]): Promise { - const result = await this.deleteMany({ rid: { $in: rids } }); + async removeByRoomIds(rids: string[], options?: { onTrash: (doc: ISubscription) => void }): Promise { + const result = await this.deleteMany({ rid: { $in: rids } }, options); await Users.removeRoomByRoomIds(rids); @@ -1850,6 +1961,19 @@ export class SubscriptionsRaw extends BaseRaw implements ISubscri return this.updateMany(query, update); } + findUnreadThreadsByRoomId( + rid: ISubscription['rid'], + tunread: ISubscription['tunread'], + options?: FindOptions, + ): FindCursor { + const query = { + rid, + tunread: { $in: tunread }, + }; + + return this.find(query, options); + } + openByRoomIdAndUserId(roomId: string, userId: string): Promise { const query = { 'rid': roomId, diff --git a/apps/meteor/server/models/raw/Users.js b/apps/meteor/server/models/raw/Users.js index f5897c635727..0b78f0f9e454 100644 --- a/apps/meteor/server/models/raw/Users.js +++ b/apps/meteor/server/models/raw/Users.js @@ -1339,7 +1339,7 @@ export class UsersRaw extends BaseRaw { return this.updateOne(query, update); } - removeRoomByRoomId(rid) { + removeRoomByRoomId(rid, options) { return this.updateMany( { __rooms: rid, @@ -1347,6 +1347,7 @@ export class UsersRaw extends BaseRaw { { $pull: { __rooms: rid }, }, + options, ); } diff --git a/apps/meteor/server/models/startup.ts b/apps/meteor/server/models/startup.ts index 3d6dc6066689..eaca155674f5 100644 --- a/apps/meteor/server/models/startup.ts +++ b/apps/meteor/server/models/startup.ts @@ -22,6 +22,7 @@ import './Integrations'; import './Invites'; import './LivechatAgentActivity'; import './LivechatBusinessHours'; +import './LivechatContacts'; import './LivechatCustomField'; import './LivechatDepartment'; import './LivechatDepartmentAgents'; diff --git a/apps/meteor/server/services/federation/domain/IFederationBridge.ts b/apps/meteor/server/services/federation/domain/IFederationBridge.ts index 635202cdd6f4..1076888f511d 100644 --- a/apps/meteor/server/services/federation/domain/IFederationBridge.ts +++ b/apps/meteor/server/services/federation/domain/IFederationBridge.ts @@ -110,5 +110,6 @@ export interface IFederationBridge { externalUserId: string, externalRoomId: string, ): Promise<{ creator: { id: string; username: string }; name: string; joinedMembers: string[] } | undefined>; + ping(): Promise<{ durationMs: number }>; deactivateUser(externalUserId: string): Promise; } diff --git a/apps/meteor/server/services/federation/infrastructure/matrix/Bridge.ts b/apps/meteor/server/services/federation/infrastructure/matrix/Bridge.ts index f5eb049a7496..31c101bbfdac 100644 --- a/apps/meteor/server/services/federation/infrastructure/matrix/Bridge.ts +++ b/apps/meteor/server/services/federation/infrastructure/matrix/Bridge.ts @@ -24,6 +24,8 @@ let MatrixUserInstance: any; const DEFAULT_TIMEOUT_IN_MS_FOR_JOINING_ROOMS = 180000; +const DEFAULT_TIMEOUT_IN_MS_FOR_PING_EVENT = 60 * 1000; + export class MatrixBridge implements IFederationBridge { protected bridgeInstance: Bridge; @@ -44,6 +46,32 @@ export class MatrixBridge implements IFederationBridge { if (!this.isRunning) { await this.bridgeInstance.run(this.internalSettings.getBridgePort()); + + this.bridgeInstance.addAppServicePath({ + method: 'POST', + path: '/_matrix/app/v1/ping', + checkToken: true, + handler: (_req, res, _next) => { + /* + * https://spec.matrix.org/v1.11/application-service-api/#post_matrixappv1ping + * Spec does not talk about what to do with the id. It is safe to ignore it as we are already checking for + * homeserver token to be correct. + * From the spec this might be a bit confusing, as it shows a txn id for post, but app service doing nothing with it afterwards + * when receiving from the homeserver. + * From spec directly - + AS ---> HS : /_matrix/client/v1/appservice/{appserviceId}/ping {"transaction_id": "meow"} + HS ---> AS : /_matrix/app/v1/ping {"transaction_id": "meow"} + HS <--- AS : 200 OK {} + AS <--- HS : 200 OK {"duration_ms": 123} + * https://github.com/matrix-org/matrix-spec/blob/e53e6ea8764b95f0bdb738549fca6f9f3f901298/content/application-service-api.md?plain=1#L229-L232 + * Code - wise, also doesn't care what happens with the response. + * https://github.com/element-hq/synapse/blob/cb6f4a84a6a8f2b79b80851f37eb5fa4c7c5264a/synapse/rest/client/appservice_ping.py#L80 - nothing done on return + * https://github.com/element-hq/synapse/blob/cb6f4a84a6a8f2b79b80851f37eb5fa4c7c5264a/synapse/appservice/api.py#L321-L332 - not even returning the response, caring for just the http status code - https://github.com/element-hq/synapse/blob/cb6f4a84a6a8f2b79b80851f37eb5fa4c7c5264a/synapse/http/client.py#L532-L537 + */ + res.status(200).json({}); + }, + }); + this.isRunning = true; } } catch (err) { @@ -657,6 +685,10 @@ export class MatrixBridge implements IFederationBridge { return MatrixEnumSendMessageType.FILE; } + private getMyHomeServerOrigin() { + return new URL(`https://${this.internalSettings.getHomeServerDomain()}`).hostname; + } + public async uploadContent( externalSenderId: string, content: Buffer, @@ -724,6 +756,16 @@ export class MatrixBridge implements IFederationBridge { controller: { onEvent: (request) => { const event = request.getData() as unknown as AbstractMatrixEvent; + + // TODO: can we ignore all events from out homeserver? + // This was added particularly to avoid duplicating messages. + // Messages sent from rocket.chat also causes a m.room.message event, which if gets to this bridge + // before the event id promise is resolved, the respective message does not get event id attached to them any longer, + // thus this event handler "resends" the message to the rocket.chat room (not to matrix though). + if (event.type === 'm.room.message' && this.extractHomeserverOrigin(event.sender) === this.getMyHomeServerOrigin()) { + return; + } + this.eventHandler(event); }, onLog: (line, isError) => { @@ -753,7 +795,27 @@ export class MatrixBridge implements IFederationBridge { }; } - public async deactivateUser(uid: string) { + public async ping(): Promise<{ durationMs: number }> { + if (!this.isRunning || !this.bridgeInstance) { + throw new Error("matrix bridge isn't yet running"); + } + + const { duration_ms: durationMs } = await this.bridgeInstance.getIntent().matrixClient.doRequest( + 'POST', + `/_matrix/client/v1/appservice/${this.internalSettings.getApplicationServiceId()}/ping`, + {}, + /* + * Empty txn id as it is optional, neither does the spec says exactly what to do with it. + * https://github.com/matrix-org/matrix-spec/blob/1fc8f8856fe47849f90344cfa91601c984627acb/data/api/client-server/appservice_ping.yaml#L55-L56 + */ + {}, + DEFAULT_TIMEOUT_IN_MS_FOR_PING_EVENT, + ); + + return { durationMs }; + } + + public async deactivateUser(uid: string): Promise { /* * https://spec.matrix.org/v1.11/client-server-api/#post_matrixclientv3accountdeactivate * Using { erase: false } since rocket.chat side on deactivation we do not delete anything. diff --git a/apps/meteor/server/services/federation/infrastructure/rocket-chat/adapters/Room.ts b/apps/meteor/server/services/federation/infrastructure/rocket-chat/adapters/Room.ts index 91f5a6e66b43..9deabb53006e 100644 --- a/apps/meteor/server/services/federation/infrastructure/rocket-chat/adapters/Room.ts +++ b/apps/meteor/server/services/federation/infrastructure/rocket-chat/adapters/Room.ts @@ -7,6 +7,12 @@ import { saveRoomTopic } from '../../../../../../app/channel-settings/server'; import { addUserToRoom } from '../../../../../../app/lib/server/functions/addUserToRoom'; import { createRoom } from '../../../../../../app/lib/server/functions/createRoom'; import { removeUserFromRoom } from '../../../../../../app/lib/server/functions/removeUserFromRoom'; +import { + notifyOnSubscriptionChanged, + notifyOnSubscriptionChangedById, + notifyOnSubscriptionChangedByRoomId, + notifyOnSubscriptionChangedByRoomIdAndUserId, +} from '../../../../../../app/lib/server/lib/notifyListener'; import { settings } from '../../../../../../app/settings/server'; import { getDefaultSubscriptionPref } from '../../../../../../app/utils/lib/getDefaultSubscriptionPref'; import { getValidRoomName } from '../../../../../../app/utils/server/lib/getValidRoomName'; @@ -78,9 +84,16 @@ export class RocketChatRoomAdapter { public async removeDirectMessageRoom(federatedRoom: FederatedRoom): Promise { const roomId = federatedRoom.getInternalId(); - await Rooms.removeById(roomId); - await Subscriptions.removeByRoomId(roomId); - await MatrixBridgedRoom.removeByLocalRoomId(roomId); + + await Promise.all([ + Rooms.removeById(roomId), + Subscriptions.removeByRoomId(roomId, { + async onTrash(doc) { + void notifyOnSubscriptionChanged(doc, 'removed'); + }, + }), + MatrixBridgedRoom.removeByLocalRoomId(roomId), + ]); } public async createFederatedRoomForDirectMessage(federatedRoom: DirectMessageFederatedRoom): Promise { @@ -160,10 +173,13 @@ export class RocketChatRoomAdapter { } const user = federatedUser.getInternalReference(); - return Subscriptions.createWithRoomAndUser(room, user, { + const { insertedId } = await Subscriptions.createWithRoomAndUser(room, user, { ts: new Date(), ...getDefaultSubscriptionPref(user), }); + if (insertedId) { + void notifyOnSubscriptionChangedById(insertedId, 'inserted'); + } }) .filter(Boolean), ); @@ -182,33 +198,37 @@ export class RocketChatRoomAdapter { } public async updateRoomType(federatedRoom: FederatedRoom): Promise { - await Rooms.setRoomTypeById(federatedRoom.getInternalId(), federatedRoom.getRoomType()); - await Subscriptions.updateAllRoomTypesByRoomId(federatedRoom.getRoomType(), federatedRoom.getRoomType()); + const rid = federatedRoom.getInternalId(); + const roomType = federatedRoom.getRoomType(); + + await Rooms.setRoomTypeById(rid, roomType); + await Subscriptions.updateAllRoomTypesByRoomId(rid, roomType); + + void notifyOnSubscriptionChangedByRoomId(rid); } public async updateDisplayRoomName(federatedRoom: FederatedRoom, federatedUser: FederatedUser): Promise { - await Rooms.setFnameById(federatedRoom.getInternalId(), federatedRoom.getDisplayName()); - await Subscriptions.updateNameAndFnameByRoomId( - federatedRoom.getInternalId(), - federatedRoom.getName() || '', - federatedRoom.getDisplayName() || '', - ); + const rid = federatedRoom.getInternalId(); + const roomName = federatedRoom.getName() || ''; + const displayName = federatedRoom.getDisplayName() || ''; + const internalReference = federatedUser.getInternalReference(); - await Message.saveSystemMessage( - 'r', - federatedRoom.getInternalId(), - federatedRoom.getDisplayName() || '', - federatedUser.getInternalReference() as unknown as Required, // TODO fix type - ); + await Rooms.setFnameById(rid, displayName); + await Subscriptions.updateNameAndFnameByRoomId(rid, roomName, displayName); + await Message.saveSystemMessage('r', rid, displayName, internalReference); + + void notifyOnSubscriptionChangedByRoomId(rid); } public async updateRoomName(federatedRoom: FederatedRoom): Promise { - await Rooms.setRoomNameById(federatedRoom.getInternalId(), federatedRoom.getName()); - await Subscriptions.updateNameAndFnameByRoomId( - federatedRoom.getInternalId(), - federatedRoom.getName() || '', - federatedRoom.getDisplayName() || '', - ); + const rid = federatedRoom.getInternalId(); + const roomName = federatedRoom.getName() || ''; + const displayName = federatedRoom.getDisplayName() || ''; + + await Rooms.setRoomNameById(rid, roomName); + await Subscriptions.updateNameAndFnameByRoomId(rid, roomName, displayName); + + void notifyOnSubscriptionChangedByRoomId(rid); } public async updateRoomTopic(federatedRoom: FederatedRoom, federatedUser: FederatedUser): Promise { @@ -262,12 +282,15 @@ export class RocketChatRoomAdapter { rolesToRemove: ROCKET_CHAT_FEDERATION_ROLES[]; notifyChannel: boolean; }): Promise { - const subscription = await Subscriptions.findOneByRoomIdAndUserId(federatedRoom.getInternalId(), targetFederatedUser.getInternalId(), { - projection: { roles: 1 }, - }); + const uid = targetFederatedUser.getInternalId(); + const rid = federatedRoom.getInternalId(); + + const subscription = await Subscriptions.findOneByRoomIdAndUserId(rid, uid, { projection: { roles: 1 } }); + if (!subscription) { return; } + const { roles: currentRoles = [] } = subscription; const toAdd = rolesToAdd.filter((role) => !currentRoles.includes(role)); const toRemove = rolesToRemove.filter((role) => currentRoles.includes(role)); @@ -275,14 +298,19 @@ export class RocketChatRoomAdapter { _id: fromUser.getInternalId(), username: fromUser.getUsername(), }; + if (toAdd.length > 0) { - await Subscriptions.addRolesByUserId(targetFederatedUser.getInternalId(), toAdd, federatedRoom.getInternalId()); + const addRolesResponse = await Subscriptions.addRolesByUserId(uid, toAdd, rid); + if (addRolesResponse.modifiedCount) { + void notifyOnSubscriptionChangedByRoomIdAndUserId(rid, uid); + } + if (notifyChannel) { await Promise.all( toAdd.map((role) => Message.saveSystemMessage( 'subscription-role-added', - federatedRoom.getInternalId(), + rid, targetFederatedUser.getInternalReference().username || '', whoDidTheChange, { role }, @@ -291,14 +319,19 @@ export class RocketChatRoomAdapter { ); } } + if (toRemove.length > 0) { - await Subscriptions.removeRolesByUserId(targetFederatedUser.getInternalId(), toRemove, federatedRoom.getInternalId()); + const removeRolesResponse = await Subscriptions.removeRolesByUserId(uid, toRemove, rid); + if (removeRolesResponse.modifiedCount) { + void notifyOnSubscriptionChangedByRoomIdAndUserId(rid, uid); + } + if (notifyChannel) { await Promise.all( toRemove.map((role) => Message.saveSystemMessage( 'subscription-role-removed', - federatedRoom.getInternalId(), + rid, targetFederatedUser.getInternalReference().username || '', whoDidTheChange, { role }, @@ -307,6 +340,7 @@ export class RocketChatRoomAdapter { ); } } + if (settings.get('UI_DisplayRoles')) { this.notifyUIAboutRoomRolesChange(targetFederatedUser, federatedRoom, toAdd, toRemove); } diff --git a/apps/meteor/server/services/federation/infrastructure/rocket-chat/adapters/Settings.ts b/apps/meteor/server/services/federation/infrastructure/rocket-chat/adapters/Settings.ts index 9d447e881e78..861137f15e47 100644 --- a/apps/meteor/server/services/federation/infrastructure/rocket-chat/adapters/Settings.ts +++ b/apps/meteor/server/services/federation/infrastructure/rocket-chat/adapters/Settings.ts @@ -68,6 +68,17 @@ export class RocketChatSettingsAdapter { return settings.get('Federation_Matrix_enable_ephemeral_events') === true; } + public isConfigurationValid(): boolean { + return settings.get('Federation_Matrix_configuration_status') === 'Valid'; + } + + public async setConfigurationStatus(status: 'Valid' | 'Invalid'): Promise { + const { modifiedCount } = await Settings.updateOne({ _id: 'Federation_Matrix_configuration_status' }, { $set: { value: status } }); + if (modifiedCount) { + void notifyOnSettingChangedById('Federation_Matrix_configuration_status'); + } + } + public onFederationEnabledStatusChanged( callback: ( enabled: boolean, @@ -205,7 +216,7 @@ export class RocketChatSettingsAdapter { const siteUrl = settings.get('Site_Url'); await settingsRegistry.add('Federation_Matrix_id', `rocketchat_${uniqueId}`, { - readonly: true, + readonly: process.env.NODE_ENV === 'production', type: 'string', i18nLabel: 'Federation_Matrix_id', i18nDescription: 'Federation_Matrix_id_desc', @@ -214,7 +225,7 @@ export class RocketChatSettingsAdapter { }); await settingsRegistry.add('Federation_Matrix_hs_token', homeserverToken, { - readonly: true, + readonly: process.env.NODE_ENV === 'production', type: 'string', i18nLabel: 'Federation_Matrix_hs_token', i18nDescription: 'Federation_Matrix_hs_token_desc', @@ -223,7 +234,7 @@ export class RocketChatSettingsAdapter { }); await settingsRegistry.add('Federation_Matrix_as_token', applicationServiceToken, { - readonly: true, + readonly: process.env.NODE_ENV === 'production', type: 'string', i18nLabel: 'Federation_Matrix_as_token', i18nDescription: 'Federation_Matrix_as_token_desc', @@ -287,5 +298,27 @@ export class RocketChatSettingsAdapter { group: 'Federation', section: 'Matrix Bridge', }); + + await settingsRegistry.add('Federation_Matrix_configuration_status', 'Invalid', { + readonly: true, + type: 'string', + i18nLabel: 'Federation_Matrix_configuration_status', + i18nDescription: 'Federation_Matrix_configuration_status_desc', + public: false, + enterprise: false, + invalidValue: '', + group: 'Federation', + section: 'Matrix Bridge', + }); + + await settingsRegistry.add('Federation_Matrix_check_configuration_button', 'checkFederationConfiguration', { + type: 'action', + actionText: 'Federation_Matrix_check_configuration', + public: false, + enterprise: false, + invalidValue: '', + group: 'Federation', + section: 'Matrix Bridge', + }); } } diff --git a/apps/meteor/server/services/federation/infrastructure/rocket-chat/adapters/logger.ts b/apps/meteor/server/services/federation/infrastructure/rocket-chat/adapters/logger.ts index ddb606d37df8..87412cc2071d 100644 --- a/apps/meteor/server/services/federation/infrastructure/rocket-chat/adapters/logger.ts +++ b/apps/meteor/server/services/federation/infrastructure/rocket-chat/adapters/logger.ts @@ -3,3 +3,5 @@ import { Logger } from '@rocket.chat/logger'; const logger = new Logger('Federation_Matrix'); export const federationBridgeLogger = logger.section('matrix_federation_bridge'); + +export const federationServiceLogger = logger.section('matrix_federation_service'); diff --git a/apps/meteor/server/services/federation/infrastructure/rocket-chat/hooks/index.ts b/apps/meteor/server/services/federation/infrastructure/rocket-chat/hooks/index.ts index f14257512b11..8cac9bc9ffb0 100644 --- a/apps/meteor/server/services/federation/infrastructure/rocket-chat/hooks/index.ts +++ b/apps/meteor/server/services/federation/infrastructure/rocket-chat/hooks/index.ts @@ -1,19 +1,22 @@ import type { IMessage, IRoom, IUser } from '@rocket.chat/core-typings'; import { isMessageFromMatrixFederation, isRoomFederated, isEditedMessage } from '@rocket.chat/core-typings'; -import { settings } from '../../../../../../app/settings/server'; import { callbacks } from '../../../../../../lib/callbacks'; import { afterLeaveRoomCallback } from '../../../../../../lib/callbacks/afterLeaveRoomCallback'; import { afterRemoveFromRoomCallback } from '../../../../../../lib/callbacks/afterRemoveFromRoomCallback'; import type { FederationRoomServiceSender } from '../../../application/room/sender/RoomServiceSender'; +import { isFederationEnabled, throwIfFederationNotEnabledOrNotReady, throwIfFederationNotReady } from '../../../utils'; export class FederationHooks { public static afterUserLeaveRoom(callback: (user: IUser, room: IRoom) => Promise): void { afterLeaveRoomCallback.add( async (user: IUser, room?: IRoom): Promise => { - if (!room || !isRoomFederated(room) || !user || !settings.get('Federation_Matrix_enabled')) { + if (!room || !isRoomFederated(room) || !user) { return; } + + throwIfFederationNotEnabledOrNotReady(); + await callback(user, room); }, callbacks.priority.HIGH, @@ -24,16 +27,12 @@ export class FederationHooks { public static onUserRemovedFromRoom(callback: (removedUser: IUser, room: IRoom, userWhoRemoved: IUser) => Promise): void { afterRemoveFromRoomCallback.add( async (params, room): Promise => { - if ( - !room || - !isRoomFederated(room) || - !params || - !params.removedUser || - !params.userWhoRemoved || - !settings.get('Federation_Matrix_enabled') - ) { + if (!room || !isRoomFederated(room) || !params || !params.removedUser || !params.userWhoRemoved) { return; } + + throwIfFederationNotEnabledOrNotReady(); + await callback(params.removedUser, room, params.userWhoRemoved); }, callbacks.priority.HIGH, @@ -45,9 +44,10 @@ export class FederationHooks { callbacks.add( 'federation.beforeAddUserToARoom', async (params: { user: IUser | string; inviter?: IUser }, room: IRoom): Promise => { - if (!params?.user || !room) { + if (!params?.user || !room || !isFederationEnabled()) { return; } + await callback(params.user, room); }, callbacks.priority.HIGH, @@ -59,7 +59,7 @@ export class FederationHooks { callbacks.add( 'federation.beforeAddUserToARoom', async (params: { user: IUser | string; inviter: IUser }, room: IRoom): Promise => { - if (!params?.user || !params.inviter || !room || !settings.get('Federation_Matrix_enabled')) { + if (!params?.user || !params.inviter || !room || !isFederationEnabled()) { return; } @@ -74,9 +74,12 @@ export class FederationHooks { callbacks.add( 'federation.beforeCreateDirectMessage', async (members: IUser[]): Promise => { - if (!members || !settings.get('Federation_Matrix_enabled')) { + if (!members) { return; } + + throwIfFederationNotEnabledOrNotReady(); + await callback(members); }, callbacks.priority.HIGH, @@ -88,16 +91,12 @@ export class FederationHooks { callbacks.add( 'afterSetReaction', async (message: IMessage, params: { user: IUser; reaction: string }): Promise => { - if ( - !message || - !isMessageFromMatrixFederation(message) || - !params || - !params.user || - !params.reaction || - !settings.get('Federation_Matrix_enabled') - ) { + if (!message || !isMessageFromMatrixFederation(message) || !params || !params.user || !params.reaction) { return; } + + throwIfFederationNotEnabledOrNotReady(); + await callback(message, params.user, params.reaction); }, callbacks.priority.HIGH, @@ -109,17 +108,12 @@ export class FederationHooks { callbacks.add( 'afterUnsetReaction', async (message: IMessage, params: { user: IUser; reaction: string; oldMessage: IMessage }): Promise => { - if ( - !message || - !isMessageFromMatrixFederation(message) || - !params || - !params.user || - !params.reaction || - !params.oldMessage || - !settings.get('Federation_Matrix_enabled') - ) { + if (!message || !isMessageFromMatrixFederation(message) || !params || !params.user || !params.reaction || !params.oldMessage) { return; } + + throwIfFederationNotEnabledOrNotReady(); + await callback(params.oldMessage, params.user, params.reaction); }, callbacks.priority.HIGH, @@ -131,15 +125,12 @@ export class FederationHooks { callbacks.add( 'afterDeleteMessage', async (message: IMessage, room: IRoom): Promise => { - if ( - !room || - !message || - !isRoomFederated(room) || - !isMessageFromMatrixFederation(message) || - !settings.get('Federation_Matrix_enabled') - ) { + if (!room || !message || !isRoomFederated(room) || !isMessageFromMatrixFederation(message)) { return; } + + throwIfFederationNotEnabledOrNotReady(); + await callback(message, room._id); }, callbacks.priority.HIGH, @@ -150,16 +141,13 @@ export class FederationHooks { public static afterMessageUpdated(callback: (message: IMessage, roomId: IRoom['_id'], userId: string) => Promise): void { callbacks.add( 'afterSaveMessage', - async (message, { room }): Promise => { - if ( - !room || - !isRoomFederated(room) || - !message || - !isMessageFromMatrixFederation(message) || - !settings.get('Federation_Matrix_enabled') - ) { + async (message: IMessage, { room }): Promise => { + if (!room || !isRoomFederated(room) || !message || !isMessageFromMatrixFederation(message)) { return message; } + + throwIfFederationNotEnabledOrNotReady(); + if (!isEditedMessage(message)) { return message; } @@ -174,10 +162,13 @@ export class FederationHooks { public static afterMessageSent(callback: (message: IMessage, roomId: IRoom['_id'], userId: string) => Promise): void { callbacks.add( 'afterSaveMessage', - async (message, { room }): Promise => { - if (!room || !isRoomFederated(room) || !message || !settings.get('Federation_Matrix_enabled')) { + async (message: IMessage, { room }): Promise => { + if (!room || !isRoomFederated(room) || !message) { return message; } + + throwIfFederationNotEnabledOrNotReady(); + if (isEditedMessage(message)) { return message; } @@ -190,9 +181,16 @@ export class FederationHooks { } public static async afterRoomRoleChanged(federationRoomService: FederationRoomServiceSender, data?: Record) { - if (!data || !settings.get('Federation_Matrix_enabled')) { + if (!data) { + return; + } + + if (!isFederationEnabled()) { return; } + + throwIfFederationNotReady(); + const { _id: role, type: action, @@ -225,9 +223,12 @@ export class FederationHooks { callbacks.add( 'afterRoomNameChange', async (params: Record): Promise => { - if (!params?.rid || !params.name || !settings.get('Federation_Matrix_enabled')) { + if (!params?.rid || !params.name) { return; } + + throwIfFederationNotEnabledOrNotReady(); + await callback(params.rid, params.name); }, callbacks.priority.HIGH, @@ -239,9 +240,12 @@ export class FederationHooks { callbacks.add( 'afterRoomTopicChange', async (params: Record): Promise => { - if (!params?.rid || !params.topic || !settings.get('Federation_Matrix_enabled')) { + if (!params?.rid || !params.topic) { return; } + + throwIfFederationNotEnabledOrNotReady(); + await callback(params.rid, params.topic); }, callbacks.priority.HIGH, @@ -266,5 +270,7 @@ export class FederationHooks { callbacks.remove('afterSaveMessage', 'federation-v2-after-room-message-updated'); callbacks.remove('afterSaveMessage', 'federation-v2-after-room-message-sent'); callbacks.remove('afterSaveMessage', 'federation-v2-after-room-message-sent'); + callbacks.remove('afterRoomNameChange', 'federation-v2-after-room-name-changed'); + callbacks.remove('afterRoomTopicChange', 'federation-v2-after-room-topic-changed'); } } diff --git a/apps/meteor/server/services/federation/infrastructure/rocket-chat/well-known.ts b/apps/meteor/server/services/federation/infrastructure/rocket-chat/well-known.ts index b94dfe6628c4..b1088c2f6ff9 100644 --- a/apps/meteor/server/services/federation/infrastructure/rocket-chat/well-known.ts +++ b/apps/meteor/server/services/federation/infrastructure/rocket-chat/well-known.ts @@ -30,7 +30,7 @@ async function returnMatrixClientJSON(_: IncomingMessage, res: ServerResponse) { res.setHeader('content-type', 'application/json'); - res.write(JSON.stringify({ 'm.homeserver': `${protocol}//${hostname}` })); + res.write(JSON.stringify({ 'm.homeserver': { base_url: `${protocol}//${hostname}` } })); res.end(); } diff --git a/apps/meteor/server/services/federation/service.ts b/apps/meteor/server/services/federation/service.ts index 66d3fd0cb6ee..904e73913a17 100644 --- a/apps/meteor/server/services/federation/service.ts +++ b/apps/meteor/server/services/federation/service.ts @@ -1,5 +1,10 @@ +import { IncomingMessage } from 'node:http'; +import { URL } from 'node:url'; + import { ServiceClassInternal } from '@rocket.chat/core-services'; -import type { IFederationService } from '@rocket.chat/core-services'; +import type { IFederationService, FederationConfigurationStatus } from '@rocket.chat/core-services'; +import { isRoomFederated, type IRoom } from '@rocket.chat/core-typings'; +import { serverFetch as fetch } from '@rocket.chat/server-fetch'; import type { FederationRoomServiceSender } from './application/room/sender/RoomServiceSender'; import type { FederationUserServiceSender } from './application/user/sender/UserServiceSender'; @@ -12,10 +17,28 @@ import type { RocketChatNotificationAdapter } from './infrastructure/rocket-chat import type { RocketChatRoomAdapter } from './infrastructure/rocket-chat/adapters/Room'; import type { RocketChatSettingsAdapter } from './infrastructure/rocket-chat/adapters/Settings'; import type { RocketChatUserAdapter } from './infrastructure/rocket-chat/adapters/User'; +import { federationServiceLogger } from './infrastructure/rocket-chat/adapters/logger'; import { FederationRoomSenderConverter } from './infrastructure/rocket-chat/converters/RoomSender'; import { FederationHooks } from './infrastructure/rocket-chat/hooks'; - import './infrastructure/rocket-chat/well-known'; +import { throwIfFederationNotEnabledOrNotReady } from './utils'; + +function extractError(e: unknown) { + if (e instanceof Error || (typeof e === 'object' && e && 'toString' in e)) { + if ('name' in e && e.name === 'AbortError') { + return 'Operation timed out'; + } + + return e.toString(); + } + + federationServiceLogger.error(e); + + return 'Unknown error'; +} + +// for airgapped deployments, use environment variable to override a local instance of federationtester +const federationTesterHost = process.env.FEDERATION_TESTER_HOST?.trim()?.replace(/\/$/, '') || 'https://federationtester.matrix.org'; export abstract class AbstractFederationService extends ServiceClassInternal { private cancelSettingsObserver: () => void; @@ -126,7 +149,9 @@ export abstract class AbstractFederationService extends ServiceClassInternal { if (isFederationEnabled) { await this.onDisableFederation(); - return this.onEnableFederation(); + await this.onEnableFederation(); + await this.verifyConfiguration(); + return; } return this.onDisableFederation(); @@ -180,6 +205,17 @@ export abstract class AbstractFederationService extends ServiceClassInternal { this.internalQueueInstance.setHandler(federationEventsHandler.handleEvent.bind(federationEventsHandler), this.PROCESSING_CONCURRENCY); } + private canOtherHomeserversFederate(): Promise { + const url = new URL(`https://${this.internalSettingsAdapter.getHomeServerDomain()}`); + + return new Promise((resolve, reject) => + fetch(`${federationTesterHost}/api/federation-ok?server_name=${url.host}`) + .then((response) => response.text()) + .then((text) => resolve(text === 'GOOD')) + .catch(reject), + ); + } + protected getInternalSettingsAdapter(): RocketChatSettingsAdapter { return this.internalSettingsAdapter; } @@ -239,7 +275,75 @@ export abstract class AbstractFederationService extends ServiceClassInternal { return this.bridge.verifyInviteeIds(matrixIds); } - protected async deactivateRemoteUser(remoteUserId: string) { + public async configurationStatus(): Promise { + const status: FederationConfigurationStatus = { + appservice: { + roundTrip: { durationMs: -1 }, + ok: false, + }, + externalReachability: { + ok: false, + }, + }; + + try { + const pingResponse = await this.bridge.ping(); + status.appservice.roundTrip.durationMs = pingResponse.durationMs; + status.appservice.ok = true; + } catch (error) { + if (error instanceof IncomingMessage) { + if (error.statusCode === 404) { + status.appservice.error = 'homeserver version must be >=1.84.x'; + } else { + status.appservice.error = `received unknown status from homeserver, message: ${error.statusMessage}`; + } + } else { + status.appservice.error = extractError(error); + } + } + + try { + status.externalReachability.ok = await this.canOtherHomeserversFederate(); + } catch (error) { + status.externalReachability.error = extractError(error); + } + + return status; + } + + public async markConfigurationValid(): Promise { + return this.internalSettingsAdapter.setConfigurationStatus('Valid'); + } + + public async markConfigurationInvalid(): Promise { + return this.internalSettingsAdapter.setConfigurationStatus('Invalid'); + } + + public async verifyConfiguration(): Promise { + try { + await this.bridge?.ping(); // throws error if fails + + if (!(await this.canOtherHomeserversFederate())) { + throw new Error('External reachability could not be verified'); + } + + void this.markConfigurationValid(); + } catch (error) { + federationServiceLogger.error(error); + + void this.markConfigurationInvalid(); + } + } + + public async beforeCreateRoom(room: Partial): Promise { + if (!isRoomFederated(room)) { + return; + } + + throwIfFederationNotEnabledOrNotReady(); + } + + protected async deactivateRemoteUser(remoteUserId: string): Promise { return this.bridge.deactivateUser(remoteUserId); } } @@ -347,7 +451,27 @@ export class FederationService extends AbstractBaseFederationService implements return super.created(); } - public async deactivateRemoteUser(userId: string) { + public async verifyConfiguration(): Promise { + return super.verifyConfiguration(); + } + + public async markConfigurationValid(): Promise { + return super.markConfigurationValid(); + } + + public async markConfigurationInvalid(): Promise { + return super.markConfigurationInvalid(); + } + + public async configurationStatus(): Promise { + return super.configurationStatus(); + } + + public async beforeCreateRoom(room: Partial): Promise { + return super.beforeCreateRoom(room); + } + + public async deactivateRemoteUser(userId: string): Promise { return super.deactivateRemoteUser(userId); } } diff --git a/apps/meteor/server/services/federation/utils.ts b/apps/meteor/server/services/federation/utils.ts new file mode 100644 index 000000000000..0256b4f04fe8 --- /dev/null +++ b/apps/meteor/server/services/federation/utils.ts @@ -0,0 +1,44 @@ +import { settings } from '../../../app/settings/server'; + +export function isFederationEnabled(): boolean { + return settings.get('Federation_Matrix_enabled'); +} + +export function isFederationReady(): boolean { + return settings.get('Federation_Matrix_configuration_status') === 'Valid'; +} + +export function throwIfFederationNotEnabledOrNotReady(): void { + if (!isFederationEnabled()) { + throw new Error('Federation is not enabled'); + } + + if (!isFederationReady()) { + throw new Error('Federation configuration is invalid'); + } +} + +export function throwIfFederationEnabledButNotReady(): void { + if (!isFederationEnabled()) { + return; + } + + throwIfFederationNotReady(); +} + +export function throwIfFederationNotReady(): void { + if (!isFederationReady()) { + throw new Error('Federation configuration is invalid'); + } +} + +export class FederationMatrixInvalidConfigurationError extends Error { + constructor(cause?: string) { + // eslint-disable-next-line prefer-template + const message = 'Federation configuration is invalid' + (cause ? ',' + cause[0].toLowerCase() + cause.slice(1) : ''); + + super(message); + + this.name = 'FederationMatrixInvalidConfiguration'; + } +} diff --git a/apps/meteor/server/services/messages/hooks/BeforeFederationActions.ts b/apps/meteor/server/services/messages/hooks/BeforeFederationActions.ts new file mode 100644 index 000000000000..a954e4899970 --- /dev/null +++ b/apps/meteor/server/services/messages/hooks/BeforeFederationActions.ts @@ -0,0 +1,13 @@ +import { type IMessage, type IRoom, isMessageFromMatrixFederation, isRoomFederated } from '@rocket.chat/core-typings'; + +import { isFederationEnabled, isFederationReady } from '../../federation/utils'; + +export class FederationActions { + public static shouldPerformAction(message: IMessage, room: IRoom): boolean { + if (isMessageFromMatrixFederation(message) || isRoomFederated(room)) { + return isFederationEnabled() && isFederationReady(); + } + + return true; + } +} diff --git a/apps/meteor/server/services/messages/service.ts b/apps/meteor/server/services/messages/service.ts index 906868b6bb17..b20b5236b7fe 100644 --- a/apps/meteor/server/services/messages/service.ts +++ b/apps/meteor/server/services/messages/service.ts @@ -13,6 +13,8 @@ import { executeSetReaction } from '../../../app/reactions/server/setReaction'; import { settings } from '../../../app/settings/server'; import { getUserAvatarURL } from '../../../app/utils/server/getUserAvatarURL'; import { BeforeSaveCannedResponse } from '../../../ee/server/hooks/messages/BeforeSaveCannedResponse'; +import { FederationMatrixInvalidConfigurationError } from '../federation/utils'; +import { FederationActions } from './hooks/BeforeFederationActions'; import { BeforeSaveBadWords } from './hooks/BeforeSaveBadWords'; import { BeforeSaveCheckMAC } from './hooks/BeforeSaveCheckMAC'; import { BeforeSaveJumpToMessage } from './hooks/BeforeSaveJumpToMessage'; @@ -168,6 +170,10 @@ export class MessageService extends ServiceClassInternal implements IMessageServ // TODO looks like this one was not being used (so I'll left it commented) // await this.joinDiscussionOnMessage({ message, room, user }); + if (!FederationActions.shouldPerformAction(message, room)) { + throw new FederationMatrixInvalidConfigurationError('Unable to send message'); + } + message = await mentionServer.execute(message); message = await this.cannedResponse.replacePlaceholders({ message, room, user }); message = await this.badWords.filterBadWords({ message }); @@ -237,4 +243,16 @@ export class MessageService extends ServiceClassInternal implements IMessageServ // await Room.join({ room, user }); // } + + async beforeReacted(message: IMessage, room: IRoom) { + if (!FederationActions.shouldPerformAction(message, room)) { + throw new FederationMatrixInvalidConfigurationError('Unable to react to message'); + } + } + + async beforeDelete(message: IMessage, room: IRoom) { + if (!FederationActions.shouldPerformAction(message, room)) { + throw new FederationMatrixInvalidConfigurationError('Unable to delete message'); + } + } } diff --git a/apps/meteor/server/services/omnichannel-analytics/AgentData.ts b/apps/meteor/server/services/omnichannel-analytics/AgentData.ts index 7475dc4a2d5e..40ce0f1236cb 100644 --- a/apps/meteor/server/services/omnichannel-analytics/AgentData.ts +++ b/apps/meteor/server/services/omnichannel-analytics/AgentData.ts @@ -77,7 +77,7 @@ export class AgentOverviewData { const agentConversations = new Map(); // stores total conversations for each agent const date = { gte: from.toDate(), - lt: to.add(1, 'days').toDate(), + lte: to.toDate(), }; const data: ConversationData = { @@ -128,7 +128,7 @@ export class AgentOverviewData { const agentChatDurations = new Map(); // stores total conversations for each agent const date = { gte: from.toDate(), - lt: to.add(1, 'days').toDate(), + lte: to.toDate(), }; const data: ConversationData = { @@ -178,7 +178,7 @@ export class AgentOverviewData { const agentMessages = new Map(); // stores total conversations for each agent const date = { gte: from.toDate(), - lt: to.add(1, 'days').toDate(), + lte: to.toDate(), }; const data: ConversationData = { @@ -220,7 +220,7 @@ export class AgentOverviewData { const agentAvgRespTime = new Map(); // stores avg response time for each agent const date = { gte: from.toDate(), - lt: to.add(1, 'days').toDate(), + lte: to.toDate(), }; const data: ConversationData = { @@ -270,7 +270,7 @@ export class AgentOverviewData { const agentFirstRespTime = new Map(); // stores avg response time for each agent const date = { gte: from.toDate(), - lt: to.add(1, 'days').toDate(), + lte: to.toDate(), }; const data: ConversationData = { @@ -312,7 +312,7 @@ export class AgentOverviewData { const agentAvgRespTime = new Map(); // stores avg response time for each agent const date = { gte: from.toDate(), - lt: to.add(1, 'days').toDate(), + lte: to.toDate(), }; const data: ConversationData = { @@ -362,7 +362,7 @@ export class AgentOverviewData { const agentAvgReactionTime = new Map(); // stores avg reaction time for each agent const date = { gte: from.toDate(), - lt: to.add(1, 'days').toDate(), + lte: to.toDate(), }; const data: ConversationData = { diff --git a/apps/meteor/server/services/omnichannel-analytics/ChartData.ts b/apps/meteor/server/services/omnichannel-analytics/ChartData.ts index f2b1f21b5b9a..505bff76541b 100644 --- a/apps/meteor/server/services/omnichannel-analytics/ChartData.ts +++ b/apps/meteor/server/services/omnichannel-analytics/ChartData.ts @@ -14,7 +14,7 @@ type ChartDataValidActions = type DateParam = { gte: Date; - lt: Date; + lte: Date; }; export class ChartData { diff --git a/apps/meteor/server/services/omnichannel-analytics/OverviewData.ts b/apps/meteor/server/services/omnichannel-analytics/OverviewData.ts index a05823dd0fe4..b9e9faa2f8b2 100644 --- a/apps/meteor/server/services/omnichannel-analytics/OverviewData.ts +++ b/apps/meteor/server/services/omnichannel-analytics/OverviewData.ts @@ -97,7 +97,7 @@ export class OverviewData { const date = { gte: moment.tz(from, timezone).startOf('day').utc(), - lt: moment.tz(to, timezone).endOf('day').utc(), + lte: moment.tz(to, timezone).endOf('day').utc(), }; // @ts-expect-error - Check extraquery usage on this func @@ -181,7 +181,7 @@ export class OverviewData { const date = { gte: from.toDate(), - lt: to.add(1, 'days').toDate(), + lte: to.toDate(), }; await this.roomsModel.getAnalyticsMetricsBetweenDate('l', date, { departmentId }, extraQuery).forEach(({ metrics }) => { diff --git a/apps/meteor/server/services/omnichannel-analytics/service.ts b/apps/meteor/server/services/omnichannel-analytics/service.ts index d40b5d341f71..4f479d83acdd 100644 --- a/apps/meteor/server/services/omnichannel-analytics/service.ts +++ b/apps/meteor/server/services/omnichannel-analytics/service.ts @@ -111,13 +111,13 @@ export class OmnichannelAnalyticsService extends ServiceClassInternal implements const hour = parseInt(m.add(currentHour ? 1 : 0, 'hour').format('H')); const label = { from: moment.utc().set({ hour }).tz(timezone).format('hA'), - to: moment.utc().set({ hour }).add(1, 'hour').tz(timezone).format('hA'), + to: moment.utc().set({ hour }).endOf('hour').tz(timezone).format('hA'), }; data.dataLabels.push(`${label.from}-${label.to}`); const date = { gte: m.toDate(), - lt: moment(m).add(1, 'hours').toDate(), + lte: moment(m).endOf('hour').toDate(), }; data.dataPoints.push(await this.chart.callAction(chartLabel, date, departmentId, extraQuery)); @@ -128,7 +128,7 @@ export class OmnichannelAnalyticsService extends ServiceClassInternal implements const date = { gte: m.toDate(), - lt: moment(m).add(1, 'days').toDate(), + lte: moment(m).endOf('day').toDate(), }; data.dataPoints.push(await this.chart.callAction(chartLabel, date, departmentId, extraQuery)); diff --git a/apps/meteor/server/services/room/hooks/BeforeFederationActions.ts b/apps/meteor/server/services/room/hooks/BeforeFederationActions.ts new file mode 100644 index 000000000000..925fdfcbee32 --- /dev/null +++ b/apps/meteor/server/services/room/hooks/BeforeFederationActions.ts @@ -0,0 +1,13 @@ +import type { IRoom } from '@rocket.chat/core-typings'; + +import { throwIfFederationNotEnabledOrNotReady } from '../../federation/utils'; + +export class FederationActions { + public static blockIfRoomFederatedButServiceNotReady({ federated }: Pick) { + if (!federated) { + return; + } + + throwIfFederationNotEnabledOrNotReady(); + } +} diff --git a/apps/meteor/server/services/room/service.ts b/apps/meteor/server/services/room/service.ts index 3ba47284ddee..5bbde4a2814e 100644 --- a/apps/meteor/server/services/room/service.ts +++ b/apps/meteor/server/services/room/service.ts @@ -11,6 +11,7 @@ import { getValidRoomName } from '../../../app/utils/server/lib/getValidRoomName import { RoomMemberActions } from '../../../definition/IRoomTypeConfig'; import { roomCoordinator } from '../../lib/rooms/roomCoordinator'; import { createDirectMessage } from '../../methods/createDirectMessage'; +import { FederationActions } from './hooks/BeforeFederationActions'; export class RoomService extends ServiceClassInternal implements IRoomService { protected name = 'room'; @@ -121,4 +122,20 @@ export class RoomService extends ServiceClassInternal implements IRoomService { return addUserToRoom(room._id, user); } + + async beforeLeave(room: IRoom): Promise { + FederationActions.blockIfRoomFederatedButServiceNotReady(room); + } + + async beforeUserRemoved(room: IRoom): Promise { + FederationActions.blockIfRoomFederatedButServiceNotReady(room); + } + + async beforeNameChange(room: IRoom): Promise { + FederationActions.blockIfRoomFederatedButServiceNotReady(room); + } + + async beforeTopicChange(room: IRoom): Promise { + FederationActions.blockIfRoomFederatedButServiceNotReady(room); + } } diff --git a/apps/meteor/server/services/team/service.ts b/apps/meteor/server/services/team/service.ts index bc4211322b66..190464f48da4 100644 --- a/apps/meteor/server/services/team/service.ts +++ b/apps/meteor/server/services/team/service.ts @@ -32,6 +32,7 @@ import { addUserToRoom } from '../../../app/lib/server/functions/addUserToRoom'; import { checkUsernameAvailability } from '../../../app/lib/server/functions/checkUsernameAvailability'; import { getSubscribedRoomsForUserWithDetails } from '../../../app/lib/server/functions/getRoomsWithSingleOwner'; import { removeUserFromRoom } from '../../../app/lib/server/functions/removeUserFromRoom'; +import { notifyOnSubscriptionChangedByRoomIdAndUserId } from '../../../app/lib/server/lib/notifyListener'; import { settings } from '../../../app/settings/server'; export class TeamService extends ServiceClassInternal implements ITeamService { @@ -77,6 +78,21 @@ export class TeamService extends ServiceClassInternal implements ITeamService { }; try { + const roomId = + room.id || + ( + await Room.create(owner || uid, { + ...room, + type: team.type === TEAM_TYPE.PRIVATE ? 'p' : 'c', + name: team.name, + members: memberUsernames as string[], + extraData: { + ...room.extraData, + }, + sidepanel, + }) + )._id; + const result = await Team.insertOne(teamData); const teamId = result.insertedId; // the same uid can be passed at 3 positions: owner, member list or via caller @@ -106,33 +122,14 @@ export class TeamService extends ServiceClassInternal implements ITeamService { await TeamMember.insertMany(membersList); - let roomId = room.id; - if (roomId) { - await Rooms.setTeamMainById(roomId, teamId); - await Message.saveSystemMessage('user-converted-to-team', roomId, team.name, createdBy); - } else { - const roomType: IRoom['t'] = team.type === TEAM_TYPE.PRIVATE ? 'p' : 'c'; - - const newRoom = { - ...room, - type: roomType, - name: team.name, - members: memberUsernames as string[], - extraData: { - ...room.extraData, - teamId, - teamMain: true, - }, - sidepanel, - }; - - const createdRoom = await Room.create(owner || uid, newRoom); - roomId = createdRoom._id; - } - + await Rooms.setTeamMainById(roomId, teamId); await Team.updateMainRoomForTeam(teamId, roomId); teamData.roomId = roomId; + if (room.id) { + await Message.saveSystemMessage('user-converted-to-team', roomId, team.name, createdBy); + } + return { _id: teamId, ...teamData, @@ -745,7 +742,7 @@ export class TeamService extends ServiceClassInternal implements ITeamService { throw new Error('invalid-team'); } - await Promise.all([ + const responses = await Promise.all([ TeamMember.updateOneByUserIdAndTeamId(member.userId, teamId, memberUpdate), Subscriptions.updateOne( { @@ -757,6 +754,10 @@ export class TeamService extends ServiceClassInternal implements ITeamService { }, ), ]); + + if (responses[1].modifiedCount) { + void notifyOnSubscriptionChangedByRoomIdAndUserId(team.roomId, member.userId); + } } async removeMember(teamId: string, userId: string): Promise { diff --git a/apps/meteor/server/settings/e2e.ts b/apps/meteor/server/settings/e2e.ts index 0ff31090f5d8..6f22784f1709 100644 --- a/apps/meteor/server/settings/e2e.ts +++ b/apps/meteor/server/settings/e2e.ts @@ -16,6 +16,14 @@ export const createE2ESettings = () => enableQuery: { _id: 'E2E_Enable', value: true }, }); + await this.add('E2E_Enable_Encrypt_Files', true, { + type: 'boolean', + i18nLabel: 'E2E_Enable_Encrypt_Files', + i18nDescription: 'E2E_Enable_Encrypt_Files_Description', + public: true, + enableQuery: { _id: 'E2E_Enable', value: true }, + }); + await this.add('E2E_Enabled_Default_DirectRooms', false, { type: 'boolean', public: true, diff --git a/apps/meteor/tests/data/livechat/rooms.ts b/apps/meteor/tests/data/livechat/rooms.ts index e2084adda934..9532fd4214ab 100644 --- a/apps/meteor/tests/data/livechat/rooms.ts +++ b/apps/meteor/tests/data/livechat/rooms.ts @@ -240,11 +240,11 @@ export const uploadFile = (roomId: string, visitorToken: string): Promise => { +export const sendAgentMessage = (roomId: string, msg?: string, userCredentials: Credentials = credentials): Promise => { return new Promise((resolve, reject) => { void request .post(methodCall('sendMessage')) - .set(credentials) + .set(userCredentials) .send({ message: JSON.stringify({ method: 'sendMessage', diff --git a/apps/meteor/tests/e2e/account-forgetSessionOnWindowClose.spec.ts b/apps/meteor/tests/e2e/account-forgetSessionOnWindowClose.spec.ts new file mode 100644 index 000000000000..a19b0e9866da --- /dev/null +++ b/apps/meteor/tests/e2e/account-forgetSessionOnWindowClose.spec.ts @@ -0,0 +1,55 @@ +import { DEFAULT_USER_CREDENTIALS } from './config/constants'; +import { Registration } from './page-objects'; +import { test, expect } from './utils/test'; + +test.describe.serial('Forget session on window close setting', () => { + let poRegistration: Registration; + + test.beforeEach(async ({ page }) => { + poRegistration = new Registration(page); + + await page.goto('/home'); + }); + + test.describe('Setting off', async () => { + test.beforeAll(async ({ api }) => { + await api.post('/settings/Accounts_ForgetUserSessionOnWindowClose', { value: false }); + }); + + test('Login using credentials and reload to stay logged in', async ({ page, context }) => { + await poRegistration.username.type('user1'); + await poRegistration.inputPassword.type(DEFAULT_USER_CREDENTIALS.password); + await poRegistration.btnLogin.click(); + + await expect(page.locator('role=heading[name="Welcome to Rocket.Chat"]')).toBeVisible(); + + const newPage = await context.newPage(); + await newPage.goto('/home'); + + await expect(newPage.locator('role=heading[name="Welcome to Rocket.Chat"]')).toBeVisible(); + }); + }); + + test.describe('Setting on', async () => { + test.beforeAll(async ({ api }) => { + await api.post('/settings/Accounts_ForgetUserSessionOnWindowClose', { value: true }); + }); + + test.afterAll(async ({ api }) => { + await api.post('/settings/Accounts_ForgetUserSessionOnWindowClose', { value: false }); + }); + + test('Login using credentials and reload to get logged out', async ({ page, context }) => { + await poRegistration.username.type('user1'); + await poRegistration.inputPassword.type(DEFAULT_USER_CREDENTIALS.password); + await poRegistration.btnLogin.click(); + + await expect(page.locator('role=heading[name="Welcome to Rocket.Chat"]')).toBeVisible(); + + const newPage = await context.newPage(); + await newPage.goto('/home'); + + await expect(newPage.locator('role=button[name="Login"]')).toBeVisible(); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/administration.spec.ts b/apps/meteor/tests/e2e/administration.spec.ts index 45fee011efc3..808c7e719faa 100644 --- a/apps/meteor/tests/e2e/administration.spec.ts +++ b/apps/meteor/tests/e2e/administration.spec.ts @@ -75,11 +75,12 @@ test.describe.parallel('administration', () => { test('expect create a user', async () => { await poAdmin.tabs.users.btnNewUser.click(); + await poAdmin.tabs.users.inputEmail.type(faker.internet.email()); await poAdmin.tabs.users.inputName.type(faker.person.firstName()); await poAdmin.tabs.users.inputUserName.type(faker.internet.userName()); - await poAdmin.tabs.users.inputEmail.type(faker.internet.email()); - await poAdmin.tabs.users.checkboxVerified.click(); + await poAdmin.tabs.users.inputSetManually.click(); await poAdmin.tabs.users.inputPassword.type('any_password'); + await poAdmin.tabs.users.inputConfirmPassword.type('any_password'); await expect(poAdmin.tabs.users.userRole).toBeVisible(); await poAdmin.tabs.users.btnSave.click(); }); @@ -97,8 +98,9 @@ test.describe.parallel('administration', () => { await poAdmin.tabs.users.inputName.type(faker.person.firstName()); await poAdmin.tabs.users.inputUserName.type(username); await poAdmin.tabs.users.inputEmail.type(faker.internet.email()); - await poAdmin.tabs.users.checkboxVerified.click(); + await poAdmin.tabs.users.inputSetManually.click(); await poAdmin.tabs.users.inputPassword.type('any_password'); + await poAdmin.tabs.users.inputConfirmPassword.type('any_password'); await expect(poAdmin.tabs.users.userRole).toBeVisible(); await expect(poAdmin.tabs.users.joinDefaultChannels).toBeVisible(); await poAdmin.tabs.users.btnSave.click(); diff --git a/apps/meteor/tests/e2e/e2e-encryption.spec.ts b/apps/meteor/tests/e2e/e2e-encryption.spec.ts index dd481ea39b6a..d98acaf847c4 100644 --- a/apps/meteor/tests/e2e/e2e-encryption.spec.ts +++ b/apps/meteor/tests/e2e/e2e-encryption.spec.ts @@ -425,6 +425,149 @@ test.describe.serial('e2e-encryption', () => { await expect(poHomeChannel.content.nthMessage(0).locator('.rcx-icon--name-key')).toBeVisible(); }); + test.describe('File Encryption', async () => { + test.afterAll(async ({ api }) => { + expect((await api.post('/settings/FileUpload_MediaTypeWhiteList', { value: '' })).status()).toBe(200); + expect((await api.post('/settings/FileUpload_MediaTypeBlackList', { value: 'image/svg+xml' })).status()).toBe(200); + }); + + test('File and description encryption', async ({ page }) => { + await test.step('create an encrypted channel', async () => { + const channelName = faker.string.uuid(); + + await poHomeChannel.sidenav.openNewByLabel('Channel'); + await poHomeChannel.sidenav.inputChannelName.fill(channelName); + await poHomeChannel.sidenav.advancedSettingsAccordion.click(); + await poHomeChannel.sidenav.checkboxEncryption.click(); + await poHomeChannel.sidenav.btnCreate.click(); + + await expect(page).toHaveURL(`/group/${channelName}`); + + await poHomeChannel.dismissToast(); + + await expect(poHomeChannel.content.encryptedRoomHeaderIcon).toBeVisible(); + }); + + await test.step('send a file in channel', async () => { + await poHomeChannel.content.dragAndDropTxtFile(); + await poHomeChannel.content.descriptionInput.fill('any_description'); + await poHomeChannel.content.fileNameInput.fill('any_file1.txt'); + await poHomeChannel.content.btnModalConfirm.click(); + + await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible(); + await expect(poHomeChannel.content.getFileDescription).toHaveText('any_description'); + await expect(poHomeChannel.content.lastMessageFileName).toContainText('any_file1.txt'); + }); + }); + + test('File encryption with whitelisted and blacklisted media types', async ({ page, api }) => { + await test.step('create an encrypted room', async () => { + const channelName = faker.string.uuid(); + + await poHomeChannel.sidenav.openNewByLabel('Channel'); + await poHomeChannel.sidenav.inputChannelName.fill(channelName); + await poHomeChannel.sidenav.advancedSettingsAccordion.click(); + await poHomeChannel.sidenav.checkboxEncryption.click(); + await poHomeChannel.sidenav.btnCreate.click(); + + await expect(page).toHaveURL(`/group/${channelName}`); + + await poHomeChannel.dismissToast(); + + await expect(poHomeChannel.content.encryptedRoomHeaderIcon).toBeVisible(); + }); + + await test.step('send a text file in channel', async () => { + await poHomeChannel.content.dragAndDropTxtFile(); + await poHomeChannel.content.descriptionInput.fill('message 1'); + await poHomeChannel.content.fileNameInput.fill('any_file1.txt'); + await poHomeChannel.content.btnModalConfirm.click(); + + await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible(); + await expect(poHomeChannel.content.getFileDescription).toHaveText('message 1'); + await expect(poHomeChannel.content.lastMessageFileName).toContainText('any_file1.txt'); + }); + + await test.step('set whitelisted media type setting', async () => { + expect((await api.post('/settings/FileUpload_MediaTypeWhiteList', { value: 'text/plain' })).status()).toBe(200); + }); + + await test.step('send text file again with whitelist setting set', async () => { + await poHomeChannel.content.dragAndDropTxtFile(); + await poHomeChannel.content.descriptionInput.fill('message 2'); + await poHomeChannel.content.fileNameInput.fill('any_file2.txt'); + await poHomeChannel.content.btnModalConfirm.click(); + + await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible(); + await expect(poHomeChannel.content.getFileDescription).toHaveText('message 2'); + await expect(poHomeChannel.content.lastMessageFileName).toContainText('any_file2.txt'); + }); + + await test.step('set blacklisted media type setting to not accept application/octet-stream media type', async () => { + expect((await api.post('/settings/FileUpload_MediaTypeBlackList', { value: 'application/octet-stream' })).status()).toBe(200); + }); + + await test.step('send text file again with blacklisted setting set, file upload should fail', async () => { + await poHomeChannel.content.dragAndDropTxtFile(); + await poHomeChannel.content.descriptionInput.fill('message 3'); + await poHomeChannel.content.fileNameInput.fill('any_file3.txt'); + await poHomeChannel.content.btnModalConfirm.click(); + + await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible(); + await expect(poHomeChannel.content.getFileDescription).toHaveText('message 2'); + await expect(poHomeChannel.content.lastMessageFileName).toContainText('any_file2.txt'); + }); + }); + + test.describe('File encryption setting disabled', async () => { + test.beforeAll(async ({ api }) => { + expect((await api.post('/settings/E2E_Enable_Encrypt_Files', { value: false })).status()).toBe(200); + expect((await api.post('/settings/FileUpload_MediaTypeBlackList', { value: 'application/octet-stream' })).status()).toBe(200); + }); + + test.afterAll(async ({ api }) => { + expect((await api.post('/settings/E2E_Enable_Encrypt_Files', { value: true })).status()).toBe(200); + expect((await api.post('/settings/FileUpload_MediaTypeBlackList', { value: 'image/svg+xml' })).status()).toBe(200); + }); + + test('Upload file without encryption in e2ee room', async ({ page }) => { + await test.step('create an encrypted channel', async () => { + const channelName = faker.string.uuid(); + + await poHomeChannel.sidenav.openNewByLabel('Channel'); + await poHomeChannel.sidenav.inputChannelName.fill(channelName); + await poHomeChannel.sidenav.advancedSettingsAccordion.click(); + await poHomeChannel.sidenav.checkboxEncryption.click(); + await poHomeChannel.sidenav.btnCreate.click(); + + await expect(page).toHaveURL(`/group/${channelName}`); + + await poHomeChannel.dismissToast(); + + await expect(poHomeChannel.content.encryptedRoomHeaderIcon).toBeVisible(); + }); + + await test.step('send a test encrypted message to check e2ee is working', async () => { + await poHomeChannel.content.sendMessage('This is an encrypted message.'); + + await expect(poHomeChannel.content.lastUserMessageBody).toHaveText('This is an encrypted message.'); + await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible(); + }); + + await test.step('send a text file in channel, file should not be encrypted', async () => { + await poHomeChannel.content.dragAndDropTxtFile(); + await poHomeChannel.content.descriptionInput.fill('any_description'); + await poHomeChannel.content.fileNameInput.fill('any_file1.txt'); + await poHomeChannel.content.btnModalConfirm.click(); + + await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).not.toBeVisible(); + await expect(poHomeChannel.content.getFileDescription).toHaveText('any_description'); + await expect(poHomeChannel.content.lastMessageFileName).toContainText('any_file1.txt'); + }); + }); + }); + }); + test('expect slash commands to be enabled in an e2ee room', async ({ page }) => { const channelName = faker.string.uuid(); diff --git a/apps/meteor/tests/e2e/page-objects/fragments/admin-flextab-users.ts b/apps/meteor/tests/e2e/page-objects/fragments/admin-flextab-users.ts index 5b912be1fd02..0a9ccd3547c2 100644 --- a/apps/meteor/tests/e2e/page-objects/fragments/admin-flextab-users.ts +++ b/apps/meteor/tests/e2e/page-objects/fragments/admin-flextab-users.ts @@ -12,7 +12,7 @@ export class AdminFlextabUsers { } get btnSave(): Locator { - return this.page.locator('role=button[name="Save"]'); + return this.page.locator('role=button[name="Add user"]'); } get btnInvite(): Locator { @@ -31,12 +31,20 @@ export class AdminFlextabUsers { return this.page.locator('//label[text()="Email"]/following-sibling::span//input').first(); } + get inputSetManually(): Locator { + return this.page.locator('//label[text()="Set manually"]'); + } + get inputPassword(): Locator { - return this.page.locator('//label[text()="Password"]/following-sibling::span//input'); + return this.page.locator('input[placeholder="Password"]'); + } + + get inputConfirmPassword(): Locator { + return this.page.locator('input[placeholder="Confirm password"]'); } get checkboxVerified(): Locator { - return this.page.locator('//label[text()="Verified"]'); + return this.page.locator('//label[text()="Mark email as verified"]'); } get joinDefaultChannels(): Locator { @@ -55,4 +63,8 @@ export class AdminFlextabUsers { get setupSmtpLink(): Locator { return this.page.locator('role=link[name="Set up SMTP"]'); } + + get btnContextualbarClose(): Locator { + return this.page.locator('button[data-qa="ContextualbarActionClose"]'); + } } diff --git a/apps/meteor/tests/e2e/user-card-info-actions-by-member.spec.ts b/apps/meteor/tests/e2e/user-card-info-actions-by-member.spec.ts new file mode 100644 index 000000000000..b664f2c9ee69 --- /dev/null +++ b/apps/meteor/tests/e2e/user-card-info-actions-by-member.spec.ts @@ -0,0 +1,90 @@ +import { Users } from './fixtures/userStates'; +import { HomeChannel } from './page-objects'; +import { createTargetChannel, deleteChannel } from './utils'; +import { expect, test } from './utils/test'; + +test.use({ storageState: Users.user3.state }); + +test.describe.parallel('Mention User Card [To Member]', () => { + let poHomeChannel: HomeChannel; + let targetChannel: string; + + test.beforeAll(async ({ api }) => { + targetChannel = await createTargetChannel(api, { members: [Users.user1.data.username, Users.user3.data.username] }); + + await api.post(`/chat.postMessage`, { + text: `Hello @${Users.user1.data.username} @${Users.user2.data.username}`, + channel: targetChannel, + }); + }); + + test.beforeEach(async ({ page }) => { + poHomeChannel = new HomeChannel(page); + + await page.goto('/home'); + }); + + test.afterAll(({ api }) => deleteChannel(api, targetChannel)); + + test('should show correct userinfo actions for a member of the room to a non-privileged member', async ({ page }) => { + await poHomeChannel.sidenav.openChat(targetChannel); + const mentionSpan = page.locator(`span[title="Mentions user"][data-uid="${Users.user1.data.username}"]`); + await mentionSpan.click(); + + await expect(page.locator('div[aria-label="User card actions"]')).toBeVisible(); + const moreButton = await page.locator('div[aria-label="User card actions"] button[title="More"]'); + if (await moreButton.isVisible()) { + await moreButton.click(); + } + + const isAddToRoomVisible = + (await page.locator('button[title="Add to room"]').isVisible()) || (await page.locator('label[data-key="Add to room"]').isVisible()); + await expect(isAddToRoomVisible).toBeFalsy(); + + const isRemoveFromRoomVisible = + (await page.locator('button[title="Remove from room"]').isVisible()) || + (await page.locator('label[data-key="Remove from room"]').isVisible()); + await expect(isRemoveFromRoomVisible).toBeFalsy(); + + const isSetAsLeaderVisible = + (await page.locator('button[title="Set as leader"]').isVisible()) || + (await page.locator('label[data-key="Set as leader"]').isVisible()); + await expect(isSetAsLeaderVisible).toBeFalsy(); + + const isSetAsModeratorVisible = + (await page.locator('button[title="Set as moderator"]').isVisible()) || + (await page.locator('label[data-key="Set as moderator"]').isVisible()); + await expect(isSetAsModeratorVisible).toBeFalsy(); + }); + + test('should show correct userinfo actions for a non-member of the room to a non-privileged member', async ({ page }) => { + await poHomeChannel.sidenav.openChat(targetChannel); + const mentionSpan = page.locator(`span[title="Mentions user"][data-uid="${Users.user2.data.username}"]`); + await mentionSpan.click(); + + await expect(page.locator('div[aria-label="User card actions"]')).toBeVisible(); + const moreButton = await page.locator('div[aria-label="User card actions"] button[title="More"]'); + if (await moreButton.isVisible()) { + await moreButton.click(); + } + + const isAddToRoomVisible = + (await page.locator('button[title="Add to room"]').isVisible()) || (await page.locator('label[data-key="Add to room"]').isVisible()); + await expect(isAddToRoomVisible).toBeFalsy(); + + const isRemoveFromRoomVisible = + (await page.locator('button[title="Remove from room"]').isVisible()) || + (await page.locator('label[data-key="Remove from room"]').isVisible()); + await expect(isRemoveFromRoomVisible).toBeFalsy(); + + const isSetAsLeaderVisible = + (await page.locator('button[title="Set as leader"]').isVisible()) || + (await page.locator('label[data-key="Set as leader"]').isVisible()); + await expect(isSetAsLeaderVisible).toBeFalsy(); + + const isSetAsModeratorVisible = + (await page.locator('button[title="Set as moderator"]').isVisible()) || + (await page.locator('label[data-key="Set as moderator"]').isVisible()); + await expect(isSetAsModeratorVisible).toBeFalsy(); + }); +}); diff --git a/apps/meteor/tests/e2e/user-card-info-actions-by-room-owner.spec.ts b/apps/meteor/tests/e2e/user-card-info-actions-by-room-owner.spec.ts new file mode 100644 index 000000000000..808acbb79bbd --- /dev/null +++ b/apps/meteor/tests/e2e/user-card-info-actions-by-room-owner.spec.ts @@ -0,0 +1,90 @@ +import { Users } from './fixtures/userStates'; +import { HomeChannel } from './page-objects'; +import { createTargetChannel, deleteChannel } from './utils'; +import { expect, test } from './utils/test'; + +test.use({ storageState: Users.admin.state }); +test.describe.parallel('Mention User Card [To Room Owner]', () => { + let poHomeChannel: HomeChannel; + let targetChannel: string; + + test.beforeAll(async ({ api }) => { + targetChannel = await createTargetChannel(api, { members: [Users.user1.data.username] }); + + await api.post(`/chat.postMessage`, { + text: `Hello @${Users.user1.data.username} @${Users.user2.data.username}`, + channel: targetChannel, + }); + }); + + test.beforeEach(async ({ page }) => { + poHomeChannel = new HomeChannel(page); + + await page.goto('/home'); + }); + + test.afterAll(({ api }) => deleteChannel(api, targetChannel)); + + test('should show correct userinfo actions for a member of the room to the room owner', async ({ page }) => { + await poHomeChannel.sidenav.openChat(targetChannel); + const mentionSpan = page.locator(`span[title="Mentions user"][data-uid="${Users.user1.data.username}"]`); + await mentionSpan.click(); + + await expect(page.locator('div[aria-label="User card actions"]')).toBeVisible(); + const moreButton = await page.locator('div[aria-label="User card actions"] button[title="More"]'); + + if (await moreButton.isVisible()) { + await moreButton.click(); + } + + const isAddToRoomVisible = + (await page.locator('button[title="Add to room"]').isVisible()) || (await page.locator('label[data-key="Add to room"]').isVisible()); + await expect(isAddToRoomVisible).toBeFalsy(); + + const isRemoveFromRoomVisible = + (await page.locator('button[title="Remove from room"]').isVisible()) || + (await page.locator('label[data-key="Remove from room"]').isVisible()); + await expect(isRemoveFromRoomVisible).toBeTruthy(); + + const isSetAsLeaderVisible = + (await page.locator('button[title="Set as leader"]').isVisible()) || + (await page.locator('label[data-key="Set as leader"]').isVisible()); + await expect(isSetAsLeaderVisible).toBeTruthy(); + + const isSetAsModeratorVisible = + (await page.locator('button[title="Set as moderator"]').isVisible()) || + (await page.locator('label[data-key="Set as moderator"]').isVisible()); + await expect(isSetAsModeratorVisible).toBeTruthy(); + }); + + test('should show correct userinfo actions for a non-member of the room to the room owner', async ({ page }) => { + await poHomeChannel.sidenav.openChat(targetChannel); + const mentionSpan = page.locator(`span[title="Mentions user"][data-uid="${Users.user2.data.username}"]`); + await mentionSpan.click(); + + await expect(page.locator('div[aria-label="User card actions"]')).toBeVisible(); + const moreButton = await page.locator('div[aria-label="User card actions"] button[title="More"]'); + if (await moreButton.isVisible()) { + await moreButton.click(); + } + + const isAddToRoomVisible = + (await page.locator('button[title="Add to room"]').isVisible()) || (await page.locator('label[data-key="Add to room"]').isVisible()); + await expect(isAddToRoomVisible).toBeTruthy(); + + const isRemoveFromRoomVisible = + (await page.locator('button[title="Remove from room"]').isVisible()) || + (await page.locator('label[data-key="Remove from room"]').isVisible()); + await expect(isRemoveFromRoomVisible).toBeFalsy(); + + const isSetAsLeaderVisible = + (await page.locator('button[title="Set as leader"]').isVisible()) || + (await page.locator('label[data-key="Set as leader"]').isVisible()); + await expect(isSetAsLeaderVisible).toBeFalsy(); + + const isSetAsModeratorVisible = + (await page.locator('button[title="Set as moderator"]').isVisible()) || + (await page.locator('label[data-key="Set as moderator"]').isVisible()); + await expect(isSetAsModeratorVisible).toBeFalsy(); + }); +}); diff --git a/apps/meteor/tests/end-to-end/api/federation.ts b/apps/meteor/tests/end-to-end/api/federation.ts index 9d832d9fc1ac..a1bfd92f1d29 100644 --- a/apps/meteor/tests/end-to-end/api/federation.ts +++ b/apps/meteor/tests/end-to-end/api/federation.ts @@ -67,7 +67,7 @@ describe('federation', () => { .expect('Content-Type', 'application/json') .expect(200) .expect((res) => { - expect(res.body).to.have.property('m.homeserver', 'http://localhost'); + expect(res.body['m.homeserver']).to.have.property('base_url', 'http://localhost'); }); }); }); diff --git a/apps/meteor/tests/end-to-end/api/livechat/00-rooms.ts b/apps/meteor/tests/end-to-end/api/livechat/00-rooms.ts index 9f0427ec2d8e..f3ae205c7090 100644 --- a/apps/meteor/tests/end-to-end/api/livechat/00-rooms.ts +++ b/apps/meteor/tests/end-to-end/api/livechat/00-rooms.ts @@ -36,6 +36,7 @@ import { deleteVisitor, makeAgentUnavailable, sendAgentMessage, + fetchInquiry, } from '../../../data/livechat/rooms'; import { saveTags } from '../../../data/livechat/tags'; import type { DummyResponse } from '../../../data/livechat/utils'; @@ -2246,6 +2247,105 @@ describe('LIVECHAT - rooms', () => { await request.post(api('livechat/room.closeByUser')).set(credentials).send({ rid: _id, comment: 'test' }).expect(400); await deleteVisitor(visitor.token); }); + it('should fail one of the requests if 3 simultaneous closes are attempted', async () => { + const visitor = await createVisitor(); + const { _id } = await createLivechatRoom(visitor.token); + + const results = await Promise.allSettled([ + request.post(api('livechat/room.closeByUser')).set(credentials).send({ rid: _id, comment: 'test' }), + request.post(api('livechat/room.closeByUser')).set(credentials).send({ rid: _id, comment: 'test' }), + request.post(api('livechat/room.closeByUser')).set(credentials).send({ rid: _id, comment: 'test' }), + ]); + + const validResponse = results.filter((res) => (res as any).value.status === 200); + const invalidResponses = results.filter((res) => (res as any).value.status !== 200); + + expect(validResponse.length).to.equal(1); + expect(invalidResponses.length).to.equal(2); + // @ts-expect-error promise typings + expect(invalidResponses[0].value.body).to.have.property('success', false); + // @ts-expect-error promise typings + expect(invalidResponses[0].value.body).to.have.property('error'); + // The transaction is not consistent on the error apparently, sometimes it will reach the point of trying to close the inquiry and abort there (since another call already closed the room and finished) + // and sometimes it will abort because the transactions are still running and they're being locked. This is something i'm not liking but since tx should be retried we got this + // @ts-expect-error promise typings + expect(['error-room-cannot-be-closed-try-again', 'Error removing inquiry']).to.include(invalidResponses[0].value.body.error); + }); + + it('should allow different rooms to be closed simultaneously', async () => { + const visitor = await createVisitor(); + const { _id } = await createLivechatRoom(visitor.token); + + const visitor2 = await createVisitor(); + const { _id: _id2 } = await createLivechatRoom(visitor2.token); + + const results = await Promise.allSettled([ + request.post(api('livechat/room.closeByUser')).set(credentials).send({ rid: _id, comment: 'test' }), + request.post(api('livechat/room.closeByUser')).set(credentials).send({ rid: _id2, comment: 'test' }), + ]); + + const validResponse = results.filter((res) => (res as any).value.status === 200); + const invalidResponses = results.filter((res) => (res as any).value.status !== 200); + + expect(validResponse.length).to.equal(2); + expect(invalidResponses.length).to.equal(0); + }); + + it('when both user & visitor try to close room, only one will succeed (theres no guarantee who will win)', async () => { + const visitor = await createVisitor(); + const { _id } = await createLivechatRoom(visitor.token); + + const results = await Promise.allSettled([ + request.post(api('livechat/room.closeByUser')).set(credentials).send({ rid: _id, comment: 'test' }), + request.post(api('livechat/room.close')).set(credentials).send({ rid: _id, token: visitor.token }), + ]); + + const validResponse = results.filter((res) => (res as any).value.status === 200); + const invalidResponses = results.filter((res) => (res as any).value.status !== 200); + + // @ts-expect-error promise typings + const whoWon = validResponse[0].value.request.url.includes('closeByUser') ? 'user' : 'visitor'; + + expect(validResponse.length).to.equal(1); + expect(invalidResponses.length).to.equal(1); + // @ts-expect-error promise typings + expect(invalidResponses[0].value.body).to.have.property('success', false); + // This error indicates a conflict in the simultaneous close and that the request was rejected + // @ts-expect-error promise typings + expect(invalidResponses[0].value.body).to.have.property('error'); + // @ts-expect-error promise typings + expect(['error-room-cannot-be-closed-try-again', 'Error removing inquiry']).to.include(invalidResponses[0].value.body.error); + + const room = await getLivechatRoomInfo(_id); + + expect(room).to.not.have.property('open'); + expect(room).to.have.property('closer', whoWon); + }); + + it('when a close request is tried multiple times, the final state of the room should be valid', async () => { + const visitor = await createVisitor(); + const { _id } = await createLivechatRoom(visitor.token); + + await Promise.allSettled([ + request.post(api('livechat/room.closeByUser')).set(credentials).send({ rid: _id, comment: 'test' }), + request.post(api('livechat/room.closeByUser')).set(credentials).send({ rid: _id, comment: 'test' }), + request.post(api('livechat/room.closeByUser')).set(credentials).send({ rid: _id, comment: 'test' }), + ]); + + const room = await getLivechatRoomInfo(_id); + const inqForRoom = await fetchInquiry(_id); + const sub = await request + .get(api('subscriptions.getOne')) + .set(credentials) + .query({ roomId: _id }) + .expect('Content-Type', 'application/json'); + + expect(room).to.not.have.property('open'); + expect(room).to.have.property('closedAt'); + expect(room).to.have.property('closer', 'user'); + expect(inqForRoom).to.be.null; + expect(sub.body.subscription).to.be.null; + }); (IS_EE ? it : it.skip)('should close room and generate transcript pdf', async () => { const { diff --git a/apps/meteor/tests/end-to-end/api/livechat/04-dashboards.ts b/apps/meteor/tests/end-to-end/api/livechat/04-dashboards.ts index c0a559bbcba7..416e117d06e1 100644 --- a/apps/meteor/tests/end-to-end/api/livechat/04-dashboards.ts +++ b/apps/meteor/tests/end-to-end/api/livechat/04-dashboards.ts @@ -3,7 +3,7 @@ import type { Credentials } from '@rocket.chat/api-client'; import type { ILivechatDepartment, IUser } from '@rocket.chat/core-typings'; import { Random } from '@rocket.chat/random'; import { expect } from 'chai'; -import { before, describe, it } from 'mocha'; +import { before, after, describe, it } from 'mocha'; import moment from 'moment'; import type { Response } from 'supertest'; @@ -19,6 +19,7 @@ import { import { createAnOnlineAgent } from '../../../data/livechat/users'; import { sleep } from '../../../data/livechat/utils'; import { removePermissionFromAllRoles, restorePermissionToRoles, updateSetting } from '../../../data/permissions.helper'; +import { deleteUser } from '../../../data/users.helper'; import { IS_EE } from '../../../e2e/config/constants'; describe('LIVECHAT - dashboards', function () { @@ -775,6 +776,339 @@ describe('LIVECHAT - dashboards', function () { expect(user1Data).to.have.property('value', '28.57%'); expect(user2Data).to.have.property('value', '71.43%'); }); + (IS_EE ? it : it.skip)('should only return results in the provided date interval when searching for total conversations', async () => { + const yesterday = moment().subtract(1, 'days').format('YYYY-MM-DD'); + + const result = await request + .get(api('livechat/analytics/agent-overview')) + .query({ from: yesterday, to: yesterday, name: 'Total_conversations', departmentId: department._id }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(result.body).to.have.property('success', true); + expect(result.body).to.have.property('head'); + expect(result.body.head).to.be.an('array').with.lengthOf(2); + expect(result.body.head[0]).to.have.property('name', 'Agent'); + expect(result.body.head[1]).to.have.property('name', '%_of_conversations'); + expect(result.body).to.have.property('data'); + expect(result.body.data).to.be.an('array').that.is.empty; + }); + (IS_EE ? it : it.skip)( + 'should only return results in the provided date interval when searching for average chat durations', + async () => { + const yesterday = moment().subtract(1, 'days').format('YYYY-MM-DD'); + + const result = await request + .get(api('livechat/analytics/agent-overview')) + .query({ from: yesterday, to: yesterday, name: 'Avg_chat_duration', departmentId: department._id }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(result.body).to.have.property('success', true); + expect(result.body).to.have.property('head'); + expect(result.body.head).to.be.an('array').with.lengthOf(2); + expect(result.body.head[0]).to.have.property('name', 'Agent'); + expect(result.body.head[1]).to.have.property('name', 'Avg_chat_duration'); + expect(result.body).to.have.property('data'); + expect(result.body.data).to.be.an('array').that.is.empty; + }, + ); + (IS_EE ? it : it.skip)('should only return results in the provided date interval when searching for total messages', async () => { + const yesterday = moment().subtract(1, 'days').format('YYYY-MM-DD'); + + const result = await request + .get(api('livechat/analytics/agent-overview')) + .query({ from: yesterday, to: yesterday, name: 'Total_messages', departmentId: department._id }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(result.body).to.have.property('success', true); + expect(result.body).to.have.property('head'); + expect(result.body.head).to.be.an('array').with.lengthOf(2); + expect(result.body.head[0]).to.have.property('name', 'Agent'); + expect(result.body.head[1]).to.have.property('name', 'Total_messages'); + expect(result.body).to.have.property('data'); + expect(result.body.data).to.be.an('array').that.is.empty; + }); + (IS_EE ? it : it.skip)( + 'should only return results in the provided date interval when searching for average first response times', + async () => { + const yesterday = moment().subtract(1, 'days').format('YYYY-MM-DD'); + + const result = await request + .get(api('livechat/analytics/agent-overview')) + .query({ from: yesterday, to: yesterday, name: 'Avg_first_response_time', departmentId: department._id }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(result.body).to.have.property('success', true); + expect(result.body).to.have.property('head'); + expect(result.body.head).to.be.an('array').with.lengthOf(2); + expect(result.body.head[0]).to.have.property('name', 'Agent'); + expect(result.body.head[1]).to.have.property('name', 'Avg_first_response_time'); + expect(result.body).to.have.property('data'); + expect(result.body.data).to.be.an('array').that.is.empty; + }, + ); + (IS_EE ? it : it.skip)( + 'should only return results in the provided date interval when searching for best first response times', + async () => { + const yesterday = moment().subtract(1, 'days').format('YYYY-MM-DD'); + + const result = await request + .get(api('livechat/analytics/agent-overview')) + .query({ from: yesterday, to: yesterday, name: 'Best_first_response_time', departmentId: department._id }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(result.body).to.have.property('success', true); + expect(result.body).to.have.property('head'); + expect(result.body.head).to.be.an('array').with.lengthOf(2); + expect(result.body.head[0]).to.have.property('name', 'Agent'); + expect(result.body.head[1]).to.have.property('name', 'Best_first_response_time'); + expect(result.body).to.have.property('data'); + expect(result.body.data).to.be.an('array').that.is.empty; + }, + ); + (IS_EE ? it : it.skip)( + 'should only return results in the provided date interval when searching for average response times', + async () => { + const yesterday = moment().subtract(1, 'days').format('YYYY-MM-DD'); + + const result = await request + .get(api('livechat/analytics/agent-overview')) + .query({ from: yesterday, to: yesterday, name: 'Avg_response_time', departmentId: department._id }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(result.body).to.have.property('success', true); + expect(result.body).to.have.property('head'); + expect(result.body.head).to.be.an('array').with.lengthOf(2); + expect(result.body.head[0]).to.have.property('name', 'Agent'); + expect(result.body.head[1]).to.have.property('name', 'Avg_response_time'); + expect(result.body).to.have.property('data'); + expect(result.body.data).to.be.an('array').that.is.empty; + }, + ); + (IS_EE ? it : it.skip)( + 'should only return results in the provided date interval when searching for average reaction times', + async () => { + const yesterday = moment().subtract(1, 'days').format('YYYY-MM-DD'); + + const result = await request + .get(api('livechat/analytics/agent-overview')) + .query({ from: yesterday, to: yesterday, name: 'Avg_reaction_time', departmentId: department._id }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(result.body).to.have.property('success', true); + expect(result.body).to.have.property('head'); + expect(result.body.head).to.be.an('array').with.lengthOf(2); + expect(result.body.head[0]).to.have.property('name', 'Agent'); + expect(result.body.head[1]).to.have.property('name', 'Avg_reaction_time'); + expect(result.body).to.have.property('data'); + expect(result.body.data).to.be.an('array').that.is.empty; + }, + ); + }); + + describe('[livechat/analytics/agent-overview] - Average first response time', () => { + let agent: { credentials: Credentials; user: IUser & { username: string } }; + let originalFirstResponseTimeInSeconds: number; + let roomId: string; + const firstDelayInSeconds = 4; + const secondDelayInSeconds = 8; + + before(async () => { + agent = await createAnOnlineAgent(); + }); + + after(async () => { + await deleteUser(agent.user); + }); + + it('should return no average response time for an agent if no response has been sent in the period', async () => { + await startANewLivechatRoomAndTakeIt({ agent: agent.credentials }); + + const today = moment().startOf('day').format('YYYY-MM-DD'); + + const result = await request + .get(api('livechat/analytics/agent-overview')) + .query({ from: today, to: today, name: 'Avg_first_response_time' }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(result.body).to.have.property('success', true); + expect(result.body).to.have.property('head'); + expect(result.body).to.have.property('data'); + expect(result.body.data).to.be.an('array'); + expect(result.body.data).to.not.deep.include({ name: agent.user.username }); + }); + + it("should not consider system messages in agents' first response time metric", async () => { + const response = await startANewLivechatRoomAndTakeIt({ agent: agent.credentials }); + roomId = response.room._id; + + await sleep(firstDelayInSeconds * 1000); + await sendAgentMessage(roomId, 'first response from agent', agent.credentials); + + const today = moment().startOf('day').format('YYYY-MM-DD'); + const result = await request + .get(api('livechat/analytics/agent-overview')) + .query({ from: today, to: today, name: 'Avg_first_response_time' }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(result.body).to.have.property('success', true); + expect(result.body).to.have.property('head'); + expect(result.body).to.have.property('data'); + expect(result.body.data).to.be.an('array'); + + const agentData = result.body.data.find( + (agentOverviewData: { name: string; value: string }) => agentOverviewData.name === agent.user.username, + ); + expect(agentData).to.not.be.undefined; + expect(agentData).to.have.property('name', agent.user.username); + expect(agentData).to.have.property('value'); + originalFirstResponseTimeInSeconds = moment.duration(agentData.value).asSeconds(); + expect(originalFirstResponseTimeInSeconds).to.be.greaterThanOrEqual(firstDelayInSeconds); + }); + + it('should correctly calculate the average time of first responses for an agent', async () => { + const response = await startANewLivechatRoomAndTakeIt({ agent: agent.credentials }); + roomId = response.room._id; + + await sleep(secondDelayInSeconds * 1000); + await sendAgentMessage(roomId, 'first response from agent', agent.credentials); + + const today = moment().startOf('day').format('YYYY-MM-DD'); + const result = await request + .get(api('livechat/analytics/agent-overview')) + .query({ from: today, to: today, name: 'Avg_first_response_time' }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(result.body).to.have.property('success', true); + expect(result.body).to.have.property('head'); + expect(result.body).to.have.property('data'); + expect(result.body.data).to.be.an('array').that.is.not.empty; + + const agentData = result.body.data.find( + (agentOverviewData: { name: string; value: string }) => agentOverviewData.name === agent.user.username, + ); + expect(agentData).to.not.be.undefined; + expect(agentData).to.have.property('name', agent.user.username); + expect(agentData).to.have.property('value'); + const averageFirstResponseTimeInSeconds = moment.duration(agentData.value).asSeconds(); + expect(averageFirstResponseTimeInSeconds).to.be.greaterThan(originalFirstResponseTimeInSeconds); + expect(averageFirstResponseTimeInSeconds).to.be.greaterThanOrEqual((firstDelayInSeconds + secondDelayInSeconds) / 2); + expect(averageFirstResponseTimeInSeconds).to.be.lessThan(secondDelayInSeconds); + }); + }); + + describe('[livechat/analytics/agent-overview] - Best first response time', () => { + let agent: { credentials: Credentials; user: IUser & { username: string } }; + let originalBestFirstResponseTimeInSeconds: number; + let roomId: string; + + before(async () => { + agent = await createAnOnlineAgent(); + }); + + after(() => deleteUser(agent.user)); + + it('should return no best response time for an agent if no response has been sent in the period', async () => { + await startANewLivechatRoomAndTakeIt({ agent: agent.credentials }); + + const today = moment().startOf('day').format('YYYY-MM-DD'); + + const result = await request + .get(api('livechat/analytics/agent-overview')) + .query({ from: today, to: today, name: 'Best_first_response_time' }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(result.body).to.have.property('success', true); + expect(result.body).to.have.property('head'); + expect(result.body).to.have.property('data'); + expect(result.body.data).to.be.an('array'); + expect(result.body.data).to.not.deep.include({ name: agent.user.username }); + }); + + it("should not consider system messages in agents' best response time metric", async () => { + const response = await startANewLivechatRoomAndTakeIt({ agent: agent.credentials }); + roomId = response.room._id; + + const delayInSeconds = 4; + await sleep(delayInSeconds * 1000); + + await sendAgentMessage(roomId, 'first response from agent', agent.credentials); + + const today = moment().startOf('day').format('YYYY-MM-DD'); + const result = await request + .get(api('livechat/analytics/agent-overview')) + .query({ from: today, to: today, name: 'Best_first_response_time' }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(result.body).to.have.property('success', true); + expect(result.body).to.have.property('head'); + expect(result.body).to.have.property('data'); + expect(result.body.data).to.be.an('array').that.is.not.empty; + + const agentData = result.body.data.find( + (agentOverviewData: { name: string; value: string }) => agentOverviewData.name === agent.user.username, + ); + expect(agentData).to.not.be.undefined; + expect(agentData).to.have.property('name', agent.user.username); + expect(agentData).to.have.property('value'); + originalBestFirstResponseTimeInSeconds = moment.duration(agentData.value).asSeconds(); + expect(originalBestFirstResponseTimeInSeconds).to.be.greaterThanOrEqual(delayInSeconds); + }); + + it('should correctly calculate the best first response time for an agent and there are multiple first responses in the period', async () => { + const response = await startANewLivechatRoomAndTakeIt({ agent: agent.credentials }); + roomId = response.room._id; + + const delayInSeconds = 6; + await sleep(delayInSeconds * 1000); + + await sendAgentMessage(roomId, 'first response from agent', agent.credentials); + + const today = moment().startOf('day').format('YYYY-MM-DD'); + const result = await request + .get(api('livechat/analytics/agent-overview')) + .query({ from: today, to: today, name: 'Best_first_response_time' }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(result.body).to.have.property('success', true); + expect(result.body).to.have.property('head'); + expect(result.body).to.have.property('data'); + expect(result.body.data).to.be.an('array'); + + const agentData = result.body.data.find( + (agentOverviewData: { name: string; value: string }) => agentOverviewData.name === agent.user.username, + ); + expect(agentData).to.not.be.undefined; + expect(agentData).to.have.property('name', agent.user.username); + expect(agentData).to.have.property('value'); + const bestFirstResponseTimeInSeconds = moment.duration(agentData.value).asSeconds(); + expect(bestFirstResponseTimeInSeconds).to.be.equal(originalBestFirstResponseTimeInSeconds); + }); }); describe('livechat/analytics/overview', () => { @@ -835,12 +1169,12 @@ describe('LIVECHAT - dashboards', function () { expect(result.body).to.be.an('array'); const expectedResult = [ - { title: 'Total_conversations', value: 7 }, - { title: 'Open_conversations', value: 4 }, + { title: 'Total_conversations', value: 13 }, + { title: 'Open_conversations', value: 10 }, { title: 'On_Hold_conversations', value: 1 }, // { title: 'Total_messages', value: 6 }, // { title: 'Busiest_day', value: moment().format('dddd') }, - { title: 'Conversations_per_day', value: '3.50' }, + { title: 'Conversations_per_day', value: '6.50' }, // { title: 'Busiest_time', value: '' }, ]; @@ -857,5 +1191,60 @@ describe('LIVECHAT - dashboards', function () { const totalMessagesValue = parseInt(totalMessages.value); expect(totalMessagesValue).to.be.greaterThanOrEqual(minMessages); }); + (IS_EE ? it : it.skip)( + 'should only consider conversations in the provided time range when returning analytics conversations overview data', + async () => { + const yesterday = moment().subtract(1, 'days').format('YYYY-MM-DD'); + + const result = await request + .get(api('livechat/analytics/overview')) + .query({ from: yesterday, to: yesterday, name: 'Conversations', departmentId: department._id }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(result.body).to.be.an('array'); + + const expectedResult = [ + { title: 'Total_conversations', value: 0 }, + { title: 'Open_conversations', value: 0 }, + { title: 'On_Hold_conversations', value: 0 }, + { title: 'Conversations_per_day', value: '0.00' }, + ]; + + expectedResult.forEach((expected) => { + const resultItem = result.body.find((item: any) => item.title === expected.title); + expect(resultItem).to.not.be.undefined; + expect(resultItem).to.have.property('value', expected.value); + }); + }, + ); + (IS_EE ? it : it.skip)( + 'should only consider conversations in the provided time range when returning analytics productivity overview data', + async () => { + const yesterday = moment().subtract(1, 'days').format('YYYY-MM-DD'); + + const result = await request + .get(api('livechat/analytics/overview')) + .query({ from: yesterday, to: yesterday, name: 'Productivity', departmentId: department._id }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(result.body).to.be.an('array'); + + const expectedResult = [ + { title: 'Avg_response_time', value: '00:00:00' }, + { title: 'Avg_first_response_time', value: '00:00:00' }, + { title: 'Avg_reaction_time', value: '00:00:00' }, + ]; + + expectedResult.forEach((expected) => { + const resultItem = result.body.find((item: any) => item.title === expected.title); + expect(resultItem).to.not.be.undefined; + expect(resultItem).to.have.property('value', expected.value); + }); + }, + ); }); }); diff --git a/apps/meteor/tests/end-to-end/api/livechat/contacts.ts b/apps/meteor/tests/end-to-end/api/livechat/contacts.ts new file mode 100644 index 000000000000..21eced5ee7e9 --- /dev/null +++ b/apps/meteor/tests/end-to-end/api/livechat/contacts.ts @@ -0,0 +1,299 @@ +import { faker } from '@faker-js/faker'; +import { expect } from 'chai'; +import { before, after, describe, it } from 'mocha'; + +import { getCredentials, api, request, credentials } from '../../../data/api-data'; +import { createCustomField, deleteCustomField } from '../../../data/livechat/custom-fields'; +import { createAgent } from '../../../data/livechat/rooms'; +import { removePermissionFromAllRoles, restorePermissionToRoles, updatePermission, updateSetting } from '../../../data/permissions.helper'; +import { createUser, deleteUser } from '../../../data/users.helper'; + +describe('LIVECHAT - contacts', () => { + before((done) => getCredentials(done)); + + before(async () => { + await updateSetting('Livechat_enabled', true); + await updatePermission('create-livechat-contact', ['admin']); + }); + + after(async () => { + await restorePermissionToRoles('create-livechat-contact'); + await updateSetting('Livechat_enabled', true); + }); + + describe('[POST] omnichannel/contacts', () => { + it('should be able to create a new contact', async () => { + const res = await request + .post(api('omnichannel/contacts')) + .set(credentials) + .send({ + name: faker.person.fullName(), + emails: [faker.internet.email().toLowerCase()], + phones: [faker.phone.number()], + }); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('contactId'); + expect(res.body.contactId).to.be.an('string'); + }); + + it("should return an error if user doesn't have 'create-livechat-contact' permission", async () => { + await removePermissionFromAllRoles('create-livechat-contact'); + + const res = await request + .post(api('omnichannel/contacts')) + .set(credentials) + .send({ + name: faker.person.fullName(), + emails: [faker.internet.email().toLowerCase()], + phones: [faker.phone.number()], + }); + + expect(res.body).to.have.property('success', false); + expect(res.body.error).to.be.equal('User does not have the permissions required for this action [error-unauthorized]'); + + await restorePermissionToRoles('create-livechat-contact'); + }); + + it('should return an error if contact manager not exists', async () => { + const res = await request + .post(api('omnichannel/contacts')) + .set(credentials) + .send({ + name: faker.person.fullName(), + emails: [faker.internet.email().toLowerCase()], + phones: [faker.phone.number()], + contactManager: 'invalid', + }); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + expect(res.body.error).to.be.equal('error-contact-manager-not-found'); + }); + + it('should return an error if contact manager is not a livechat-agent', async () => { + const normalUser = await createUser(); + + const res = await request + .post(api('omnichannel/contacts')) + .set(credentials) + .send({ + name: faker.person.fullName(), + emails: [faker.internet.email().toLowerCase()], + phones: [faker.phone.number()], + contactManager: normalUser._id, + }); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + expect(res.body.error).to.be.equal('error-contact-manager-not-found'); + + await deleteUser(normalUser); + }); + + it('should be able to create a new contact with a contact manager', async () => { + const user = await createUser(); + const livechatAgent = await createAgent(user.username); + + const res = await request + .post(api('omnichannel/contacts')) + .set(credentials) + .send({ + name: faker.person.fullName(), + emails: [faker.internet.email().toLowerCase()], + phones: [faker.phone.number()], + contactManager: livechatAgent._id, + }); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('contactId'); + expect(res.body.contactId).to.be.an('string'); + + await deleteUser(user); + }); + + describe('Custom Fields', () => { + before(async () => { + await createCustomField({ + field: 'cf1', + label: 'Custom Field 1', + scope: 'visitor', + visibility: 'public', + type: 'input', + required: true, + regexp: '^[0-9]+$', + searchable: true, + public: true, + }); + }); + + after(async () => { + await deleteCustomField('cf1'); + }); + + it('should validate custom fields correctly', async () => { + const res = await request + .post(api('omnichannel/contacts')) + .set(credentials) + .send({ + name: faker.person.fullName(), + emails: [faker.internet.email().toLowerCase()], + phones: [faker.phone.number()], + customFields: { + cf1: '123', + }, + }); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('contactId'); + expect(res.body.contactId).to.be.an('string'); + }); + + it('should return an error for missing required custom field', async () => { + const res = await request + .post(api('omnichannel/contacts')) + .set(credentials) + .send({ + name: faker.person.fullName(), + emails: [faker.internet.email().toLowerCase()], + phones: [faker.phone.number()], + customFields: {}, + }); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + expect(res.body.error).to.be.equal('Invalid value for Custom Field 1 field'); + }); + + it('should return an error for invalid custom field value', async () => { + const res = await request + .post(api('omnichannel/contacts')) + .set(credentials) + .send({ + name: faker.person.fullName(), + emails: [faker.internet.email().toLowerCase()], + phones: [faker.phone.number()], + customFields: { + cf1: 'invalid', + }, + }); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + expect(res.body.error).to.be.equal('Invalid value for Custom Field 1 field'); + }); + }); + + describe('Fields Validation', () => { + it('should return an error if name is missing', async () => { + const res = await request + .post(api('omnichannel/contacts')) + .set(credentials) + .send({ + emails: [faker.internet.email().toLowerCase()], + phones: [faker.phone.number()], + }); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + expect(res.body.error).to.be.equal("must have required property 'name' [invalid-params]"); + expect(res.body.errorType).to.be.equal('invalid-params'); + }); + + it('should return an error if emails is missing', async () => { + const res = await request + .post(api('omnichannel/contacts')) + .set(credentials) + .send({ + name: faker.person.fullName(), + phones: [faker.phone.number()], + }); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + expect(res.body.error).to.be.equal("must have required property 'emails' [invalid-params]"); + expect(res.body.errorType).to.be.equal('invalid-params'); + }); + + it('should return an error if phones is missing', async () => { + const res = await request + .post(api('omnichannel/contacts')) + .set(credentials) + .send({ + name: faker.person.fullName(), + emails: [faker.internet.email().toLowerCase()], + }); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + expect(res.body.error).to.be.equal("must have required property 'phones' [invalid-params]"); + expect(res.body.errorType).to.be.equal('invalid-params'); + }); + + it('should return an error if emails is not an array', async () => { + const res = await request + .post(api('omnichannel/contacts')) + .set(credentials) + .send({ + name: faker.person.fullName(), + emails: 'invalid', + phones: [faker.phone.number()], + }); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + expect(res.body.error).to.be.equal('must be array [invalid-params]'); + expect(res.body.errorType).to.be.equal('invalid-params'); + }); + + it('should return an error if emails is not an array of strings', async () => { + const res = await request + .post(api('omnichannel/contacts')) + .set(credentials) + .send({ + name: faker.person.fullName(), + emails: [{ invalid: true }], + phones: [faker.phone.number()], + }); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + expect(res.body.error).to.be.equal('must be string [invalid-params]'); + expect(res.body.errorType).to.be.equal('invalid-params'); + }); + + it('should return an error if phones is not an array of strings', async () => { + const res = await request + .post(api('omnichannel/contacts')) + .set(credentials) + .send({ + name: faker.person.fullName(), + emails: [faker.internet.email().toLowerCase()], + phones: [{ invalid: true }], + }); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + expect(res.body.error).to.be.equal('must be string [invalid-params]'); + expect(res.body.errorType).to.be.equal('invalid-params'); + }); + + it('should return an error if additional fields are provided', async () => { + const res = await request + .post(api('omnichannel/contacts')) + .set(credentials) + .send({ + name: faker.person.fullName(), + emails: [faker.internet.email().toLowerCase()], + phones: [faker.phone.number()], + additional: 'invalid', + }); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + expect(res.body.error).to.be.equal('must NOT have additional properties [invalid-params]'); + expect(res.body.errorType).to.be.equal('invalid-params'); + }); + }); + }); +}); diff --git a/apps/meteor/tests/end-to-end/api/oauthapps.ts b/apps/meteor/tests/end-to-end/api/oauthapps.ts index 7bffa3297bfc..db714d1107bd 100644 --- a/apps/meteor/tests/end-to-end/api/oauthapps.ts +++ b/apps/meteor/tests/end-to-end/api/oauthapps.ts @@ -51,8 +51,11 @@ describe('[OAuthApps]', () => { }); describe('[/oauth-apps.get]', () => { - it('should return a single oauthApp by id', (done) => { - void request + before(() => updatePermission('manage-oauth-apps', ['admin'])); + after(() => updatePermission('manage-oauth-apps', ['admin'])); + + it('should return a single oauthApp by id', () => { + return request .get(api('oauth-apps.get')) .query({ appId: 'zapier' }) .set(credentials) @@ -61,11 +64,11 @@ describe('[OAuthApps]', () => { expect(res.body).to.have.property('success', true); expect(res.body).to.have.property('oauthApp'); expect(res.body.oauthApp._id).to.be.equal('zapier'); - }) - .end(done); + expect(res.body.oauthApp).to.have.property('clientSecret'); + }); }); - it('should return a single oauthApp by client id', (done) => { - void request + it('should return a single oauthApp by client id', () => { + return request .get(api('oauth-apps.get')) .query({ clientId: 'zapier' }) .set(credentials) @@ -74,36 +77,49 @@ describe('[OAuthApps]', () => { expect(res.body).to.have.property('success', true); expect(res.body).to.have.property('oauthApp'); expect(res.body.oauthApp._id).to.be.equal('zapier'); - }) - .end(done); + expect(res.body.oauthApp).to.have.property('clientSecret'); + }); }); - it('should return a 403 Forbidden error when the user does not have the necessary permission by client id', (done) => { - void updatePermission('manage-oauth-apps', []).then(() => { - void request - .get(api('oauth-apps.get')) - .query({ clientId: 'zapier' }) - .set(credentials) - .expect(403) - .expect((res) => { - expect(res.body).to.have.property('success', false); - expect(res.body.error).to.be.equal('unauthorized'); - }) - .end(done); - }); + it('should return only non sensitive information if user does not have the permission to manage oauth apps when searching by clientId', async () => { + await updatePermission('manage-oauth-apps', []); + await request + .get(api('oauth-apps.get')) + .query({ clientId: 'zapier' }) + .set(credentials) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('oauthApp'); + expect(res.body.oauthApp._id).to.be.equal('zapier'); + expect(res.body.oauthApp.clientId).to.be.equal('zapier'); + expect(res.body.oauthApp).to.not.have.property('clientSecret'); + }); }); - it('should return a 403 Forbidden error when the user does not have the necessary permission by app id', (done) => { - void updatePermission('manage-oauth-apps', []).then(() => { - void request - .get(api('oauth-apps.get')) - .query({ appId: 'zapier' }) - .set(credentials) - .expect(403) - .expect((res) => { - expect(res.body).to.have.property('success', false); - expect(res.body.error).to.be.equal('unauthorized'); - }) - .end(done); - }); + it('should return only non sensitive information if user does not have the permission to manage oauth apps when searching by appId', async () => { + await updatePermission('manage-oauth-apps', []); + await request + .get(api('oauth-apps.get')) + .query({ appId: 'zapier' }) + .set(credentials) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('oauthApp'); + expect(res.body.oauthApp._id).to.be.equal('zapier'); + expect(res.body.oauthApp.clientId).to.be.equal('zapier'); + expect(res.body.oauthApp).to.not.have.property('clientSecret'); + }); + }); + it('should fail returning an oauth app when an invalid id is provided (avoid NoSQL injections)', () => { + return request + .get(api('oauth-apps.get')) + .query({ _id: '{ "$ne": "" }' }) + .set(credentials) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'OAuth app not found.'); + }); }); }); diff --git a/apps/meteor/tests/end-to-end/api/rooms.ts b/apps/meteor/tests/end-to-end/api/rooms.ts index fa5878cc3c01..981388c416a9 100644 --- a/apps/meteor/tests/end-to-end/api/rooms.ts +++ b/apps/meteor/tests/end-to-end/api/rooms.ts @@ -418,6 +418,7 @@ describe('[Rooms]', () => { .filter((type) => type !== 'image/svg+xml') .join(','); await updateSetting('FileUpload_MediaTypeBlackList', newBlockedMediaTypes); + await updateSetting('E2E_Enable_Encrypt_Files', true); }); after(() => @@ -427,6 +428,7 @@ describe('[Rooms]', () => { updateSetting('FileUpload_Restrict_to_room_members', true), updateSetting('FileUpload_ProtectFiles', true), updateSetting('FileUpload_MediaTypeBlackList', blockedMediaTypes), + updateSetting('E2E_Enable_Encrypt_Files', true), ]), ); @@ -708,6 +710,78 @@ describe('[Rooms]', () => { expect(res.body.message.attachments[0]).to.have.property('description', 'some_file_description'); }); }); + + it('should correctly save encrypted file', async () => { + let fileId; + + await request + .post(api(`rooms.media/${testChannel._id}`)) + .set(credentials) + .attach('file', fs.createReadStream(path.join(__dirname, '../../mocks/files/diagram.drawio')), { + contentType: 'application/octet-stream', + }) + .field({ content: JSON.stringify({ algorithm: 'rc.v1.aes-sha2', ciphertext: 'something' }) }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('file'); + expect(res.body.file).to.have.property('_id'); + expect(res.body.file).to.have.property('url'); + + fileId = res.body.file._id; + }); + + await request + .post(api(`rooms.mediaConfirm/${testChannel._id}/${fileId}`)) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('message'); + expect(res.body.message).to.have.property('files'); + expect(res.body.message.files).to.be.an('array').of.length(1); + expect(res.body.message.files[0]).to.have.property('type', 'application/octet-stream'); + expect(res.body.message.files[0]).to.have.property('name', 'diagram.drawio'); + }); + }); + + it('should fail encrypted file upload when files encryption is disabled', async () => { + await updateSetting('E2E_Enable_Encrypt_Files', false); + + await request + .post(api(`rooms.media/${testChannel._id}`)) + .set(credentials) + .attach('file', fs.createReadStream(path.join(__dirname, '../../mocks/files/diagram.drawio')), { + contentType: 'application/octet-stream', + }) + .field({ content: JSON.stringify({ algorithm: 'rc.v1.aes-sha2', ciphertext: 'something' }) }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-invalid-file-type'); + }); + }); + + it('should fail encrypted file upload on blacklisted application/octet-stream media type', async () => { + await updateSetting('FileUpload_MediaTypeBlackList', 'application/octet-stream'); + + await request + .post(api(`rooms.media/${testChannel._id}`)) + .set(credentials) + .attach('file', fs.createReadStream(path.join(__dirname, '../../mocks/files/diagram.drawio')), { + contentType: 'application/octet-stream', + }) + .field({ content: JSON.stringify({ algorithm: 'rc.v1.aes-sha2', ciphertext: 'something' }) }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-invalid-file-type'); + }); + }); }); describe('/rooms.favorite', () => { @@ -2958,4 +3032,286 @@ describe('[Rooms]', () => { }); }); }); + describe('/rooms.isMember', () => { + let testChannel: IRoom; + let testGroup: IRoom; + let testDM: IRoom; + + const fakeRoomId = `room.test.${Date.now()}-${Math.random()}`; + const fakeUserId = `user.test.${Date.now()}-${Math.random()}`; + + const testChannelName = `channel.test.${Date.now()}-${Math.random()}`; + const testGroupName = `group.test.${Date.now()}-${Math.random()}`; + + let testUser1: TestUser; + let testUser2: TestUser; + let testUserNonMember: TestUser; + let testUser1Credentials: Credentials; + let testUserNonMemberCredentials: Credentials; + + before(async () => { + testUser1 = await createUser(); + testUser1Credentials = await login(testUser1.username, password); + }); + + before(async () => { + testUser2 = await createUser(); + }); + + before(async () => { + testUserNonMember = await createUser(); + testUserNonMemberCredentials = await login(testUserNonMember.username, password); + }); + + before(async () => { + const response = await createRoom({ + type: 'c', + name: testChannelName, + members: [testUser1.username, testUser2.username], + }); + testChannel = response.body.channel; + }); + + before(async () => { + const response = await createRoom({ + type: 'p', + name: testGroupName, + members: [testUser1.username, testUser2.username], + }); + testGroup = response.body.group; + }); + + before(async () => { + const response = await createRoom({ + type: 'd', + username: testUser2.username, + credentials: testUser1Credentials, + }); + testDM = response.body.room; + }); + + after(() => + Promise.all([ + deleteRoom({ type: 'c', roomId: testChannel._id }), + deleteRoom({ type: 'p', roomId: testGroup._id }), + deleteRoom({ type: 'd', roomId: testDM._id }), + deleteUser(testUser1), + deleteUser(testUser2), + deleteUser(testUserNonMember), + ]), + ); + + it('should return error if room not found', () => { + return request + .get(api('rooms.isMember')) + .set(testUser1Credentials) + .query({ + roomId: fakeRoomId, + userId: testUser1._id, + }) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property( + 'error', + 'The required "roomId" or "roomName" param provided does not match any channel [error-room-not-found]', + ); + }); + }); + + it('should return error if user not found with the given userId', () => { + return request + .get(api('rooms.isMember')) + .set(testUser1Credentials) + .query({ + roomId: testChannel._id, + userId: fakeUserId, + }) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'error-user-not-found'); + }); + }); + + it('should return error if user not found with the given username', () => { + return request + .get(api('rooms.isMember')) + .set(testUser1Credentials) + .query({ + roomId: testChannel._id, + username: fakeUserId, + }) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'error-user-not-found'); + }); + }); + + it('should return success with isMember=true if given userId is a member of the channel', () => { + return request + .get(api('rooms.isMember')) + .set(testUser1Credentials) + .query({ + roomId: testChannel._id, + userId: testUser2._id, + }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('isMember', true); + }); + }); + + it('should return success with isMember=true if given username is a member of the channel', () => { + return request + .get(api('rooms.isMember')) + .set(testUser1Credentials) + .query({ + roomId: testChannel._id, + username: testUser2.username, + }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('isMember', true); + }); + }); + + it('should return success with isMember=false if user is not a member of the channel', () => { + return request + .get(api('rooms.isMember')) + .set(testUser1Credentials) + .query({ + roomId: testChannel._id, + userId: testUserNonMember._id, + }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('isMember', false); + }); + }); + + it('should return success with isMember=true if given userId is a member of the group', () => { + return request + .get(api('rooms.isMember')) + .set(testUser1Credentials) + .query({ + roomId: testGroup._id, + userId: testUser2._id, + }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('isMember', true); + }); + }); + + it('should return success with isMember=true if given username is a member of the group', () => { + return request + .get(api('rooms.isMember')) + .set(testUser1Credentials) + .query({ + roomId: testGroup._id, + username: testUser2.username, + }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('isMember', true); + }); + }); + + it('should return success with isMember=false if user is not a member of the group', () => { + return request + .get(api('rooms.isMember')) + .set(testUser1Credentials) + .query({ + roomId: testGroup._id, + userId: testUserNonMember._id, + }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('isMember', false); + }); + }); + + it('should return unauthorized if caller cannot access the group', () => { + return request + .get(api('rooms.isMember')) + .set(testUserNonMemberCredentials) + .query({ + roomId: testGroup._id, + userId: testUser1._id, + }) + .expect(403) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'unauthorized'); + }); + }); + + it('should return success with isMember=true if given userId is a member of the DM', () => { + return request + .get(api('rooms.isMember')) + .set(testUser1Credentials) + .query({ + roomId: testDM._id, + userId: testUser2._id, + }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('isMember', true); + }); + }); + + it('should return success with isMember=true if given username is a member of the DM', () => { + return request + .get(api('rooms.isMember')) + .set(testUser1Credentials) + .query({ + roomId: testDM._id, + username: testUser2.username, + }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('isMember', true); + }); + }); + + it('should return success with isMember=false if user is not a member of the DM', () => { + return request + .get(api('rooms.isMember')) + .set(testUser1Credentials) + .query({ + roomId: testDM._id, + userId: testUserNonMember._id, + }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('isMember', false); + }); + }); + + it('should return unauthorized if caller cannot access the DM', () => { + return request + .get(api('rooms.isMember')) + .set(testUserNonMemberCredentials) + .query({ + roomId: testDM._id, + userId: testUser1._id, + }) + .expect(403) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'unauthorized'); + }); + }); + }); }); diff --git a/apps/meteor/tests/end-to-end/api/teams.ts b/apps/meteor/tests/end-to-end/api/teams.ts index 425d0039e502..ca07d3e32679 100644 --- a/apps/meteor/tests/end-to-end/api/teams.ts +++ b/apps/meteor/tests/end-to-end/api/teams.ts @@ -47,9 +47,16 @@ describe('[Teams]', () => { before(async () => { testUser = await createUser(); + await updateSetting('UTF8_Channel_Names_Validation', '[0-9a-zA-Z-_.]+'); }); - after(() => Promise.all([...createdTeams.map((team) => deleteTeam(credentials, team.name)), deleteUser(testUser)])); + after(async () => { + await Promise.all([ + ...createdTeams.map((team) => deleteTeam(credentials, team.name)), + deleteUser(testUser), + updateSetting('UTF8_Channel_Names_Validation', '[0-9a-zA-Z-_.]+'), + ]); + }); it('should create a public team', (done) => { void request @@ -250,6 +257,51 @@ describe('[Teams]', () => { .expect('Content-Type', 'application/json') .expect(400); }); + + it('should not create a team with no associated room', async () => { + const teamName = 'invalid*team*name'; + + await request + .post(api('teams.create')) + .set(credentials) + .send({ + name: teamName, + type: 0, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((response) => { + expect(response.body).to.have.property('success', false); + expect(response.body).to.have.property('error'); + expect(response.body.error).to.be.equal('error-team-creation'); + }); + + await request + .get(api('teams.info')) + .set(credentials) + .query({ + teamName, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((response) => { + expect(response.body).to.have.property('success', false); + expect(response.body).to.have.property('error', 'Team not found'); + }); + + await request + .get(api('teams.members')) + .set(credentials) + .query({ + teamName, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((response) => { + expect(response.body).to.have.property('success', false); + expect(response.body).to.have.property('error', 'team-does-not-exist'); + }); + }); }); describe('/teams.convertToChannel', () => { diff --git a/apps/meteor/tests/end-to-end/api/users.ts b/apps/meteor/tests/end-to-end/api/users.ts index d6112dd2416b..e908baebd974 100644 --- a/apps/meteor/tests/end-to-end/api/users.ts +++ b/apps/meteor/tests/end-to-end/api/users.ts @@ -605,6 +605,25 @@ describe('[Users]', () => { }) .end(done); }); + + it('should return an error when trying register new user with an invalid username', (done) => { + void request + .post(api('users.register')) + .send({ + email, + name: 'name', + username: 'test$username<>', + pass: 'test', + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error').and.to.be.equal('The username provided is not valid'); + }) + .end(done); + }); + it('should return an error when trying register new user with an existing username', (done) => { void request .post(api('users.register')) @@ -3700,9 +3719,9 @@ describe('[Users]', () => { it('should invalidate all active sesions', (done) => { /* We want to validate that the login with the "old" credentials fails - However, the removal of the tokens is done asynchronously. - Thus, we check that within the next seconds, at least one try to - access an authentication requiring route fails */ + However, the removal of the tokens is done asynchronously. + Thus, we check that within the next seconds, at least one try to + access an authentication requiring route fails */ let counter = 0; async function checkAuthenticationFails() { @@ -4060,9 +4079,9 @@ describe('[Users]', () => { it('should invalidate all active sesions', (done) => { /* We want to validate that the login with the "old" credentials fails - However, the removal of the tokens is done asynchronously. - Thus, we check that within the next seconds, at least one try to - access an authentication requiring route fails */ + However, the removal of the tokens is done asynchronously. + Thus, we check that within the next seconds, at least one try to + access an authentication requiring route fails */ let counter = 0; async function checkAuthenticationFails() { diff --git a/apps/meteor/tests/mocks/files/diagram.drawio b/apps/meteor/tests/mocks/files/diagram.drawio new file mode 100644 index 000000000000..a86c2673ab98 --- /dev/null +++ b/apps/meteor/tests/mocks/files/diagram.drawio @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/apps/meteor/tests/unit/app/lib/server/functions/closeLivechatRoom.tests.ts b/apps/meteor/tests/unit/app/lib/server/functions/closeLivechatRoom.tests.ts index 07ee437832d2..b40b971128bc 100644 --- a/apps/meteor/tests/unit/app/lib/server/functions/closeLivechatRoom.tests.ts +++ b/apps/meteor/tests/unit/app/lib/server/functions/closeLivechatRoom.tests.ts @@ -73,7 +73,7 @@ describe('closeLivechatRoom', () => { it('should not perform any operation when a closed room with no subscriptions is provided and the caller is not subscribed to it', async () => { livechatRoomsStub.findOneById.resolves({ ...room, open: false }); - subscriptionsStub.countByRoomId.resolves(0); + subscriptionsStub.removeByRoomId.resolves({ deletedCount: 0 }); subscriptionsStub.findOneByRoomIdAndUserId.resolves(null); hasPermissionStub.resolves(true); @@ -81,13 +81,12 @@ describe('closeLivechatRoom', () => { expect(livechatStub.closeRoom.notCalled).to.be.true; expect(livechatRoomsStub.findOneById.calledOnceWith(room._id)).to.be.true; expect(subscriptionsStub.findOneByRoomIdAndUserId.notCalled).to.be.true; - expect(subscriptionsStub.countByRoomId.calledOnceWith(room._id)).to.be.true; - expect(subscriptionsStub.removeByRoomId.notCalled).to.be.true; + expect(subscriptionsStub.removeByRoomId.calledOnceWith(room._id)).to.be.true; }); it('should remove dangling subscription when a closed room with subscriptions is provided and the caller is not subscribed to it', async () => { livechatRoomsStub.findOneById.resolves({ ...room, open: false }); - subscriptionsStub.countByRoomId.resolves(1); + subscriptionsStub.removeByRoomId.resolves({ deletedCount: 1 }); subscriptionsStub.findOneByRoomIdAndUserId.resolves(null); hasPermissionStub.resolves(true); @@ -95,28 +94,25 @@ describe('closeLivechatRoom', () => { expect(livechatStub.closeRoom.notCalled).to.be.true; expect(livechatRoomsStub.findOneById.calledOnceWith(room._id)).to.be.true; expect(subscriptionsStub.findOneByRoomIdAndUserId.notCalled).to.be.true; - expect(subscriptionsStub.countByRoomId.calledOnceWith(room._id)).to.be.true; expect(subscriptionsStub.removeByRoomId.calledOnceWith(room._id)).to.be.true; }); it('should remove dangling subscription when a closed room is provided but the user is still subscribed to it', async () => { livechatRoomsStub.findOneById.resolves({ ...room, open: false }); subscriptionsStub.findOneByRoomIdAndUserId.resolves(subscription); - subscriptionsStub.countByRoomId.resolves(1); + subscriptionsStub.removeByRoomId.resolves({ deletedCount: 1 }); hasPermissionStub.resolves(true); await closeLivechatRoom(user, room._id, {}); expect(livechatStub.closeRoom.notCalled).to.be.true; expect(livechatRoomsStub.findOneById.calledOnceWith(room._id)).to.be.true; expect(subscriptionsStub.findOneByRoomIdAndUserId.notCalled).to.be.true; - expect(subscriptionsStub.countByRoomId.calledOnceWith(room._id)).to.be.true; expect(subscriptionsStub.removeByRoomId.calledOnceWith(room._id)).to.be.true; }); it('should not perform any operation when the caller is not subscribed to an open room and does not have the permission to close others rooms', async () => { livechatRoomsStub.findOneById.resolves(room); subscriptionsStub.findOneByRoomIdAndUserId.resolves(null); - subscriptionsStub.countByRoomId.resolves(1); hasPermissionStub.resolves(false); await expect(closeLivechatRoom(user, room._id, {})).to.be.rejectedWith('error-not-authorized'); @@ -129,7 +125,6 @@ describe('closeLivechatRoom', () => { it('should close the room when the caller is not subscribed to it but has the permission to close others rooms', async () => { livechatRoomsStub.findOneById.resolves(room); subscriptionsStub.findOneByRoomIdAndUserId.resolves(null); - subscriptionsStub.countByRoomId.resolves(1); hasPermissionStub.resolves(true); await closeLivechatRoom(user, room._id, {}); @@ -142,7 +137,6 @@ describe('closeLivechatRoom', () => { it('should close the room when the caller is subscribed to it and does not have the permission to close others rooms', async () => { livechatRoomsStub.findOneById.resolves(room); subscriptionsStub.findOneByRoomIdAndUserId.resolves(subscription); - subscriptionsStub.countByRoomId.resolves(1); hasPermissionStub.resolves(false); await closeLivechatRoom(user, room._id, {}); diff --git a/apps/meteor/tests/unit/app/lib/server/functions/validateUsername.spec.ts b/apps/meteor/tests/unit/app/lib/server/functions/validateUsername.spec.ts new file mode 100644 index 000000000000..647873b8ffbd --- /dev/null +++ b/apps/meteor/tests/unit/app/lib/server/functions/validateUsername.spec.ts @@ -0,0 +1,94 @@ +import { expect } from 'chai'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +describe('validateUsername', () => { + const getStub = sinon.stub(); + + const proxySettings = { + settings: { + get: getStub, + }, + }; + + const { validateUsername } = proxyquire.noCallThru().load('../../../../../../app/lib/server/functions/validateUsername', { + '../../../settings/server': proxySettings, + }); + + beforeEach(() => { + getStub.reset(); + }); + + afterEach(() => { + sinon.restore(); + }); + + describe('with default settings', () => { + beforeEach(() => { + getStub.withArgs('UTF8_User_Names_Validation').returns('[0-9a-zA-Z-_.]+'); + }); + + it('should return true for a valid username', () => { + const result = validateUsername('valid_username.123'); + expect(result).to.be.true; + }); + + it('should return false for an invalid username containing special HTML tags', () => { + const result = validateUsername('username
$
'); + expect(result).to.be.false; + }); + + it('should return false for an empty username', () => { + const result = validateUsername(''); + expect(result).to.be.false; + }); + + it('should return false for a username with invalid characters', () => { + const result = validateUsername('invalid*username!'); + expect(result).to.be.false; + }); + + it('should return true for a username with allowed special characters', () => { + const result = validateUsername('username-_.'); + expect(result).to.be.true; + }); + }); + + describe('with custom regex settings', () => { + beforeEach(() => { + getStub.withArgs('UTF8_User_Names_Validation').returns('[a-zA-Z]+'); + }); + + it('should return true for a username matching the custom regex', () => { + const result = validateUsername('ValidUsername'); + expect(result).to.be.true; + }); + + it('should return false for a username that does not match the custom regex', () => { + const result = validateUsername('username123'); + expect(result).to.be.false; + }); + }); + + describe('with null regex settings', () => { + beforeEach(() => { + getStub.withArgs('UTF8_User_Names_Validation').returns(null); + }); + + it('should fallback to the default regex pattern if the settings value is null', () => { + const result = validateUsername('username'); + expect(result).to.be.true; + }); + }); + + describe('with invalid regex settings', () => { + beforeEach(() => { + getStub.withArgs('UTF8_User_Names_Validation').returns('invalid['); + }); + + it('should fallback to the default regex pattern if the settings value is invalid', () => { + const result = validateUsername('username'); + expect(result).to.be.true; + }); + }); +}); diff --git a/apps/meteor/tests/unit/app/livechat/server/lib/Contacts.spec.ts b/apps/meteor/tests/unit/app/livechat/server/lib/Contacts.spec.ts new file mode 100644 index 000000000000..9ff2019ffca5 --- /dev/null +++ b/apps/meteor/tests/unit/app/livechat/server/lib/Contacts.spec.ts @@ -0,0 +1,40 @@ +import { expect } from 'chai'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +const { validateCustomFields } = proxyquire.noCallThru().load('../../../../../../app/livechat/server/lib/Contacts', { + 'meteor/check': sinon.stub(), + 'meteor/meteor': sinon.stub(), +}); + +describe('[OC] Contacts', () => { + describe('validateCustomFields', () => { + const mockCustomFields = [{ _id: 'cf1', label: 'Custom Field 1', regexp: '^[0-9]+$', required: true }]; + + it('should validate custom fields correctly', () => { + expect(() => validateCustomFields(mockCustomFields, { cf1: '123' })).to.not.throw(); + }); + + it('should throw an error if a required custom field is missing', () => { + expect(() => validateCustomFields(mockCustomFields, {})).to.throw(); + }); + + it('should NOT throw an error when a non-required custom field is missing', () => { + const allowedCustomFields = [{ _id: 'field1', label: 'Field 1', required: false }]; + const customFields = {}; + + expect(() => validateCustomFields(allowedCustomFields, customFields)).not.to.throw(); + }); + + it('should throw an error if a custom field value does not match the regexp', () => { + expect(() => validateCustomFields(mockCustomFields, { cf1: 'invalid' })).to.throw(); + }); + + it('should handle an empty customFields input without throwing an error', () => { + const allowedCustomFields = [{ _id: 'field1', label: 'Field 1', required: false }]; + const customFields = {}; + + expect(() => validateCustomFields(allowedCustomFields, customFields)).not.to.throw(); + }); + }); +}); diff --git a/apps/meteor/tests/unit/server/federation/infrastructure/rocket-chat/hooks/hooks.spec.ts b/apps/meteor/tests/unit/server/federation/infrastructure/rocket-chat/hooks/hooks.spec.ts index c77f6e4993fa..94d8fa26bd9c 100644 --- a/apps/meteor/tests/unit/server/federation/infrastructure/rocket-chat/hooks/hooks.spec.ts +++ b/apps/meteor/tests/unit/server/federation/infrastructure/rocket-chat/hooks/hooks.spec.ts @@ -8,7 +8,9 @@ import { afterRemoveFromRoomCallback } from '../../../../../../../lib/callbacks/ import type * as hooksModule from '../../../../../../../server/services/federation/infrastructure/rocket-chat/hooks'; const remove = sinon.stub(); -const get = sinon.stub(); +const throwIfFederationNotEnabledOrNotReady = sinon.stub(); +const throwIfFederationNotReady = sinon.stub(); +const isFederationEnabled = sinon.stub(); const hooks: Record = {}; const { FederationHooks } = proxyquire @@ -35,8 +37,10 @@ const { FederationHooks } = proxyquire '../../../../../../lib/callbacks/afterRemoveFromRoomCallback': { afterRemoveFromRoomCallback, }, - '../../../../../../app/settings/server': { - settings: { get }, + '../../../utils': { + throwIfFederationNotEnabledOrNotReady, + throwIfFederationNotReady, + isFederationEnabled, }, }); @@ -44,12 +48,13 @@ describe('Federation - Infrastructure - RocketChat - Hooks', () => { beforeEach(() => { FederationHooks.removeAllListeners(); remove.reset(); - get.reset(); + throwIfFederationNotEnabledOrNotReady.reset(); + throwIfFederationNotReady.reset(); + isFederationEnabled.reset(); }); describe('#afterUserLeaveRoom()', () => { it('should NOT execute the callback if no room was provided', async () => { - get.returns(true); const stub = sinon.stub(); FederationHooks.afterUserLeaveRoom(stub); @@ -59,7 +64,6 @@ describe('Federation - Infrastructure - RocketChat - Hooks', () => { }); it('should NOT execute the callback if the provided room is not federated', async () => { - get.returns(true); const stub = sinon.stub(); FederationHooks.afterUserLeaveRoom(stub); @@ -70,7 +74,6 @@ describe('Federation - Infrastructure - RocketChat - Hooks', () => { }); it('should NOT execute the callback if no user was provided', async () => { - get.returns(true); const stub = sinon.stub(); FederationHooks.afterUserLeaveRoom(stub); @@ -81,18 +84,20 @@ describe('Federation - Infrastructure - RocketChat - Hooks', () => { }); it('should NOT execute the callback if federation module was disabled', async () => { - get.returns(false); + const error = new Error(); + + throwIfFederationNotEnabledOrNotReady.throws(error); const stub = sinon.stub(); FederationHooks.afterUserLeaveRoom(stub); // @ts-expect-error - await afterLeaveRoomCallback.run({}, { federated: true }); + // eslint-disable-next-line @typescript-eslint/no-floating-promises + expect(afterLeaveRoomCallback.run({}, { federated: true })).to.have.rejectedWith(error); expect(stub.called).to.be.false; }); it('should execute the callback when everything is correct', async () => { - get.returns(true); const stub = sinon.stub(); FederationHooks.afterUserLeaveRoom(stub); @@ -105,7 +110,6 @@ describe('Federation - Infrastructure - RocketChat - Hooks', () => { describe('#onUserRemovedFromRoom()', () => { it('should NOT execute the callback if no room was provided', async () => { - get.returns(true); const stub = sinon.stub(); FederationHooks.onUserRemovedFromRoom(stub); @@ -116,7 +120,6 @@ describe('Federation - Infrastructure - RocketChat - Hooks', () => { }); it('should NOT execute the callback if the provided room is not federated', async () => { - get.returns(true); const stub = sinon.stub(); FederationHooks.onUserRemovedFromRoom(stub); @@ -127,7 +130,6 @@ describe('Federation - Infrastructure - RocketChat - Hooks', () => { }); it('should NOT execute the callback if no params were provided', async () => { - get.returns(true); const stub = sinon.stub(); FederationHooks.onUserRemovedFromRoom(stub); @@ -138,7 +140,6 @@ describe('Federation - Infrastructure - RocketChat - Hooks', () => { }); it('should NOT execute the callback if no removedUser was provided', async () => { - get.returns(true); const stub = sinon.stub(); FederationHooks.onUserRemovedFromRoom(stub); // @ts-expect-error @@ -148,7 +149,6 @@ describe('Federation - Infrastructure - RocketChat - Hooks', () => { }); it('should NOT execute the callback if no userWhoRemoved was provided', async () => { - get.returns(true); const stub = sinon.stub(); FederationHooks.onUserRemovedFromRoom(stub); // @ts-expect-error @@ -158,17 +158,21 @@ describe('Federation - Infrastructure - RocketChat - Hooks', () => { }); it('should NOT execute the callback if federation module was disabled', async () => { - get.returns(false); + const error = new Error(); + + throwIfFederationNotEnabledOrNotReady.throws(error); const stub = sinon.stub(); FederationHooks.onUserRemovedFromRoom(stub); - // @ts-expect-error - await afterRemoveFromRoomCallback.run({ removedUser: 'removedUser', userWhoRemoved: 'userWhoRemoved' }, { federated: true }); + // eslint-disable-next-line @typescript-eslint/no-floating-promises + expect( + // @ts-ignore-error + afterRemoveFromRoomCallback.run({ removedUser: 'removedUser', userWhoRemoved: 'userWhoRemoved' }, { federated: true }), + ).to.have.rejectedWith(error); expect(stub.called).to.be.false; }); it('should execute the callback when everything is correct', async () => { - get.returns(true); const stub = sinon.stub(); FederationHooks.onUserRemovedFromRoom(stub); // @ts-expect-error @@ -179,7 +183,6 @@ describe('Federation - Infrastructure - RocketChat - Hooks', () => { describe('#canAddFederatedUserToNonFederatedRoom()', () => { it('should NOT execute the callback if no room was provided', () => { - get.returns(true); const stub = sinon.stub(); FederationHooks.canAddFederatedUserToNonFederatedRoom(stub); hooks['federation-v2-can-add-federated-user-to-non-federated-room'](); @@ -187,7 +190,6 @@ describe('Federation - Infrastructure - RocketChat - Hooks', () => { }); it('should NOT execute the callback if no params were provided', () => { - get.returns(true); const stub = sinon.stub(); FederationHooks.canAddFederatedUserToNonFederatedRoom(stub); hooks['federation-v2-can-add-federated-user-to-non-federated-room']({}, { federated: true }); @@ -195,7 +197,6 @@ describe('Federation - Infrastructure - RocketChat - Hooks', () => { }); it('should NOT execute the callback if no user was provided', () => { - get.returns(true); const stub = sinon.stub(); FederationHooks.canAddFederatedUserToNonFederatedRoom(stub); hooks['federation-v2-can-add-federated-user-to-non-federated-room']({}, { federated: true }, {}); @@ -203,7 +204,7 @@ describe('Federation - Infrastructure - RocketChat - Hooks', () => { }); it('should execute the callback when everything is correct', () => { - get.returns(true); + isFederationEnabled.returns(true); const stub = sinon.stub(); FederationHooks.canAddFederatedUserToNonFederatedRoom(stub); hooks['federation-v2-can-add-federated-user-to-non-federated-room']({ user: 'user' }, { federated: true }); @@ -213,7 +214,6 @@ describe('Federation - Infrastructure - RocketChat - Hooks', () => { describe('#canAddFederatedUserToFederatedRoom()', () => { it('should NOT execute the callback if no room was provided', () => { - get.returns(true); const stub = sinon.stub(); FederationHooks.canAddFederatedUserToFederatedRoom(stub); hooks['federation-v2-can-add-federated-user-to-federated-room'](); @@ -221,7 +221,6 @@ describe('Federation - Infrastructure - RocketChat - Hooks', () => { }); it('should NOT execute the callback if no params were provided', () => { - get.returns(true); const stub = sinon.stub(); FederationHooks.canAddFederatedUserToFederatedRoom(stub); hooks['federation-v2-can-add-federated-user-to-federated-room']({}, { federated: true }); @@ -229,7 +228,6 @@ describe('Federation - Infrastructure - RocketChat - Hooks', () => { }); it('should NOT execute the callback if no user was provided', () => { - get.returns(true); const stub = sinon.stub(); FederationHooks.canAddFederatedUserToFederatedRoom(stub); hooks['federation-v2-can-add-federated-user-to-federated-room']({}, { federated: true }, {}); @@ -237,7 +235,6 @@ describe('Federation - Infrastructure - RocketChat - Hooks', () => { }); it('should NOT execute the callback if no inviter was provided', () => { - get.returns(true); const stub = sinon.stub(); FederationHooks.canAddFederatedUserToFederatedRoom(stub); hooks['federation-v2-can-add-federated-user-to-federated-room']({ user: 'user' }, { federated: true }, {}); @@ -245,15 +242,15 @@ describe('Federation - Infrastructure - RocketChat - Hooks', () => { }); it('should NOT execute the callback if federation module was disabled', () => { - get.returns(false); const stub = sinon.stub(); FederationHooks.canAddFederatedUserToFederatedRoom(stub); + // eslint-disable-next-line @typescript-eslint/no-floating-promises hooks['federation-v2-can-add-federated-user-to-federated-room']({ user: 'user', inviter: 'inviter' }, { federated: true }); expect(stub.called).to.be.false; }); it('should execute the callback when everything is correct', () => { - get.returns(true); + isFederationEnabled.returns(true); const stub = sinon.stub(); FederationHooks.canAddFederatedUserToFederatedRoom(stub); hooks['federation-v2-can-add-federated-user-to-federated-room']({ user: 'user', inviter: 'inviter' }, { federated: true }); @@ -263,7 +260,6 @@ describe('Federation - Infrastructure - RocketChat - Hooks', () => { describe('#canCreateDirectMessageFromUI()', () => { it('should NOT execute the callback if no members was provided', () => { - get.returns(true); const stub = sinon.stub(); FederationHooks.canCreateDirectMessageFromUI(stub); hooks['federation-v2-can-create-direct-message-from-ui-ce'](); @@ -271,15 +267,16 @@ describe('Federation - Infrastructure - RocketChat - Hooks', () => { }); it('should NOT execute the callback if federation module was disabled', () => { - get.returns(false); + const error = new Error(); + throwIfFederationNotEnabledOrNotReady.throws(error); const stub = sinon.stub(); FederationHooks.canCreateDirectMessageFromUI(stub); - hooks['federation-v2-can-create-direct-message-from-ui-ce']([]); + // eslint-disable-next-line @typescript-eslint/no-floating-promises + expect(hooks['federation-v2-can-create-direct-message-from-ui-ce']([])).to.have.rejectedWith(error); expect(stub.called).to.be.false; }); it('should execute the callback when everything is correct', () => { - get.returns(true); const stub = sinon.stub(); FederationHooks.canCreateDirectMessageFromUI(stub); hooks['federation-v2-can-create-direct-message-from-ui-ce']([]); @@ -289,7 +286,6 @@ describe('Federation - Infrastructure - RocketChat - Hooks', () => { describe('#afterMessageReacted()', () => { it('should NOT execute the callback if no message was provided', () => { - get.returns(true); const stub = sinon.stub(); FederationHooks.afterMessageReacted(stub); hooks['federation-v2-after-message-reacted'](); @@ -297,7 +293,6 @@ describe('Federation - Infrastructure - RocketChat - Hooks', () => { }); it('should NOT execute the callback if the provided message is not from a federated room', () => { - get.returns(true); const stub = sinon.stub(); FederationHooks.afterMessageReacted(stub); hooks['federation-v2-after-message-reacted']({}); @@ -305,7 +300,6 @@ describe('Federation - Infrastructure - RocketChat - Hooks', () => { }); it('should NOT execute the callback if no params were provided', () => { - get.returns(true); const stub = sinon.stub(); FederationHooks.afterMessageReacted(stub); hooks['federation-v2-after-message-reacted']({ federation: { eventId: 'eventId' } }, {}); @@ -313,7 +307,6 @@ describe('Federation - Infrastructure - RocketChat - Hooks', () => { }); it('should NOT execute the callback if no user was provided', () => { - get.returns(true); const stub = sinon.stub(); FederationHooks.afterMessageReacted(stub); hooks['federation-v2-after-message-reacted']({ federation: { eventId: 'eventId' } }, { federated: true }, {}); @@ -321,7 +314,6 @@ describe('Federation - Infrastructure - RocketChat - Hooks', () => { }); it('should NOT execute the callback if no reaction was provided', () => { - get.returns(true); const stub = sinon.stub(); FederationHooks.afterMessageReacted(stub); hooks['federation-v2-after-message-reacted']({ federation: { eventId: 'eventId' } }, { user: 'user' }); @@ -329,15 +321,18 @@ describe('Federation - Infrastructure - RocketChat - Hooks', () => { }); it('should NOT execute the callback if federation module was disabled', () => { - get.returns(false); + const error = new Error(); + throwIfFederationNotEnabledOrNotReady.throws(error); const stub = sinon.stub(); FederationHooks.afterMessageReacted(stub); - hooks['federation-v2-after-message-reacted']({ federation: { eventId: 'eventId' } }, { user: 'user', reaction: 'reaction' }); + // eslint-disable-next-line @typescript-eslint/no-floating-promises + expect( + hooks['federation-v2-after-message-reacted']({ federation: { eventId: 'eventId' } }, { user: 'user', reaction: 'reaction' }), + ).to.have.rejectedWith(error); expect(stub.called).to.be.false; }); it('should execute the callback when everything is correct', () => { - get.returns(true); const stub = sinon.stub(); FederationHooks.afterMessageReacted(stub); hooks['federation-v2-after-message-reacted']({ federation: { eventId: 'eventId' } }, { user: 'user', reaction: 'reaction' }); @@ -347,7 +342,6 @@ describe('Federation - Infrastructure - RocketChat - Hooks', () => { describe('#afterMessageunReacted()', () => { it('should NOT execute the callback if no message was provided', () => { - get.returns(true); const stub = sinon.stub(); FederationHooks.afterMessageunReacted(stub); hooks['federation-v2-after-message-unreacted'](); @@ -355,7 +349,6 @@ describe('Federation - Infrastructure - RocketChat - Hooks', () => { }); it('should NOT execute the callback if the provided message is not from a federated room', () => { - get.returns(true); const stub = sinon.stub(); FederationHooks.afterMessageunReacted(stub); hooks['federation-v2-after-message-unreacted']({}); @@ -363,7 +356,6 @@ describe('Federation - Infrastructure - RocketChat - Hooks', () => { }); it('should NOT execute the callback if no params were provided', () => { - get.returns(true); const stub = sinon.stub(); FederationHooks.afterMessageunReacted(stub); hooks['federation-v2-after-message-unreacted']({ federation: { eventId: 'eventId' } }, {}); @@ -371,7 +363,6 @@ describe('Federation - Infrastructure - RocketChat - Hooks', () => { }); it('should NOT execute the callback if no user was provided', () => { - get.returns(true); const stub = sinon.stub(); FederationHooks.afterMessageunReacted(stub); hooks['federation-v2-after-message-unreacted']({ federation: { eventId: 'eventId' } }, { federated: true }, {}); @@ -379,7 +370,6 @@ describe('Federation - Infrastructure - RocketChat - Hooks', () => { }); it('should NOT execute the callback if no reaction was provided', () => { - get.returns(true); const stub = sinon.stub(); FederationHooks.afterMessageunReacted(stub); hooks['federation-v2-after-message-unreacted']({ federation: { eventId: 'eventId' } }, { user: 'user' }); @@ -387,7 +377,6 @@ describe('Federation - Infrastructure - RocketChat - Hooks', () => { }); it('should NOT execute the callback if no oldMessage was provided', () => { - get.returns(true); const stub = sinon.stub(); FederationHooks.afterMessageunReacted(stub); hooks['federation-v2-after-message-unreacted']({ federation: { eventId: 'eventId' } }, { user: 'user', reaction: 'reaction' }); @@ -395,18 +384,21 @@ describe('Federation - Infrastructure - RocketChat - Hooks', () => { }); it('should NOT execute the callback if federation module was disabled', () => { - get.returns(false); + const error = new Error(); + throwIfFederationNotEnabledOrNotReady.throws(error); const stub = sinon.stub(); FederationHooks.afterMessageunReacted(stub); - hooks['federation-v2-after-message-unreacted']( - { federation: { eventId: 'eventId' } }, - { user: 'user', reaction: 'reaction', oldMessage: {} }, - ); + // eslint-disable-next-line @typescript-eslint/no-floating-promises + expect( + hooks['federation-v2-after-message-unreacted']( + { federation: { eventId: 'eventId' } }, + { user: 'user', reaction: 'reaction', oldMessage: {} }, + ), + ).to.have.rejectedWith(error); expect(stub.called).to.be.false; }); it('should execute the callback when everything is correct', () => { - get.returns(true); const stub = sinon.stub(); FederationHooks.afterMessageunReacted(stub); hooks['federation-v2-after-message-unreacted']( @@ -419,7 +411,6 @@ describe('Federation - Infrastructure - RocketChat - Hooks', () => { describe('#afterMessageDeleted()', () => { it('should NOT execute the callback if no room was provided', () => { - get.returns(true); const stub = sinon.stub(); FederationHooks.afterMessageDeleted(stub); hooks['federation-v2-after-room-message-deleted'](); @@ -427,7 +418,6 @@ describe('Federation - Infrastructure - RocketChat - Hooks', () => { }); it('should NOT execute the callback if the provided room is not federated', () => { - get.returns(true); const stub = sinon.stub(); FederationHooks.afterMessageDeleted(stub); hooks['federation-v2-after-room-message-deleted']({}, {}); @@ -435,7 +425,6 @@ describe('Federation - Infrastructure - RocketChat - Hooks', () => { }); it('should NOT execute the callback if the provided message is not from a federated room', () => { - get.returns(true); const stub = sinon.stub(); FederationHooks.afterMessageDeleted(stub); hooks['federation-v2-after-room-message-deleted']({}, { federated: true }); @@ -443,15 +432,18 @@ describe('Federation - Infrastructure - RocketChat - Hooks', () => { }); it('should NOT execute the callback if federation module was disabled', () => { - get.returns(false); + const error = new Error(); + throwIfFederationNotEnabledOrNotReady.throws(error); const stub = sinon.stub(); FederationHooks.afterMessageDeleted(stub); - hooks['federation-v2-after-room-message-deleted']({ federation: { eventId: 'eventId' } }, { federated: true }); + // eslint-disable-next-line @typescript-eslint/no-floating-promises + expect( + hooks['federation-v2-after-room-message-deleted']({ federation: { eventId: 'eventId' } }, { federated: true }), + ).to.have.rejectedWith(error); expect(stub.called).to.be.false; }); it('should execute the callback when everything is correct', () => { - get.returns(true); const stub = sinon.stub(); FederationHooks.afterMessageDeleted(stub); hooks['federation-v2-after-room-message-deleted']({ federation: { eventId: 'eventId' } }, { federated: true, _id: 'roomId' }); @@ -461,7 +453,6 @@ describe('Federation - Infrastructure - RocketChat - Hooks', () => { describe('#afterMessageUpdated()', () => { it('should NOT execute the callback if no room was provided', () => { - get.returns(true); const stub = sinon.stub(); FederationHooks.afterMessageUpdated(stub); hooks['federation-v2-after-room-message-updated'](); @@ -469,7 +460,6 @@ describe('Federation - Infrastructure - RocketChat - Hooks', () => { }); it('should NOT execute the callback if the provided room is not federated', () => { - get.returns(true); const stub = sinon.stub(); FederationHooks.afterMessageUpdated(stub); hooks['federation-v2-after-room-message-updated']({}, {}); @@ -477,7 +467,6 @@ describe('Federation - Infrastructure - RocketChat - Hooks', () => { }); it('should NOT execute the callback if the provided message is not from a federated room', () => { - get.returns(true); const stub = sinon.stub(); FederationHooks.afterMessageUpdated(stub); hooks['federation-v2-after-room-message-updated']({}, { federated: true }); @@ -485,15 +474,18 @@ describe('Federation - Infrastructure - RocketChat - Hooks', () => { }); it('should NOT execute the callback if federation module was disabled', () => { - get.returns(false); + const error = new Error(); + throwIfFederationNotEnabledOrNotReady.throws(error); const stub = sinon.stub(); FederationHooks.afterMessageUpdated(stub); - hooks['federation-v2-after-room-message-updated']({ federation: { eventId: 'eventId' } }, { federated: true }); + // eslint-disable-next-line @typescript-eslint/no-floating-promises + expect( + hooks['federation-v2-after-room-message-updated']({ federation: { eventId: 'eventId' } }, { federated: true }), + ).to.have.rejectedWith(error); expect(stub.called).to.be.false; }); it('should NOT execute the callback if the message is not a edited one', () => { - get.returns(true); const stub = sinon.stub(); FederationHooks.afterMessageUpdated(stub); hooks['federation-v2-after-room-message-updated']({ federation: { eventId: 'eventId' } }, { federated: true }); @@ -504,7 +496,7 @@ describe('Federation - Infrastructure - RocketChat - Hooks', () => { const editedAt = faker.date.recent(); const editedBy = { _id: 'userId' }; const message = { federation: { eventId: 'eventId' }, editedAt, editedBy }; - get.returns(true); + const stub = sinon.stub(); FederationHooks.afterMessageUpdated(stub); hooks['federation-v2-after-room-message-updated'](message, { room: { federated: true, _id: 'roomId' } }); @@ -514,7 +506,6 @@ describe('Federation - Infrastructure - RocketChat - Hooks', () => { describe('#afterMessageSent()', () => { it('should NOT execute the callback if no room was provided', () => { - get.returns(true); const stub = sinon.stub(); FederationHooks.afterMessageSent(stub); hooks['federation-v2-after-room-message-sent'](); @@ -522,7 +513,6 @@ describe('Federation - Infrastructure - RocketChat - Hooks', () => { }); it('should NOT execute the callback if the provided room is not federated', () => { - get.returns(true); const stub = sinon.stub(); FederationHooks.afterMessageSent(stub); hooks['federation-v2-after-room-message-sent']({}, {}); @@ -530,15 +520,16 @@ describe('Federation - Infrastructure - RocketChat - Hooks', () => { }); it('should NOT execute the callback if federation module was disabled', () => { - get.returns(false); + const error = new Error(); + throwIfFederationNotEnabledOrNotReady.throws(error); const stub = sinon.stub(); FederationHooks.afterMessageSent(stub); - hooks['federation-v2-after-room-message-sent']({}, { federated: true }); + // eslint-disable-next-line @typescript-eslint/no-floating-promises + expect(hooks['federation-v2-after-room-message-sent']({}, { federated: true })).to.have.rejectedWith(error); expect(stub.called).to.be.false; }); it('should NOT execute the callback if the message is edited one', () => { - get.returns(true); const stub = sinon.stub(); FederationHooks.afterMessageSent(stub); const editedAt = faker.date.recent(); @@ -548,7 +539,6 @@ describe('Federation - Infrastructure - RocketChat - Hooks', () => { }); it('should execute the callback when everything is correct', () => { - get.returns(true); const stub = sinon.stub(); FederationHooks.afterMessageSent(stub); hooks['federation-v2-after-room-message-sent']({ u: { _id: 'userId' } }, { room: { federated: true, _id: 'roomId' } }); @@ -581,7 +571,8 @@ describe('Federation - Infrastructure - RocketChat - Hooks', () => { }); it('should NOT call the Federation module is disabled', async () => { - get.returns(false); + isFederationEnabled.returns(false); + await FederationHooks.afterRoomRoleChanged(handlers, undefined); expect(handlers.onRoomOwnerAdded.called).to.be.false; @@ -591,7 +582,9 @@ describe('Federation - Infrastructure - RocketChat - Hooks', () => { }); it('should NOT call the handler if the event is not for roles we are interested in on Federation', async () => { - get.returns(true); + isFederationEnabled.returns(true); + // verifyFederationReady doesn't throw by default in here + await FederationHooks.afterRoomRoleChanged(handlers, { _id: 'not-interested' }); expect(handlers.onRoomOwnerAdded.called).to.be.false; @@ -601,7 +594,8 @@ describe('Federation - Infrastructure - RocketChat - Hooks', () => { }); it('should NOT call the handler there is no handler for the event', async () => { - get.returns(true); + isFederationEnabled.returns(true); + await FederationHooks.afterRoomRoleChanged(handlers, { _id: 'owner', type: 'not-existing-type' }); expect(handlers.onRoomOwnerAdded.called).to.be.false; @@ -615,7 +609,8 @@ describe('Federation - Infrastructure - RocketChat - Hooks', () => { const internalTargetUserId = 'internalTargetUserId'; const internalUserId = 'internalUserId'; it(`should call the handler for the event ${type}`, async () => { - get.returns(true); + isFederationEnabled.returns(true); + await FederationHooks.afterRoomRoleChanged(handlers, { _id: type.split('-')[0], type: type.split('-')[1], @@ -637,7 +632,6 @@ describe('Federation - Infrastructure - RocketChat - Hooks', () => { describe('#afterRoomNameChanged()', () => { it('should NOT execute the callback if no params was provided', () => { - get.returns(true); const stub = sinon.stub(); FederationHooks.afterRoomNameChanged(stub); hooks['federation-v2-after-room-name-changed'](); @@ -645,7 +639,6 @@ describe('Federation - Infrastructure - RocketChat - Hooks', () => { }); it('should NOT execute the callback if no roomId was provided', () => { - get.returns(true); const stub = sinon.stub(); FederationHooks.afterRoomNameChanged(stub); hooks['federation-v2-after-room-name-changed']({}); @@ -653,7 +646,6 @@ describe('Federation - Infrastructure - RocketChat - Hooks', () => { }); it('should NOT execute the callback if no roomName was provided', () => { - get.returns(true); const stub = sinon.stub(); FederationHooks.afterRoomNameChanged(stub); hooks['federation-v2-after-room-name-changed']({ rid: 'roomId' }); @@ -661,15 +653,16 @@ describe('Federation - Infrastructure - RocketChat - Hooks', () => { }); it('should NOT execute the callback if federation module was disabled', () => { - get.returns(false); + const error = new Error(); + throwIfFederationNotEnabledOrNotReady.throws(error); const stub = sinon.stub(); FederationHooks.afterRoomNameChanged(stub); - hooks['federation-v2-after-room-name-changed']({ rid: 'roomId', name: 'roomName' }); + // eslint-disable-next-line @typescript-eslint/no-floating-promises + expect(hooks['federation-v2-after-room-name-changed']({ rid: 'roomId', name: 'roomName' })).to.have.rejectedWith(error); expect(stub.called).to.be.false; }); it('should execute the callback when everything is correct', () => { - get.returns(true); const stub = sinon.stub(); FederationHooks.afterRoomNameChanged(stub); hooks['federation-v2-after-room-name-changed']({ rid: 'roomId', name: 'roomName' }); @@ -679,7 +672,6 @@ describe('Federation - Infrastructure - RocketChat - Hooks', () => { describe('#afterRoomTopicChanged()', () => { it('should NOT execute the callback if no params was provided', () => { - get.returns(true); const stub = sinon.stub(); FederationHooks.afterRoomTopicChanged(stub); hooks['federation-v2-after-room-topic-changed'](); @@ -687,7 +679,6 @@ describe('Federation - Infrastructure - RocketChat - Hooks', () => { }); it('should NOT execute the callback if no roomId was provided', () => { - get.returns(true); const stub = sinon.stub(); FederationHooks.afterRoomTopicChanged(stub); hooks['federation-v2-after-room-topic-changed']({}); @@ -695,7 +686,6 @@ describe('Federation - Infrastructure - RocketChat - Hooks', () => { }); it('should NOT execute the callback if no topic was provided', () => { - get.returns(true); const stub = sinon.stub(); FederationHooks.afterRoomTopicChanged(stub); hooks['federation-v2-after-room-topic-changed']({ rid: 'roomId' }); @@ -703,15 +693,16 @@ describe('Federation - Infrastructure - RocketChat - Hooks', () => { }); it('should NOT execute the callback if federation module was disabled', () => { - get.returns(false); + const error = new Error(); + throwIfFederationNotEnabledOrNotReady.throws(error); const stub = sinon.stub(); FederationHooks.afterRoomTopicChanged(stub); - hooks['federation-v2-after-room-topic-changed']({ rid: 'roomId', topic: 'topic' }); + // eslint-disable-next-line @typescript-eslint/no-floating-promises + expect(hooks['federation-v2-after-room-topic-changed']({ rid: 'roomId', topic: 'topic' })).to.have.rejectedWith(error); expect(stub.called).to.be.false; }); it('should execute the callback when everything is correct', () => { - get.returns(true); const stub = sinon.stub(); FederationHooks.afterRoomTopicChanged(stub); hooks['federation-v2-after-room-topic-changed']({ rid: 'roomId', topic: 'topic' }); @@ -735,7 +726,7 @@ describe('Federation - Infrastructure - RocketChat - Hooks', () => { describe('#removeAllListeners()', () => { it('should remove all the listeners', () => { FederationHooks.removeAllListeners(); - expect(remove.callCount).to.be.equal(9); + expect(remove.callCount).to.be.equal(11); expect( remove.getCall(0).calledWith('federation.beforeAddUserToARoom', 'federation-v2-can-add-federated-user-to-non-federated-room'), ).to.be.equal(true); @@ -751,6 +742,8 @@ describe('Federation - Infrastructure - RocketChat - Hooks', () => { expect(remove.getCall(6).calledWith('afterSaveMessage', 'federation-v2-after-room-message-updated')).to.be.equal(true); expect(remove.getCall(7).calledWith('afterSaveMessage', 'federation-v2-after-room-message-sent')).to.be.equal(true); expect(remove.getCall(8).calledWith('afterSaveMessage', 'federation-v2-after-room-message-sent')).to.be.equal(true); + expect(remove.getCall(9).calledWith('afterRoomNameChange', 'federation-v2-after-room-name-changed')).to.be.equal(true); + expect(remove.getCall(10).calledWith('afterRoomTopicChange', 'federation-v2-after-room-topic-changed')).to.be.equal(true); }); }); }); diff --git a/apps/meteor/tests/unit/server/federation/utils.spec.ts b/apps/meteor/tests/unit/server/federation/utils.spec.ts new file mode 100644 index 000000000000..cc024d93f7dd --- /dev/null +++ b/apps/meteor/tests/unit/server/federation/utils.spec.ts @@ -0,0 +1,76 @@ +import { expect } from 'chai'; +import proxyquire from 'proxyquire'; + +import type * as federationUtilsModule from '../../../../server/services/federation/utils'; + +const settings = { + enabled: false, + ready: false, + + get(id: string) { + switch (id) { + case 'Federation_Matrix_enabled': + return this.enabled; + case 'Federation_Matrix_configuration_status': + return this.ready ? 'Valid' : 'Invalid'; + } + }, + + reset() { + this.enabled = false; + this.ready = false; + }, +}; + +const { throwIfFederationNotEnabledOrNotReady, throwIfFederationNotReady, throwIfFederationEnabledButNotReady } = proxyquire + .noCallThru() + .load('../../../../server/services/federation/utils', { + '../../../app/settings/server': { + settings, + }, + }); + +describe('Federation helper functions', () => { + afterEach(() => { + settings.reset(); + }); + + describe('#throwIfFederationNotReady', () => { + it('should throw if federation is not ready', () => { + expect(throwIfFederationNotReady).to.throw(); + }); + }); + + describe('#throwIfFederationNotEnabledOrNotReady', () => { + it('should throw if federation is not enabled', () => { + expect(throwIfFederationNotEnabledOrNotReady).to.throw(); + }); + + it('should throw if federation is enabled but configuration is invalid', () => { + settings.enabled = true; + expect(throwIfFederationNotEnabledOrNotReady).to.throw(); + }); + + it('should not throw if both federation is enabled and configuration is valid', () => { + settings.enabled = true; + settings.ready = true; + expect(throwIfFederationNotEnabledOrNotReady).to.not.throw(); + }); + }); + + describe('#throwIfFederationEnabledButNotReady', () => { + it('should throw if federation is enabled and configuration is invalid', () => { + settings.enabled = true; + settings.ready = false; + + expect(throwIfFederationEnabledButNotReady).to.throw(); + }); + + it('should not throw if federation is disabled', () => { + expect(throwIfFederationEnabledButNotReady).to.not.throw(); + + settings.ready = true; + expect(throwIfFederationEnabledButNotReady).to.not.throw(); + }); + }); +}); diff --git a/apps/meteor/tests/unit/server/services/messages/hooks/BeforeFederationActions.tests.ts b/apps/meteor/tests/unit/server/services/messages/hooks/BeforeFederationActions.tests.ts new file mode 100644 index 000000000000..1c48fae7d369 --- /dev/null +++ b/apps/meteor/tests/unit/server/services/messages/hooks/BeforeFederationActions.tests.ts @@ -0,0 +1,78 @@ +import type { IMessage, IRoom } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +import type * as beforeFederationActionModule from '../../../../../../server/services/messages/hooks/BeforeFederationActions'; + +const isFederationReady = sinon.stub(); +const isFederationEnabled = sinon.stub(); + +const { FederationActions } = proxyquire + .noCallThru() + .load('../../../../../../server/services/messages/hooks/BeforeFederationActions', { + '../../federation/utils': { + isFederationEnabled, + isFederationReady, + }, + }); + +describe("Don't perform action depending on federation status", () => { + afterEach(() => { + isFederationReady.reset(); + isFederationEnabled.reset(); + }); + + it('should return true if neither message nor room is federated', () => { + expect(FederationActions.shouldPerformAction({} as IMessage, {} as IRoom)).to.be.true; + }); + + describe('Federation is enabled', () => { + it('should return true if message is federated and configuration is valid', () => { + isFederationEnabled.returns(true); + isFederationReady.returns(true); + + expect(FederationActions.shouldPerformAction({ federation: { eventId: Date.now().toString() } } as IMessage, {} as unknown as IRoom)) + .to.be.true; + }); + + it('should return true if room is federated and configuration is valid', () => { + isFederationEnabled.returns(true); + isFederationReady.returns(true); + + expect(FederationActions.shouldPerformAction({} as unknown as IMessage, { federated: true } as IRoom)).to.be.true; + }); + + it('should return false if message is federated and configuration is invalid', () => { + isFederationEnabled.returns(true); + isFederationReady.returns(false); + + expect(FederationActions.shouldPerformAction({ federation: { eventId: Date.now().toString() } } as IMessage, {} as unknown as IRoom)) + .to.be.false; + }); + + it('should return false if room is federated and configuration is invalid', () => { + isFederationEnabled.returns(true); + isFederationReady.returns(false); + + expect(FederationActions.shouldPerformAction({} as unknown as IMessage, { federated: true } as IRoom)).to.be.false; + }); + }); + + describe('Federation is disabled', () => { + it('should return false if room is federated', () => { + isFederationEnabled.returns(false); + isFederationReady.returns(false); + + expect(FederationActions.shouldPerformAction({} as unknown as IMessage, { federated: true } as IRoom)).to.be.false; + }); + + it('should return false if message is federated', () => { + isFederationEnabled.returns(false); + isFederationReady.returns(false); + + expect(FederationActions.shouldPerformAction({ federation: { eventId: Date.now().toString() } } as IMessage, {} as unknown as IRoom)) + .to.be.false; + }); + }); +}); diff --git a/apps/meteor/tests/unit/server/services/omnichannel-analytics/OverviewData.tests.ts b/apps/meteor/tests/unit/server/services/omnichannel-analytics/OverviewData.tests.ts index cd2031d387d8..d5ca07ec83d6 100644 --- a/apps/meteor/tests/unit/server/services/omnichannel-analytics/OverviewData.tests.ts +++ b/apps/meteor/tests/unit/server/services/omnichannel-analytics/OverviewData.tests.ts @@ -6,9 +6,9 @@ import sinon from 'sinon'; import { OverviewData } from '../../../../../server/services/omnichannel-analytics/OverviewData'; import { conversations } from './mockData'; -const analytics = (date: { gte: Date; lt: Date }) => { +const analytics = (date: { gte: Date; lte: Date }) => { // filter the mockData array with the date param with moment - return conversations.filter((c) => moment(c.ts).isBetween(date.gte, date.lt)); + return conversations.filter((c) => moment(c.ts).isBetween(date.gte, date.lte, undefined, '[]')); }; describe('OverviewData Analytics', () => { @@ -184,7 +184,7 @@ describe('OverviewData Analytics', () => { }); it('should return all values as 0 when theres data but not on the period we pass', async () => { const overview = new OverviewData({ - getAnalyticsBetweenDate: () => analytics({ gte: moment().set('month', 9).toDate(), lt: moment().set('month', 9).toDate() }), + getAnalyticsBetweenDate: () => analytics({ gte: moment().set('month', 9).toDate(), lte: moment().set('month', 9).toDate() }), getOnHoldConversationsBetweenDate: () => 0, } as any); const result = await overview.Conversations(moment(), moment(), '', 'UTC', (v: string): string => v, {}); @@ -200,7 +200,7 @@ describe('OverviewData Analytics', () => { }); it('should return the correct values when theres data on the period we pass', async () => { const overview = new OverviewData({ - getAnalyticsBetweenDate: (date: { gte: Date; lt: Date }) => analytics(date), + getAnalyticsBetweenDate: (date: { gte: Date; lte: Date }) => analytics(date), getOnHoldConversationsBetweenDate: () => 1, } as any); @@ -223,6 +223,47 @@ describe('OverviewData Analytics', () => { { title: 'Busiest_time', value: '11AM - 12PM' }, ]); }); + it('should only return conversation metrics related to the provided period, and not consider previous or following days', async () => { + const overview = new OverviewData({ + getAnalyticsBetweenDate: (date: { gte: Date; lte: Date }) => analytics(date), + getOnHoldConversationsBetweenDate: () => 1, + } as any); + + // choosing this specific date since the day before and after are not empty + const targetDate = moment.utc().set('month', 10).set('year', 2023).set('date', 23); + + // Fixed date to assure we get the same data + const result = await overview.Conversations(targetDate.startOf('day'), targetDate.endOf('day'), '', 'UTC'); + expect(result).to.be.deep.equal([ + { title: 'Total_conversations', value: 1 }, + { title: 'Open_conversations', value: 0 }, + { title: 'On_Hold_conversations', value: 1 }, + { title: 'Total_messages', value: 14 }, + { title: 'Busiest_day', value: 'Thursday' }, + { title: 'Conversations_per_day', value: '1.00' }, + { title: 'Busiest_time', value: '7AM - 8AM' }, + ]); + }); + it('should return all values as 0 when there is no data in the provided period, but there is data in the previous and following days', async () => { + const overview = new OverviewData({ + getAnalyticsBetweenDate: (date: { gte: Date; lte: Date }) => analytics(date), + getOnHoldConversationsBetweenDate: () => 0, + } as any); + + // choosing this specific date since the day before and after are not empty + const targetDate = moment.utc().set('month', 10).set('year', 2023).set('date', 13); + + const result = await overview.Conversations(targetDate.startOf('day'), targetDate.endOf('day'), '', 'UTC'); + expect(result).to.be.deep.equal([ + { title: 'Total_conversations', value: 0 }, + { title: 'Open_conversations', value: 0 }, + { title: 'On_Hold_conversations', value: 0 }, + { title: 'Total_messages', value: 0 }, + { title: 'Busiest_day', value: '-' }, + { title: 'Conversations_per_day', value: '0.00' }, + { title: 'Busiest_time', value: '-' }, + ]); + }); }); describe('Productivity', () => { @@ -241,7 +282,7 @@ describe('OverviewData Analytics', () => { }); it('should return all values as 0 when theres data but not on the period we pass', async () => { const overview = new OverviewData({ - getAnalyticsMetricsBetweenDate: (_: any, date: { gte: Date; lt: Date }) => analytics(date), + getAnalyticsMetricsBetweenDate: (_: any, date: { gte: Date; lte: Date }) => analytics(date), } as any); const result = await overview.Productivity( moment().set('month', 9), @@ -259,7 +300,7 @@ describe('OverviewData Analytics', () => { }); it('should return the correct values when theres data on the period we pass', async () => { const overview = new OverviewData({ - getAnalyticsMetricsBetweenDate: (_: any, date: { gte: Date; lt: Date }) => analytics(date), + getAnalyticsMetricsBetweenDate: (_: any, date: { gte: Date; lte: Date }) => analytics(date), } as any); const result = await overview.Productivity( moment().set('month', 10).set('year', 2023).startOf('month'), @@ -274,5 +315,37 @@ describe('OverviewData Analytics', () => { { title: 'Avg_reaction_time', value: '00:00:49' }, ]); }); + it('should only return productivity metrics related to the provided period, and not consider previous or following days', async () => { + const overview = new OverviewData({ + getAnalyticsMetricsBetweenDate: (_: any, date: { gte: Date; lte: Date }) => analytics(date), + } as any); + + // choosing this specific date since the day before and after are not empty + const targetDate = moment().set('month', 10).set('year', 2023).set('date', 25); + + const result = await overview.Productivity(targetDate.startOf('day'), targetDate.clone().endOf('day'), '', 'UTC'); + + expect(result).to.be.deep.equal([ + { title: 'Avg_response_time', value: '00:00:01' }, + { title: 'Avg_first_response_time', value: '00:00:04' }, + { title: 'Avg_reaction_time', value: '00:02:03' }, + ]); + }); + it('should return all values as 0 when there is no data in the provided period, but there is data in the previous and following days', async () => { + const overview = new OverviewData({ + getAnalyticsMetricsBetweenDate: (_: any, date: { gte: Date; lte: Date }) => analytics(date), + } as any); + + // choosing this specific date since the day before and after are not empty + const targetDate = moment.utc().set('month', 10).set('year', 2023).set('date', 13); + + const result = await overview.Productivity(targetDate.startOf('day'), targetDate.endOf('day'), '', 'UTC'); + + expect(result).to.be.deep.equal([ + { title: 'Avg_response_time', value: '00:00:00' }, + { title: 'Avg_first_response_time', value: '00:00:00' }, + { title: 'Avg_reaction_time', value: '00:00:00' }, + ]); + }); }); }); diff --git a/apps/meteor/tests/unit/server/services/room/hooks/FederationActions.tests.ts b/apps/meteor/tests/unit/server/services/room/hooks/FederationActions.tests.ts new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/ee/apps/ddp-streamer/package.json b/ee/apps/ddp-streamer/package.json index fdee5d5d3b9a..1687042e0d31 100644 --- a/ee/apps/ddp-streamer/package.json +++ b/ee/apps/ddp-streamer/package.json @@ -15,7 +15,7 @@ ], "author": "Rocket.Chat", "dependencies": { - "@rocket.chat/apps-engine": "1.44.0", + "@rocket.chat/apps-engine": "1.45.0-alpha.866", "@rocket.chat/core-services": "workspace:^", "@rocket.chat/core-typings": "workspace:^", "@rocket.chat/emitter": "~0.31.25", diff --git a/ee/packages/presence/package.json b/ee/packages/presence/package.json index 21f1883b6704..787eb2053007 100644 --- a/ee/packages/presence/package.json +++ b/ee/packages/presence/package.json @@ -6,7 +6,7 @@ "@babel/core": "~7.22.20", "@babel/preset-env": "~7.22.20", "@babel/preset-typescript": "~7.22.15", - "@rocket.chat/apps-engine": "1.44.0", + "@rocket.chat/apps-engine": "1.45.0-alpha.866", "@rocket.chat/eslint-config": "workspace:^", "@rocket.chat/rest-typings": "workspace:^", "@types/node": "^14.18.63", diff --git a/ee/packages/ui-theming/package.json b/ee/packages/ui-theming/package.json index d1929c8b93f0..713265b36bfa 100644 --- a/ee/packages/ui-theming/package.json +++ b/ee/packages/ui-theming/package.json @@ -6,7 +6,7 @@ "@rocket.chat/css-in-js": "~0.31.25", "@rocket.chat/fuselage": "^0.57.0", "@rocket.chat/fuselage-hooks": "^0.33.1", - "@rocket.chat/icons": "^0.36.0", + "@rocket.chat/icons": "~0.38.0", "@rocket.chat/ui-contexts": "workspace:~", "@types/react": "~17.0.69", "eslint": "~8.45.0", diff --git a/packages/apps/package.json b/packages/apps/package.json index 15289501be4c..816e271535df 100644 --- a/packages/apps/package.json +++ b/packages/apps/package.json @@ -18,7 +18,7 @@ "/dist" ], "dependencies": { - "@rocket.chat/apps-engine": "1.44.0", + "@rocket.chat/apps-engine": "1.45.0-alpha.866", "@rocket.chat/core-typings": "workspace:^", "@rocket.chat/model-typings": "workspace:^" } diff --git a/packages/core-services/package.json b/packages/core-services/package.json index d576f87bef27..fe1d4ef0dcea 100644 --- a/packages/core-services/package.json +++ b/packages/core-services/package.json @@ -34,9 +34,9 @@ "extends": "../../package.json" }, "dependencies": { - "@rocket.chat/apps-engine": "1.44.0", + "@rocket.chat/apps-engine": "1.45.0-alpha.866", "@rocket.chat/core-typings": "workspace:^", - "@rocket.chat/icons": "^0.36.0", + "@rocket.chat/icons": "~0.38.0", "@rocket.chat/message-parser": "workspace:^", "@rocket.chat/models": "workspace:^", "@rocket.chat/rest-typings": "workspace:^", diff --git a/packages/core-services/src/index.ts b/packages/core-services/src/index.ts index ce51f4695aec..8eea19ea7405 100644 --- a/packages/core-services/src/index.ts +++ b/packages/core-services/src/index.ts @@ -59,7 +59,12 @@ export { IBroker, IBrokerNode, BaseMetricOptions, IServiceMetrics } from './type export { IServiceContext, ServiceClass, IServiceClass, ServiceClassInternal } from './types/ServiceClass'; -export { IFederationService, IFederationServiceEE, IFederationJoinExternalPublicRoomInput } from './types/IFederationService'; +export { + IFederationService, + IFederationServiceEE, + IFederationJoinExternalPublicRoomInput, + FederationConfigurationStatus, +} from './types/IFederationService'; export { ConversationData, @@ -138,8 +143,11 @@ export { IUserService, }; +const disabledEnvVar = String(process.env.DISABLE_DB_WATCHERS).toLowerCase(); + export const dbWatchersDisabled = - ['yes', 'true'].includes(String(process.env.DISABLE_DB_WATCHERS).toLowerCase()) || process.env.NODE_ENV !== 'production'; + (process.env.NODE_ENV === 'production' && ['yes', 'true'].includes(disabledEnvVar)) || + (process.env.NODE_ENV !== 'production' && !['no', 'false'].includes(disabledEnvVar)); // TODO think in a way to not have to pass the service name to proxify here as well export const Authorization = proxifyWithWait('authorization'); diff --git a/packages/core-services/src/types/IFederationService.ts b/packages/core-services/src/types/IFederationService.ts index 5563bd60db40..ffd1c1b009f6 100644 --- a/packages/core-services/src/types/IFederationService.ts +++ b/packages/core-services/src/types/IFederationService.ts @@ -1,10 +1,35 @@ +import type { IRoom } from '@rocket.chat/core-typings'; import type { FederationPaginatedResult, IFederationPublicRooms } from '@rocket.chat/rest-typings'; -export interface IFederationService { - createDirectMessageRoomAndInviteUser(internalInviterId: string, internalRoomId: string, externalInviteeId: string): Promise; +export type FederationConfigurationStatus = { + appservice: { + error?: string; + ok: boolean; + roundTrip: { + durationMs: number; + }; + }; + + externalReachability: { + error?: string; + ok: boolean; + }; +}; +interface IFederationBaseService { verifyMatrixIds(matrixIds: string[]): Promise>; + configurationStatus(): Promise; + + markConfigurationValid(): Promise; + + markConfigurationInvalid(): Promise; + + beforeCreateRoom(room: Partial): Promise; +} + +export interface IFederationService extends IFederationBaseService { + createDirectMessageRoomAndInviteUser(internalInviterId: string, internalRoomId: string, externalInviteeId: string): Promise; deactivateRemoteUser(userId: string): Promise; } @@ -15,7 +40,7 @@ export interface IFederationJoinExternalPublicRoomInput { pageToken?: string; } -export interface IFederationServiceEE { +export interface IFederationServiceEE extends IFederationBaseService { createDirectMessageRoom(internalUserId: string, invitees: string[]): Promise; searchPublicRooms( diff --git a/packages/core-services/src/types/IMessageService.ts b/packages/core-services/src/types/IMessageService.ts index 0563fc6f148d..ca84f78ea677 100644 --- a/packages/core-services/src/types/IMessageService.ts +++ b/packages/core-services/src/types/IMessageService.ts @@ -21,4 +21,6 @@ export interface IMessageService { deleteMessage(user: IUser, message: IMessage): Promise; updateMessage(message: IMessage, user: IUser, originalMsg?: IMessage): Promise; reactToMessage(userId: string, reaction: string, messageId: IMessage['_id'], shouldReact?: boolean): Promise; + beforeReacted(message: IMessage, room: IRoom): Promise; + beforeDelete(message: IMessage, room: IRoom): Promise; } diff --git a/packages/core-services/src/types/IRoomService.ts b/packages/core-services/src/types/IRoomService.ts index 23186590af50..36bf5dff2564 100644 --- a/packages/core-services/src/types/IRoomService.ts +++ b/packages/core-services/src/types/IRoomService.ts @@ -53,4 +53,8 @@ export interface IRoomService { ): Promise; getRouteLink(room: AtLeast): Promise; join(param: { room: IRoom; user: Pick; joinCode?: string }): Promise; + beforeLeave(room: IRoom): Promise; + beforeUserRemoved(room: IRoom): Promise; + beforeNameChange(room: IRoom): Promise; + beforeTopicChange(room: IRoom): Promise; } diff --git a/packages/core-typings/package.json b/packages/core-typings/package.json index 267e75e5c177..34bdc67438ca 100644 --- a/packages/core-typings/package.json +++ b/packages/core-typings/package.json @@ -22,8 +22,8 @@ "/dist" ], "dependencies": { - "@rocket.chat/apps-engine": "1.44.0", - "@rocket.chat/icons": "^0.36.0", + "@rocket.chat/apps-engine": "1.45.0-alpha.866", + "@rocket.chat/icons": "~0.38.0", "@rocket.chat/message-parser": "workspace:^", "@rocket.chat/ui-kit": "workspace:~" }, diff --git a/packages/core-typings/src/ILivechatContact.ts b/packages/core-typings/src/ILivechatContact.ts new file mode 100644 index 000000000000..149dab2b88b1 --- /dev/null +++ b/packages/core-typings/src/ILivechatContact.ts @@ -0,0 +1,25 @@ +import type { IRocketChatRecord } from './IRocketChatRecord'; + +export interface ILivechatContactChannel { + name: string; + verified: boolean; + visitorId: string; +} + +export interface ILivechatContactConflictingField { + field: string; + oldValue: string; + newValue: string; +} + +export interface ILivechatContact extends IRocketChatRecord { + name: string; + phones: string[]; + emails: string[]; + contactManager?: string; + unknown?: boolean; + hasConflict?: boolean; + conflictingFields?: ILivechatContactConflictingField[]; + customFields?: Record; + channels?: ILivechatContactChannel[]; +} diff --git a/packages/core-typings/src/IMessage/IMessage.ts b/packages/core-typings/src/IMessage/IMessage.ts index 694225dc71a4..205cbaccd466 100644 --- a/packages/core-typings/src/IMessage/IMessage.ts +++ b/packages/core-typings/src/IMessage/IMessage.ts @@ -22,90 +22,95 @@ export type MessageUrl = { parsedUrl?: Pick; }; -type VoipMessageTypesValues = - | 'voip-call-started' - | 'voip-call-declined' - | 'voip-call-on-hold' - | 'voip-call-unhold' - | 'voip-call-ended' - | 'voip-call-duration' - | 'voip-call-wrapup' - | 'voip-call-ended-unexpectedly'; - -type TeamMessageTypes = - | 'removed-user-from-team' - | 'added-user-to-team' - | 'ult' - | 'user-converted-to-team' - | 'user-converted-to-channel' - | 'user-removed-room-from-team' - | 'user-deleted-room-from-team' - | 'user-added-room-to-team' - | 'ujt'; - -type LivechatMessageTypes = - | 'livechat_navigation_history' - | 'livechat_transfer_history' - | 'omnichannel_priority_change_history' - | 'omnichannel_sla_change_history' - | 'livechat_transcript_history' - | 'livechat_video_call' - | 'livechat_transfer_history_fallback' - | 'livechat-close' - | 'livechat_webrtc_video_call' - | 'livechat-started'; - -type OmnichannelTypesValues = 'omnichannel_placed_chat_on_hold' | 'omnichannel_on_hold_chat_resumed'; - -type OtrMessageTypeValues = 'otr' | 'otr-ack'; - -export type OtrSystemMessages = 'user_joined_otr' | 'user_requested_otr_key_refresh' | 'user_key_refreshed_successfully'; - -export type MessageTypesValues = - | 'e2e' - | 'uj' - | 'ul' - | 'ru' - | 'au' - | 'mute_unmute' - | 'r' - | 'ut' - | 'wm' - | 'rm' - | 'subscription-role-added' - | 'subscription-role-removed' - | 'room-archived' - | 'room-unarchived' - | 'room_changed_privacy' - | 'room_changed_description' - | 'room_changed_announcement' - | 'room_changed_avatar' - | 'room_changed_topic' - | 'room_e2e_enabled' - | 'room_e2e_disabled' - | 'user-muted' - | 'user-unmuted' - | 'room-removed-read-only' - | 'room-set-read-only' - | 'room-allowed-reacting' - | 'room-disallowed-reacting' - | 'command' - | 'videoconf' - | 'message_pinned' - | 'message_pinned_e2e' - | 'new-moderator' - | 'moderator-removed' - | 'new-owner' - | 'owner-removed' - | 'new-leader' - | 'leader-removed' - | 'discussion-created' - | LivechatMessageTypes - | TeamMessageTypes - | VoipMessageTypesValues - | OmnichannelTypesValues - | OtrMessageTypeValues - | OtrSystemMessages; +const VoipMessageTypesValues = [ + 'voip-call-started', + 'voip-call-declined', + 'voip-call-on-hold', + 'voip-call-unhold', + 'voip-call-ended', + 'voip-call-duration', + 'voip-call-wrapup', + 'voip-call-ended-unexpectedly', +] as const; + +const TeamMessageTypesValues = [ + 'removed-user-from-team', + 'added-user-to-team', + 'ult', + 'user-converted-to-team', + 'user-converted-to-channel', + 'user-removed-room-from-team', + 'user-deleted-room-from-team', + 'user-added-room-to-team', + 'ujt', +] as const; + +const LivechatMessageTypesValues = [ + 'livechat_navigation_history', + 'livechat_transfer_history', + 'livechat_transcript_history', + 'livechat_video_call', + 'livechat_transfer_history_fallback', + 'livechat-close', + 'livechat_webrtc_video_call', + 'livechat-started', + 'omnichannel_priority_change_history', + 'omnichannel_sla_change_history', + 'omnichannel_placed_chat_on_hold', + 'omnichannel_on_hold_chat_resumed', +] as const; + +const OtrMessageTypeValues = ['otr', 'otr-ack'] as const; + +const OtrSystemMessagesValues = ['user_joined_otr', 'user_requested_otr_key_refresh', 'user_key_refreshed_successfully'] as const; +export type OtrSystemMessages = (typeof OtrSystemMessagesValues)[number]; + +const MessageTypes = [ + 'e2e', + 'uj', + 'ul', + 'ru', + 'au', + 'mute_unmute', + 'r', + 'ut', + 'wm', + 'rm', + 'subscription-role-added', + 'subscription-role-removed', + 'room-archived', + 'room-unarchived', + 'room_changed_privacy', + 'room_changed_description', + 'room_changed_announcement', + 'room_changed_avatar', + 'room_changed_topic', + 'room_e2e_enabled', + 'room_e2e_disabled', + 'user-muted', + 'user-unmuted', + 'room-removed-read-only', + 'room-set-read-only', + 'room-allowed-reacting', + 'room-disallowed-reacting', + 'command', + 'videoconf', + 'message_pinned', + 'message_pinned_e2e', + 'new-moderator', + 'moderator-removed', + 'new-owner', + 'owner-removed', + 'new-leader', + 'leader-removed', + 'discussion-created', + ...TeamMessageTypesValues, + ...LivechatMessageTypesValues, + ...VoipMessageTypesValues, + ...OtrMessageTypeValues, + ...OtrSystemMessagesValues, +] as const; +export type MessageTypesValues = (typeof MessageTypes)[number]; export type TokenType = 'code' | 'inlinecode' | 'bold' | 'italic' | 'strike' | 'link'; export type Token = { @@ -231,9 +236,9 @@ export interface IMessage extends IRocketChatRecord { }; } -export type MessageSystem = { - t: 'system'; -}; +export interface ISystemMessage extends IMessage { + t: MessageTypesValues; +} export interface IEditedMessage extends IMessage { editedAt: Date; @@ -249,6 +254,9 @@ export const isEditedMessage = (message: IMessage): message is IEditedMessage => '_id' in (message as IEditedMessage).editedBy && typeof (message as IEditedMessage).editedBy._id === 'string'; +export const isSystemMessage = (message: IMessage): message is ISystemMessage => + message.t !== undefined && MessageTypes.includes(message.t); + export const isDeletedMessage = (message: IMessage): message is IEditedMessage => isEditedMessage(message) && message.t === 'rm'; export const isMessageFromMatrixFederation = (message: IMessage): boolean => 'federation' in message && Boolean(message.federation?.eventId); diff --git a/packages/core-typings/src/IRoom.ts b/packages/core-typings/src/IRoom.ts index 4a2124e98b98..1cfb672d1573 100644 --- a/packages/core-typings/src/IRoom.ts +++ b/packages/core-typings/src/IRoom.ts @@ -53,15 +53,6 @@ export interface IRoom extends IRocketChatRecord { _id: string; }; - streamingOptions?: { - id?: string; - type?: string; - url?: string; - thumbnail?: string; - isAudioOnly?: boolean; - message?: string; - }; - prid?: string; avatarETag?: string; diff --git a/packages/core-typings/src/IStats.ts b/packages/core-typings/src/IStats.ts index 826396bbb9c7..671024f7aa02 100644 --- a/packages/core-typings/src/IStats.ts +++ b/packages/core-typings/src/IStats.ts @@ -177,7 +177,6 @@ export interface IStats { dashboardCount: number; joinJitsiButton: number; totalBroadcastRooms: number; - totalRoomsWithActiveLivestream: number; totalTriggeredEmails: number; totalRoomsWithStarred: number; totalRoomsWithPinned: number; diff --git a/packages/core-typings/src/IUpload.ts b/packages/core-typings/src/IUpload.ts index 64ad2ad933a5..8bcabee2fa8d 100644 --- a/packages/core-typings/src/IUpload.ts +++ b/packages/core-typings/src/IUpload.ts @@ -62,3 +62,12 @@ export interface IUpload { } export type IUploadWithUser = IUpload & { user?: Pick }; + +export type IE2EEUpload = IUpload & { + content: { + algorithm: string; // 'rc.v1.aes-sha2' + ciphertext: string; // Encrypted subset JSON of IUpload + }; +}; + +export const isE2EEUpload = (upload: IUpload): upload is IE2EEUpload => Boolean(upload?.content?.ciphertext && upload?.content?.algorithm); diff --git a/packages/core-typings/src/IUser.ts b/packages/core-typings/src/IUser.ts index 3c6d1c890d7b..fa411c6f7e47 100644 --- a/packages/core-typings/src/IUser.ts +++ b/packages/core-typings/src/IUser.ts @@ -152,9 +152,7 @@ export interface IUser extends IRocketChatRecord { private_key: string; public_key: string; }; - customFields?: { - [key: string]: any; - }; + customFields?: Record; settings?: IUserSettings; defaultRoom?: string; ldap?: boolean; diff --git a/packages/core-typings/src/index.ts b/packages/core-typings/src/index.ts index c04ffa998d77..5d2e2935a466 100644 --- a/packages/core-typings/src/index.ts +++ b/packages/core-typings/src/index.ts @@ -96,6 +96,7 @@ export * from './ILivechatCustomField'; export * from './IOmnichannel'; export * from './ILivechatAgentActivity'; export * from './ILivechatBusinessHour'; +export * from './ILivechatContact'; export * from './ILivechatVisitor'; export * from './ILivechatDepartmentAgents'; export * from './ILivechatAgent'; diff --git a/packages/fuselage-ui-kit/package.json b/packages/fuselage-ui-kit/package.json index 6d6b882c89d2..9431a0c19d96 100644 --- a/packages/fuselage-ui-kit/package.json +++ b/packages/fuselage-ui-kit/package.json @@ -63,13 +63,13 @@ "@babel/preset-env": "~7.22.20", "@babel/preset-react": "~7.22.15", "@babel/preset-typescript": "~7.22.15", - "@rocket.chat/apps-engine": "1.44.0", + "@rocket.chat/apps-engine": "1.45.0-alpha.866", "@rocket.chat/core-typings": "workspace:^", "@rocket.chat/eslint-config": "workspace:^", "@rocket.chat/fuselage": "^0.57.0", "@rocket.chat/fuselage-hooks": "^0.33.1", "@rocket.chat/fuselage-polyfills": "~0.31.25", - "@rocket.chat/icons": "^0.36.0", + "@rocket.chat/icons": "~0.38.0", "@rocket.chat/jest-presets": "workspace:~", "@rocket.chat/mock-providers": "workspace:^", "@rocket.chat/prettier-config": "~0.31.25", diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index c270bb9bffb1..159c1641e05e 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -121,8 +121,8 @@ "Accounts_Email_Approved_Subject": "Account approved", "Accounts_Email_Deactivated": "[name]

Your account was deactivated.

", "Accounts_Email_Deactivated_Subject": "Account deactivated", - "Accounts_EmailVerification": "Only allow verified users to login", - "Accounts_EmailVerification_Description": "Make sure you have correct SMTP settings to use this feature", + "Accounts_EmailVerification": "Require email verification to login", + "Accounts_EmailVerification_Description": "Ensure SMTP is configured to enable", "Accounts_Enrollment_Email": "Enrollment Email", "Accounts_Enrollment_Email_Default": "

Welcome to [Site_Name]

Go to [Site_URL] and try the best open source chat solution available today!

", "Accounts_Enrollment_Email_Description": "You may use the following placeholders: \n - `[name]`, `[fname]`, `[lname]` for the user's full name, first name or last name, respectively. \n - `[email]` for the user's email. \n - `[Site_Name]` and `[Site_URL]` for the Application Name and URL respectively. ", @@ -308,6 +308,7 @@ "Activate": "Activate", "Activation": "Activation", "Active": "Active", + "active": "active", "Active_users": "Active users", "Activity": "Activity", "Add": "Add", @@ -319,6 +320,7 @@ "Add_files_from": "Add files from", "Add_manager": "Add manager", "Add_monitor": "Add monitor", + "Add_more_users": "Add more users", "Add_link": "Add link", "Add_Reaction": "Add reaction", "Add_Role": "Add Role", @@ -329,6 +331,7 @@ "Add_User": "Add User", "Add_users": "Add users", "Add_members": "Add Members", + "add-to-room": "Add to room", "add-all-to-room": "Add all users to a room", "add-all-to-room_description": "Permission to add all users to a room", "add-livechat-department-agents": "Add Omnichannel Agents to Departments", @@ -986,6 +989,7 @@ "Channels_list": "List of public channels", "Channel_what_is_this_channel_about": "What is this channel about?", "Chart": "Chart", + "Chats": "Chats", "Chat_button": "Chat button", "Chat_close": "Chat Close", "Chat_closed": "Chat closed", @@ -1578,6 +1582,7 @@ "DDP_Rate_Limit_User_Requests_Allowed": "Limit by User: requests allowed", "Deactivate": "Deactivate", "Deactivated": "Deactivated", + "deactivated": "deactivated", "Decline": "Decline", "default": "default", "Default": "Default", @@ -1798,6 +1803,8 @@ "E2E_Enabled": "E2E Enabled", "E2E_Enabled_Default_DirectRooms": "Enable encryption for Direct Rooms by default", "E2E_Enabled_Default_PrivateRooms": "Enable encryption for Private Rooms by default", + "E2E_Enable_Encrypt_Files": "Encrypt files", + "E2E_Enable_Encrypt_Files_Description": "Encrypt files sent inside encrypted rooms. Check for possible conflicts in [file upload settings.](admin/settings/FileUpload)", "E2E_Encryption_Password_Change": "Change Encryption Password", "E2E_Encryption_Password_Explanation": "You can now create encrypted private groups and direct messages. You may also change existing private groups or DMs to encrypted.

This is end-to-end encryption so the key to encode/decode your messages will not be saved on the server. For that reason you need to store your password somewhere safe. You will be required to enter it on other devices you wish to use e2e encryption on.", "E2E_key_reset_email": "E2E Key Reset Notification", @@ -1892,6 +1899,7 @@ "Email_Inboxes": "Email inboxes", "Email_Inbox_has_been_added": "Email Inbox has been added", "Email_Inbox_has_been_removed": "Email Inbox has been removed", + "Email_is_required": "Email is required", "Email_Notification_Mode": "Offline Email Notifications", "Email_Notification_Mode_All": "Every Mention/DM", "Email_Notification_Mode_Disabled": "Disabled", @@ -1915,7 +1923,9 @@ "Email_verified": "Email verified", "Enterprise_Only": "Enterprise only", "Encrypted_field_hint": "Messages are end-to-end encrypted, search will not work and notifications may not show message content", + "Encrypted_file_not_allowed": "Encrypted file not allowed", "Email_sent": "Email sent", + "Email_verification_isnt_required": "Email verification to login is not required. To require, enable setting in Accounts > Registration", "Emoji": "Emoji", "Emoji_picker": "Emoji picker", "EmojiCustomFilesystem": "Custom Emoji Filesystem", @@ -1937,6 +1947,7 @@ "Enable_Svg_Favicon": "Enable SVG favicon", "Enable_timestamp": "Enable timestamp parsing in messages", "Enable_timestamp_description": "Enable timestamps to be parsed in messages", + "Enable_to_bypass_email_verification": "Enable to bypass email verification", "Enable_two-factor_authentication": "Enable two-factor authentication via TOTP", "Enable_two-factor_authentication_email": "Enable two-factor authentication via Email", "Enable_unlimited_apps": "Enable unlimited apps", @@ -1969,6 +1980,7 @@ "Engagement_Dashboard": "Engagement dashboard", "Enrich_your_workspace": "Enrich your workspace perspective with the engagement dashboard. Analyze practical usage statistics about your users, messages and channels. Included in Premium plans.", "Ensure_secure_workspace_access": "Ensure secure workspace access", + "ensure_email_address_valid": "Invalid email address", "Enter": "Enter", "Enter_a_custom_message": "Enter a custom message", "Enter_a_department_name": "Enter a department name", @@ -2328,6 +2340,8 @@ "Federation_Matrix_serve_well_known": "Serve Well Known", "Federation_Matrix_serve_well_known_Description": "Serve /.well-known/matrix/server and /.well-known/matrix/client directly from within Rocket.Chat instead of reverse proxy for federation", "Federation_Matrix_serve_well_known_Alert": "Keep this off if using DNS srv records for federation, or use a reverse proxy to return static JSON if federation traffic is heavy. Read mode.", + "Federation_Matrix_check_configuration": "Verify configuration", + "Federation_Matrix_configuration_status": "Configuration status", "Field": "Field", "Field_removed": "Field removed", "Field_required": "Field required", @@ -2566,6 +2580,7 @@ "Hi_username": "Hi [name]", "Hidden": "Hidden", "Hide": "Hide", + "Hide_additional_fields": "Hide additional fields", "Hide_counter": "Hide counter", "Hide_flextab": "Hide Contextual Bar by clicking outside of it", "Hide_Group_Warning": "Are you sure you want to hide the group \"%s\"?", @@ -3463,6 +3478,7 @@ "Managing_assets": "Managing assets", "Managing_integrations": "Managing integrations", "Manual_Selection": "Manual Selection", + "Manually_created_users_briefing": "Manually created users will initially be shown as pending. Once they log in for the first time, they will be shown as active.", "Manufacturing": "Manufacturing", "MapView_Enabled": "Enable Mapview", "MapView_Enabled_Description": "Enabling mapview will display a location share button on the right of the chat input field.", @@ -3471,6 +3487,7 @@ "Mark_all_as_read": "`%s` - Mark all messages (in all channels) as read", "Mark_as_read": "Mark as read", "Mark_as_unread": "Mark as unread", + "Mark_email_as_verified": "Mark email as verified", "Mark_read": "Mark Read", "Mark_unread": "Mark Unread", "Marketplace": "Marketplace", @@ -3826,6 +3843,7 @@ "New_Unit": "New Unit", "New_users": "New users", "New_user": "New user", + "New_user_manually_created": "New user manually created", "New_version_available_(s)": "New version available (%s)", "New_videocall_request": "New Video Call Request", "New_visitor_navigation": "New Navigation: {{history}}", @@ -4158,6 +4176,7 @@ "pdf_error_message": "Error generating PDF Transcript", "Peer_Password": "Peer Password", "Pending": "Pending", + "pending": "pending", "Pending_action": "Pending action", "Pending Avatars": "Pending Avatars", "Pending Files": "Pending Files", @@ -4722,6 +4741,7 @@ "Save_Mobile_Bandwidth": "Save Mobile Bandwidth", "Save_to_enable_this_action": "Save to enable this action", "Save_To_Webdav": "Save to WebDAV", + "Save_user": "Save user", "Save_your_encryption_password": "Save your encryption password", "Save_your_encryption_password_to_access": "Save your end-to-end encryption password to access", "save-all-canned-responses": "Save All Canned Responses", @@ -4825,7 +4845,7 @@ "Send_confirmation_email": "Send confirmation email", "Send_data_into_RocketChat_in_realtime": "Send data into Rocket.Chat in real-time.", "Send_email": "Send Email", - "Send_Email_SMTP_Warning": "To send this email you need to setup SMTP emailing server", + "Send_Email_SMTP_Warning": "Set up the SMTP server in email settings to enable.", "Send_invitation_email": "Send invitation email", "Send_invitation_email_error": "You haven't provided any valid email address.", "Send_invitation_email_info": "You can send multiple email invitations at once.", @@ -4885,9 +4905,11 @@ "Set_as_moderator": "Set as moderator", "Set_as_owner": "Set as owner", "Upload_app": "Upload App", + "Set_randomly_and_send_by_email": "Set randomly and send by email", "Set_random_password_and_send_by_email": "Set random password and send by email", "set-leader": "Set Leader", "set-leader_description": "Permission to set other users as leader of a channel", + "Set_manually": "Set manually", "set-moderator": "Set Moderator", "set-moderator_description": "Permission to set other users as moderator of a channel", "set-owner": "Set Owner", @@ -4914,6 +4936,7 @@ "shortcut_name": "shortcut name", "Should_be_a_URL_of_an_image": "Should be a URL of an image.", "Should_exists_a_user_with_this_username": "The user must already exist.", + "Show_additional_fields": "Show additional fields", "Show_agent_email": "Show agent email", "Show_agent_info": "Show agent information", "Show_all": "Show All", @@ -5109,7 +5132,7 @@ "Stats_Total_Uploads_Size": "Total Uploads Size", "Stats_Total_Users": "Total Users", "Status": "Status", - "StatusMessage": "Status Message", + "StatusMessage": "Status message", "StatusMessage_Change_Disabled": "Your Rocket.Chat administrator has disabled the changing of status messages", "StatusMessage_Changed_Successfully": "Status message changed successfully.", "StatusMessage_Placeholder": "What are you doing right now?", @@ -6041,6 +6064,7 @@ "You_have_a_new_message": "You have a new message", "You_have_been_muted": "You have been muted and cannot speak in this room", "You_have_been_removed_from__roomName_": "You've been removed from the room {{roomName}}", + "You_have_created_user": "You’ve created 1 user", "You_have_joined_a_new_call_with": "You have joined a new call with", "You_have_n_codes_remaining": "You have {{number}} codes remaining.", "You_have_not_verified_your_email": "You have not verified your email.", @@ -6120,6 +6144,7 @@ "registration.component.form.username": "Username", "registration.component.form.name": "Name", "registration.component.form.nameContainsInvalidChars": "Name contains invalid characters", + "registration.component.form.usernameContainsInvalidChars": "Username contains invalid characters", "registration.component.form.nameOptional": "Name optional", "registration.component.form.createAnAccount": "Create an account", "registration.component.form.userAlreadyExist": "Username already exists. Please try another username.", @@ -6289,6 +6314,11 @@ "Send_transcript": "Send transcript", "Undo_request": "Undo request", "No_permission": "No permission", + "Users_Table_Generic_No_users": "No %s users", + "Users_Table_no_all_users_description": "No Users found.", + "Users_Table_no_pending_users_description": "Users who are pending activation or have been manually created but haven't logged in yet appear here.", + "Users_Table_no_active_users_description": "Active users appear here.", + "Users_Table_no_deactivated_users_description": "Deactivated users appear here.", "Community_cap_description": "Community workspaces have a limit of 200 concurrent connections. If this limit is exceeded it will no longer be possible for users to see each others status. This does not affect sending and receiving of messages.", "Premium_cap_description": "Premium plans do not have a presence service limit.", "Service_status": "Service status", @@ -6363,6 +6393,7 @@ "App_will_lose_grandfathered_status": "**This {{context}} app will lose its grandfathered status.** \n \nWorkspaces on Community can have up to {{limit}} {{context}} apps enabled. Grandfathered apps count towards the limit but the limit is not applied to them.", "All_rooms": "All rooms", "All_visible": "All visible", + "all": "all", "Filter_by_room": "Filter by room type", "Filter_by_visibility": "Filter by visibility", "Theme_Appearence": "Theme Appearence", diff --git a/packages/i18n/src/locales/pt-BR.i18n.json b/packages/i18n/src/locales/pt-BR.i18n.json index 67c8f46888ad..f072bc626270 100644 --- a/packages/i18n/src/locales/pt-BR.i18n.json +++ b/packages/i18n/src/locales/pt-BR.i18n.json @@ -833,6 +833,7 @@ "Channels_list": "Lista de canais públicos", "Channel_what_is_this_channel_about": "Sobre o que é este canal?", "Chart": "Gráfico", + "Chats": "Conversas", "Chat_button": "Botão da conversa", "Chat_close": "Fechar conversa", "Chat_closed": "Conversa encerrada", @@ -4914,6 +4915,7 @@ "registration.component.form.username": "Nome de usuário", "registration.component.form.name": "Nome", "registration.component.form.nameContainsInvalidChars": "O nome contém caracteres inválidos", + "registration.component.form.usernameContainsInvalidChars": "O nome de usuário contém caracteres inválidos", "registration.component.form.userAlreadyExist": "O nome de usuário já existe. Tente outro nome de usuário.", "registration.component.form.emailAlreadyExists": "E-mail já existe", "registration.component.form.usernameAlreadyExists": "O nome de usuário já existe. Tente outro nome de usuário.", @@ -5014,4 +5016,4 @@ "Enterprise": "Enterprise", "UpgradeToGetMore_engagement-dashboard_Title": "Analytics", "UpgradeToGetMore_auditing_Title": "Auditoria de mensagem" -} \ No newline at end of file +} diff --git a/packages/model-typings/src/index.ts b/packages/model-typings/src/index.ts index 61ad5d1f5c55..83def2bd19b2 100644 --- a/packages/model-typings/src/index.ts +++ b/packages/model-typings/src/index.ts @@ -21,6 +21,7 @@ export * from './models/IInvitesModel'; export * from './models/IImportDataModel'; export * from './models/ILivechatAgentActivityModel'; export * from './models/ILivechatBusinessHoursModel'; +export * from './models/ILivechatContactsModel'; export * from './models/ILivechatCustomFieldModel'; export * from './models/ILivechatDepartmentAgentsModel'; export * from './models/ILivechatDepartmentModel'; diff --git a/packages/model-typings/src/models/IBaseModel.ts b/packages/model-typings/src/models/IBaseModel.ts index 246c3ae253dd..626f91385a04 100644 --- a/packages/model-typings/src/models/IBaseModel.ts +++ b/packages/model-typings/src/models/IBaseModel.ts @@ -9,6 +9,7 @@ import type { EnhancedOmit, Filter, FindCursor, + FindOneAndDeleteOptions, FindOneAndUpdateOptions, FindOptions, InsertManyResult, @@ -53,6 +54,7 @@ export interface IBaseModel< getUpdater(): Updater; updateFromUpdater(query: Filter, updater: Updater): Promise; + findOneAndDelete(filter: Filter, options?: FindOneAndDeleteOptions): Promise>; findOneAndUpdate(query: Filter, update: UpdateFilter | T, options?: FindOneAndUpdateOptions): Promise>; findOneById(_id: T['_id'], options?: FindOptions | undefined): Promise; @@ -93,7 +95,7 @@ export interface IBaseModel< deleteOne(filter: Filter, options?: DeleteOptions & { bypassDocumentValidation?: boolean }): Promise; - deleteMany(filter: Filter, options?: DeleteOptions): Promise; + deleteMany(filter: Filter, options?: DeleteOptions & { onTrash?: (record: ResultFields) => void }): Promise; // Trash trashFind

( diff --git a/packages/model-typings/src/models/ILivechatContactsModel.ts b/packages/model-typings/src/models/ILivechatContactsModel.ts new file mode 100644 index 000000000000..bcf48a837400 --- /dev/null +++ b/packages/model-typings/src/models/ILivechatContactsModel.ts @@ -0,0 +1,5 @@ +import type { ILivechatContact } from '@rocket.chat/core-typings'; + +import type { IBaseModel } from './IBaseModel'; + +export type ILivechatContactsModel = IBaseModel; diff --git a/packages/model-typings/src/models/ILivechatInquiryModel.ts b/packages/model-typings/src/models/ILivechatInquiryModel.ts index 6f16c3337b4d..3535b65c3518 100644 --- a/packages/model-typings/src/models/ILivechatInquiryModel.ts +++ b/packages/model-typings/src/models/ILivechatInquiryModel.ts @@ -4,7 +4,7 @@ import type { LivechatInquiryStatus, OmnichannelSortingMechanismSettingType, } from '@rocket.chat/core-typings'; -import type { FindOptions, DistinctOptions, Document, UpdateResult, DeleteResult, FindCursor } from 'mongodb'; +import type { FindOptions, DistinctOptions, Document, UpdateResult, DeleteResult, FindCursor, DeleteOptions } from 'mongodb'; import type { IBaseModel } from './IBaseModel'; @@ -26,7 +26,7 @@ export interface ILivechatInquiryModel extends IBaseModel & { position: number })[]>; - removeByRoomId(rid: string): Promise; + removeByRoomId(rid: string, options?: DeleteOptions): Promise; getQueuedInquiries(options?: FindOptions): FindCursor; takeInquiry(inquiryId: string): Promise; openInquiry(inquiryId: string): Promise; diff --git a/packages/model-typings/src/models/ILivechatRoomsModel.ts b/packages/model-typings/src/models/ILivechatRoomsModel.ts index 3a9eb98d57c4..337986df576d 100644 --- a/packages/model-typings/src/models/ILivechatRoomsModel.ts +++ b/packages/model-typings/src/models/ILivechatRoomsModel.ts @@ -6,7 +6,7 @@ import type { ILivechatVisitor, MACStats, } from '@rocket.chat/core-typings'; -import type { FindCursor, UpdateResult, AggregationCursor, Document, FindOptions, DeleteResult, Filter } from 'mongodb'; +import type { FindCursor, UpdateResult, AggregationCursor, Document, FindOptions, DeleteResult, Filter, UpdateOptions } from 'mongodb'; import type { FindPaginated } from '..'; import type { Updater } from '../updater'; @@ -133,7 +133,7 @@ export interface ILivechatRoomsModel extends IBaseModel { ): Promise; unsetEmailTranscriptRequestedByRoomId(rid: string): Promise; - closeRoomById(roomId: string, closeInfo: IOmnichannelRoomClosingInfo): Promise; + closeRoomById(roomId: string, closeInfo: IOmnichannelRoomClosingInfo, options?: UpdateOptions): Promise; bulkRemoveDepartmentAndUnitsFromRooms(departmentId: string): Promise; findOneByIdOrName(_idOrName: string, options?: FindOptions): Promise; @@ -225,22 +225,22 @@ export interface ILivechatRoomsModel extends IBaseModel { message: IMessage, updater?: Updater, ): Updater; - getTotalConversationsBetweenDate(t: 'l', date: { gte: Date; lt: Date }, data?: { departmentId: string }): Promise; + getTotalConversationsBetweenDate(t: 'l', date: { gte: Date; lte: Date }, data?: { departmentId: string }): Promise; getAnalyticsMetricsBetweenDate( t: 'l', - date: { gte: Date; lt: Date }, + date: { gte: Date; lte: Date }, data?: { departmentId?: string }, extraQuery?: Filter, ): FindCursor>; getAnalyticsMetricsBetweenDateWithMessages( t: string, - date: { gte: Date; lt: Date }, + date: { gte: Date; lte: Date }, data?: { departmentId?: string }, extraQuery?: Document, extraMatchers?: Document, ): AggregationCursor>; getAnalyticsBetweenDate( - date: { gte: Date; lt: Date }, + date: { gte: Date; lte: Date }, data?: { departmentId: string }, ): AggregationCursor>; findOpenByAgent(userId: string, extraQuery?: Filter): FindCursor; diff --git a/packages/model-typings/src/models/ILivechatVisitorsModel.ts b/packages/model-typings/src/models/ILivechatVisitorsModel.ts index 2e4441226661..3e17fc2a5962 100644 --- a/packages/model-typings/src/models/ILivechatVisitorsModel.ts +++ b/packages/model-typings/src/models/ILivechatVisitorsModel.ts @@ -76,4 +76,5 @@ export interface ILivechatVisitorsModel extends IBaseModel { _id: string, data: { name?: string; username?: string; email?: string; phone?: string; livechatData: { [k: string]: any } }, ): Promise; + setLastChatById(_id: string, lastChat: Required): Promise; } diff --git a/packages/model-typings/src/models/IOAuthAppsModel.ts b/packages/model-typings/src/models/IOAuthAppsModel.ts index 8f21ba16d2b7..859c972e4597 100644 --- a/packages/model-typings/src/models/IOAuthAppsModel.ts +++ b/packages/model-typings/src/models/IOAuthAppsModel.ts @@ -11,6 +11,7 @@ export interface IOAuthAppsModel extends IBaseModel { | { _id: string; }, + options?: FindOptions, ): Promise; findOneActiveByClientId(clientId: string, options?: FindOptions): Promise; diff --git a/packages/model-typings/src/models/IRoomsModel.ts b/packages/model-typings/src/models/IRoomsModel.ts index 1808c9f361f4..498a3c6b4bbc 100644 --- a/packages/model-typings/src/models/IRoomsModel.ts +++ b/packages/model-typings/src/models/IRoomsModel.ts @@ -100,7 +100,7 @@ export interface IRoomsModel extends IBaseModel { findDefaultRoomsForTeam(teamId: any): FindCursor; - incUsersCountByIds(ids: Array, inc: number): Promise; + incUsersCountByIds(ids: Array, inc: number, options?: UpdateOptions): Promise; findOneByNameOrFname(name: NonNullable, options?: FindOptions): Promise; @@ -112,8 +112,6 @@ export interface IRoomsModel extends IBaseModel { findByBroadcast(options?: FindOptions): FindCursor; - findByActiveLivestream(options?: FindOptions): FindCursor; - setAsFederated(roomId: IRoom['_id']): Promise; setRoomTypeById(roomId: IRoom['_id'], roomType: IRoom['t']): Promise; @@ -176,7 +174,6 @@ export interface IRoomsModel extends IBaseModel { setLastMessagePinned(roomId: string, pinnedBy: unknown, pinned?: boolean, pinnedAt?: Date): Promise; setLastMessageAsRead(roomId: string): Promise; setDescriptionById(roomId: string, description: string): Promise; - setStreamingOptionsById(roomId: string, streamingOptions: IRoom['streamingOptions']): Promise; setReadOnlyById(roomId: string, readOnly: NonNullable): Promise; setDmReadOnlyByUserId( roomId: string, diff --git a/packages/model-typings/src/models/ISubscriptionsModel.ts b/packages/model-typings/src/models/ISubscriptionsModel.ts index 91398e77ebe4..78fb38c8704b 100644 --- a/packages/model-typings/src/models/ISubscriptionsModel.ts +++ b/packages/model-typings/src/models/ISubscriptionsModel.ts @@ -10,6 +10,8 @@ import type { InsertOneResult, InsertManyResult, AggregationCursor, + DeleteOptions, + CountDocumentsOptions, } from 'mongodb'; import type { IBaseModel } from './IBaseModel'; @@ -41,7 +43,7 @@ export interface ISubscriptionsModel extends IBaseModel { readThreads?: boolean, alert?: boolean, options?: FindOptions, - ): ReturnType['update']>; + ): ReturnType['updateOne']>; removeRolesByUserId(uid: IUser['_id'], roles: IRole['_id'][], rid: IRoom['_id']): Promise; @@ -73,7 +75,23 @@ export interface ISubscriptionsModel extends IBaseModel { findByUserIdAndTypes(userId: string, types: ISubscription['t'][], options?: FindOptions): FindCursor; - removeByRoomId(roomId: string): Promise; + findByRoomIdAndNotAlertOrOpenExcludingUserIds( + filter: { + roomId: ISubscription['rid']; + uidsExclude?: ISubscription['u']['_id'][]; + uidsInclude?: ISubscription['u']['_id'][]; + onlyRead: boolean; + }, + options?: FindOptions, + ): FindCursor; + + removeByRoomId(roomId: ISubscription['rid'], options?: DeleteOptions & { onTrash: (doc: ISubscription) => void }): Promise; + + findByRoomIdExcludingUserIds( + roomId: ISubscription['rid'], + userIds: ISubscription['u']['_id'][], + options?: FindOptions, + ): FindCursor; findConnectedUsersExcept( userId: string, @@ -95,7 +113,7 @@ export interface ISubscriptionsModel extends IBaseModel { updateNameAndFnameByRoomId(roomId: string, name: string, fname: string): Promise; - setGroupE2EKey(_id: string, key: string): Promise; + setGroupE2EKey(_id: string, key: string): Promise; setGroupE2ESuggestedKey(uid: string, rid: string, key: string): Promise; @@ -119,9 +137,10 @@ export interface ISubscriptionsModel extends IBaseModel { updateAutoTranslateLanguageById(_id: string, autoTranslateLanguage: string): Promise; removeByVisitorToken(token: string): Promise; + findByToken(token: string, options?: FindOptions): FindCursor; updateMuteGroupMentions(_id: string, muteGroupMentions: boolean): Promise; - findByRoomIds(roomIds: string[]): FindCursor; + findByRoomIds(roomIds: ISubscription['u']['_id'][], options?: FindOptions): FindCursor; changeDepartmentByRoomId(rid: string, department: string): Promise; roleBaseQuery(userId: string, scope?: string): Filter | void; @@ -137,7 +156,24 @@ export interface ISubscriptionsModel extends IBaseModel { findByUserId(userId: string, options?: FindOptions): FindCursor; cachedFindByUserId(userId: string, options?: FindOptions): FindCursor; updateAutoTranslateById(_id: string, autoTranslate: boolean): Promise; + updateAllAutoTranslateLanguagesByUserId(userId: IUser['_id'], language: string): Promise; + findByAutoTranslateAndUserId( + userId: ISubscription['u']['_id'], + autoTranslate?: ISubscription['autoTranslate'], + options?: FindOptions, + ): FindCursor; + + findByUserIdAndRoomType( + userId: ISubscription['u']['_id'], + type: ISubscription['t'], + options?: FindOptions, + ): FindCursor; + findByNameAndRoomType( + filter: Partial>, + options?: FindOptions, + ): FindCursor; + disableAutoTranslateByRoomId(roomId: IRoom['_id']): Promise; findAlwaysNotifyDesktopUsersByRoomId(roomId: string): FindCursor; @@ -166,7 +202,11 @@ export interface ISubscriptionsModel extends IBaseModel { options?: FindOptions, ): FindCursor; findByRoomIdAndRoles(roomId: string, roles: string[], options?: FindOptions): FindCursor; - findByRoomIdAndUserIds(roomId: string, userIds: string[], options?: FindOptions): FindCursor; + findByRoomIdAndUserIds( + roomId: ISubscription['rid'], + userIds: ISubscription['u']['_id'][], + options?: FindOptions, + ): FindCursor; findByUserIdUpdatedAfter(userId: string, updatedAt: Date, options?: FindOptions): FindCursor; findByRoomIdAndUserIdsOrAllMessages(roomId: string, userIds: string[]): FindCursor; @@ -203,7 +243,7 @@ export interface ISubscriptionsModel extends IBaseModel { updateCustomFieldsByRoomId(rid: string, cfields: Record): Promise; setOpenForRoomIdAndUserIds(roomId: string, uids: string[]): Promise; - setAlertForRoomIdAndUserIds(roomId: string, uids: string[]): Promise; + setAlertForRoomIdAndUserIds(roomId: ISubscription['rid'], uids: ISubscription['u']['_id'][]): Promise; updateTypeByRoomId(roomId: string, type: ISubscription['t']): Promise; setBlockedByRoomId(rid: string, blocked: string, blocker: string): Promise; incUserMentionsAndUnreadForRoomIdAndUserIds( @@ -227,6 +267,12 @@ export interface ISubscriptionsModel extends IBaseModel { notificationField: keyof ISubscription, notificationOriginField: keyof ISubscription, ): Promise; + findByUserPreferences( + userId: string, + notificationOriginField: keyof ISubscription, + originFieldNotEqualValue: 'user' | 'subscription', + options?: FindOptions, + ): FindCursor; clearNotificationUserPreferences( userId: string, notificationField: string, @@ -239,9 +285,9 @@ export interface ISubscriptionsModel extends IBaseModel { users: { user: AtLeast; extraData: Record }[], ): Promise>; removeByRoomIdsAndUserId(rids: string[], userId: string): Promise; - removeByRoomIdAndUserId(roomId: string, userId: string): Promise; + removeByRoomIdAndUserId(roomId: string, userId: string): Promise; - removeByRoomIds(rids: string[]): Promise; + removeByRoomIds(rids: string[], options?: { onTrash: (doc: ISubscription) => void }): Promise; addUnreadThreadByRoomIdAndUserIds( rid: string, @@ -252,8 +298,14 @@ export interface ISubscriptionsModel extends IBaseModel { removeUnreadThreadByRoomIdAndUserId(rid: string, userId: string, tmid: string, clearAlert?: boolean): Promise; removeUnreadThreadsByRoomId(rid: string, tunread: string[]): Promise; + findUnreadThreadsByRoomId( + rid: ISubscription['rid'], + tunread: ISubscription['tunread'], + options?: FindOptions, + ): FindCursor; + countByRoomIdAndRoles(roomId: string, roles: string[]): Promise; - countByRoomId(roomId: string): Promise; + countByRoomId(roomId: string, options?: CountDocumentsOptions): Promise; countByUserId(userId: string): Promise; openByRoomIdAndUserId(roomId: string, userId: string): Promise; countByRoomIdAndNotUserId(rid: string, uid: string): Promise; diff --git a/packages/model-typings/src/models/IUsersModel.ts b/packages/model-typings/src/models/IUsersModel.ts index ae6a8f4b125f..bd660c05191d 100644 --- a/packages/model-typings/src/models/IUsersModel.ts +++ b/packages/model-typings/src/models/IUsersModel.ts @@ -9,7 +9,17 @@ import type { AtLeast, ILivechatAgentStatus, } from '@rocket.chat/core-typings'; -import type { Document, UpdateResult, FindCursor, FindOptions, Filter, InsertOneResult, DeleteResult, ModifyResult } from 'mongodb'; +import type { + Document, + UpdateResult, + FindCursor, + FindOptions, + Filter, + InsertOneResult, + DeleteResult, + ModifyResult, + UpdateOptions, +} from 'mongodb'; import type { FindPaginated, IBaseModel } from './IBaseModel'; @@ -190,7 +200,7 @@ export interface IUsersModel extends IBaseModel { setAsFederated(userId: string): any; - removeRoomByRoomId(rid: any): any; + removeRoomByRoomId(rid: any, options?: UpdateOptions): any; findOneByResetToken(token: string, options: FindOptions): Promise; diff --git a/packages/models/src/index.ts b/packages/models/src/index.ts index 655a94923feb..eb357ed293ef 100644 --- a/packages/models/src/index.ts +++ b/packages/models/src/index.ts @@ -20,6 +20,7 @@ import type { IImportDataModel, ILivechatAgentActivityModel, ILivechatBusinessHoursModel, + ILivechatContactsModel, ILivechatCustomFieldModel, ILivechatDepartmentAgentsModel, ILivechatDepartmentModel, @@ -117,6 +118,7 @@ export const Integrations = proxify('IIntegrationsModel'); export const Invites = proxify('IInvitesModel'); export const LivechatAgentActivity = proxify('ILivechatAgentActivityModel'); export const LivechatBusinessHours = proxify('ILivechatBusinessHoursModel'); +export const LivechatContacts = proxify('ILivechatContactsModel'); export const LivechatCustomField = proxify('ILivechatCustomFieldModel'); export const LivechatDepartmentAgents = proxify('ILivechatDepartmentAgentsModel'); export const LivechatDepartment = proxify('ILivechatDepartmentModel'); diff --git a/packages/rest-typings/package.json b/packages/rest-typings/package.json index 6a5bf5464e98..53bd1d2f5cb2 100644 --- a/packages/rest-typings/package.json +++ b/packages/rest-typings/package.json @@ -23,7 +23,7 @@ "/dist" ], "dependencies": { - "@rocket.chat/apps-engine": "1.44.0", + "@rocket.chat/apps-engine": "1.45.0-alpha.866", "@rocket.chat/core-typings": "workspace:^", "@rocket.chat/message-parser": "workspace:^", "@rocket.chat/ui-kit": "workspace:~", diff --git a/packages/rest-typings/src/v1/omnichannel.ts b/packages/rest-typings/src/v1/omnichannel.ts index b8519bf8fe02..c15e94030de3 100644 --- a/packages/rest-typings/src/v1/omnichannel.ts +++ b/packages/rest-typings/src/v1/omnichannel.ts @@ -1211,6 +1211,49 @@ const POSTOmnichannelContactSchema = { export const isPOSTOmnichannelContactProps = ajv.compile(POSTOmnichannelContactSchema); +type POSTOmnichannelContactsProps = { + name: string; + emails: string[]; + phones: string[]; + customFields?: Record; + contactManager?: string; +}; + +const POSTOmnichannelContactsSchema = { + type: 'object', + properties: { + name: { + type: 'string', + }, + emails: { + type: 'array', + items: { + type: 'string', + }, + uniqueItems: true, + }, + phones: { + type: 'array', + items: { + type: 'string', + }, + uniqueItems: true, + }, + customFields: { + type: 'object', + nullable: true, + }, + contactManager: { + type: 'string', + nullable: true, + }, + }, + required: ['name', 'emails', 'phones'], + additionalProperties: false, +}; + +export const isPOSTOmnichannelContactsProps = ajv.compile(POSTOmnichannelContactsSchema); + type GETOmnichannelContactProps = { contactId: string }; const GETOmnichannelContactSchema = { @@ -3649,6 +3692,10 @@ export type OmnichannelEndpoints = { GET: (params: GETOmnichannelContactProps) => { contact: ILivechatVisitor | null }; }; + '/v1/omnichannel/contacts': { + POST: (params: POSTOmnichannelContactsProps) => { contactId: string }; + }; + '/v1/omnichannel/contact.search': { GET: (params: GETOmnichannelContactSearchProps) => { contact: ILivechatVisitor | null }; }; diff --git a/packages/rest-typings/src/v1/rooms.ts b/packages/rest-typings/src/v1/rooms.ts index 1c0b6a360f7b..cefa1321402f 100644 --- a/packages/rest-typings/src/v1/rooms.ts +++ b/packages/rest-typings/src/v1/rooms.ts @@ -447,6 +447,21 @@ const GETRoomsNameExistsSchema = { export const isGETRoomsNameExists = ajv.compile(GETRoomsNameExistsSchema); +type RoomsIsMemberProps = { roomId: string } & ({ username: string } | { userId: string }); + +const RoomsIsMemberPropsSchema = { + type: 'object', + properties: { + roomId: { type: 'string', minLength: 1 }, + userId: { type: 'string', minLength: 1 }, + username: { type: 'string', minLength: 1 }, + }, + oneOf: [{ required: ['roomId', 'userId'] }, { required: ['roomId', 'username'] }], + additionalProperties: false, +}; + +export const isRoomsIsMemberProps = ajv.compile(RoomsIsMemberPropsSchema); + export type Notifications = { disableNotifications: string; muteGroupMentions: string; @@ -685,6 +700,10 @@ export type RoomsEndpoints = { }>; }; + '/v1/rooms.isMember': { + GET: (params: RoomsIsMemberProps) => { isMember: boolean }; + }; + '/v1/rooms.muteUser': { POST: (params: RoomsMuteUnmuteUser) => void; }; diff --git a/packages/rest-typings/src/v1/users/UserCreateParamsPOST.ts b/packages/rest-typings/src/v1/users/UserCreateParamsPOST.ts index 347498999011..49fb8b2f6912 100644 --- a/packages/rest-typings/src/v1/users/UserCreateParamsPOST.ts +++ b/packages/rest-typings/src/v1/users/UserCreateParamsPOST.ts @@ -19,7 +19,7 @@ export type UserCreateParamsPOST = { setRandomPassword?: boolean; sendWelcomeEmail?: boolean; verified?: boolean; - customFields?: object; + customFields?: Record; /* @deprecated */ fields: string; }; diff --git a/packages/ui-client/package.json b/packages/ui-client/package.json index 69ca7e8b7f5b..f43b2d8622b8 100644 --- a/packages/ui-client/package.json +++ b/packages/ui-client/package.json @@ -8,7 +8,7 @@ "@rocket.chat/css-in-js": "~0.31.25", "@rocket.chat/fuselage": "^0.57.0", "@rocket.chat/fuselage-hooks": "^0.33.1", - "@rocket.chat/icons": "^0.36.0", + "@rocket.chat/icons": "~0.38.0", "@rocket.chat/jest-presets": "workspace:~", "@rocket.chat/mock-providers": "workspace:^", "@rocket.chat/ui-contexts": "workspace:~", diff --git a/packages/ui-client/src/components/MultiSelectCustom/MultiSelectCustom.tsx b/packages/ui-client/src/components/MultiSelectCustom/MultiSelectCustom.tsx index a90cfb1bd1c6..0855e5fc68c5 100644 --- a/packages/ui-client/src/components/MultiSelectCustom/MultiSelectCustom.tsx +++ b/packages/ui-client/src/components/MultiSelectCustom/MultiSelectCustom.tsx @@ -1,6 +1,5 @@ import { Box, Button } from '@rocket.chat/fuselage'; import { useOutsideClick, useToggle } from '@rocket.chat/fuselage-hooks'; -import type { TranslationKey } from '@rocket.chat/ui-contexts'; import type { ComponentProps, FormEvent, ReactElement, RefObject } from 'react'; import { useCallback, useRef } from 'react'; @@ -33,7 +32,6 @@ export type OptionProp = { @param selectedOptionsTitle dropdown text after clicking one or more options. For example: 'Rooms (3)' * @param selectedOptions array with clicked options. This is used in the useFilteredTypeRooms hook, to filter the Rooms' table, for example. This array joins all of the individual clicked options from all available MultiSelectCustom components in the page. It helps to create a union filter for all the selections. * @param setSelectedOptions part of an useState hook to set the previous selectedOptions - * @param customSetSelected part of an useState hook to set the individual selected checkboxes from this instance. * @param searchBarText optional text prop that creates a search bar inside the dropdown, when added. * @returns a React Component that should be used with a custom hook for filters, such as useFilteredTypeRooms.tsx. * Check out the following files, for examples: @@ -43,11 +41,11 @@ export type OptionProp = { */ type DropDownProps = { dropdownOptions: OptionProp[]; - defaultTitle: TranslationKey; - selectedOptionsTitle: TranslationKey; + defaultTitle: string; + selectedOptionsTitle: string; selectedOptions: OptionProp[]; setSelectedOptions: (roles: OptionProp[]) => void; - searchBarText?: TranslationKey; + searchBarText?: string; } & ComponentProps; export const MultiSelectCustom = ({ @@ -77,20 +75,26 @@ export const MultiSelectCustom = ({ useOutsideClick([target], onClose); - const onSelect = (item: OptionProp, e?: FormEvent): void => { - e?.stopPropagation(); - item.checked = !item.checked; + const onSelect = useCallback( + (selectedOption: OptionProp, e?: FormEvent): void => { + e?.stopPropagation(); - if (item.checked === true) { - setSelectedOptions([...new Set([...selectedOptions, item])]); - return; - } + if (selectedOption.hasOwnProperty('checked')) { + selectedOption.checked = !selectedOption.checked; - // the user has disabled this option -> remove this from the selected options list - setSelectedOptions(selectedOptions.filter((option: OptionProp) => option.id !== item.id)); - }; + if (selectedOption.checked) { + setSelectedOptions([...new Set([...selectedOptions, selectedOption])]); + return; + } - const count = dropdownOptions.filter((option) => option.checked).length; + // the user has disabled this option -> remove this from the selected options list + setSelectedOptions(selectedOptions.filter((option: OptionProp) => option.id !== selectedOption.id)); + } + }, + [selectedOptions, setSelectedOptions], + ); + + const selectedOptionsCount = dropdownOptions.filter((option) => option.hasOwnProperty('checked') && option.checked).length; return ( @@ -101,7 +105,7 @@ export const MultiSelectCustom = ({ onKeyDown={(e) => (e.code === 'Enter' || e.code === 'Space') && toggleCollapsed(!collapsed)} defaultTitle={defaultTitle} selectedOptionsTitle={selectedOptionsTitle} - selectedOptionsCount={count} + selectedOptionsCount={selectedOptionsCount} maxCount={dropdownOptions.length} {...props} /> diff --git a/packages/ui-client/src/components/MultiSelectCustom/MultiSelectCustomAnchor.tsx b/packages/ui-client/src/components/MultiSelectCustom/MultiSelectCustomAnchor.tsx index 3a03673bc701..acd1e1eb8d6b 100644 --- a/packages/ui-client/src/components/MultiSelectCustom/MultiSelectCustomAnchor.tsx +++ b/packages/ui-client/src/components/MultiSelectCustom/MultiSelectCustomAnchor.tsx @@ -7,8 +7,8 @@ import { forwardRef } from 'react'; type MultiSelectCustomAnchorProps = { collapsed: boolean; - defaultTitle: TranslationKey; - selectedOptionsTitle: TranslationKey; + defaultTitle: string; + selectedOptionsTitle: string; selectedOptionsCount: number; maxCount: number; } & ComponentProps; @@ -37,7 +37,7 @@ const MultiSelectCustomAnchor = forwardRef - {isDirty ? `${t(selectedOptionsTitle)} (${selectedOptionsCount})` : t(defaultTitle)} + {isDirty ? `${t(selectedOptionsTitle as TranslationKey)} (${selectedOptionsCount})` : t(defaultTitle as TranslationKey)} ); diff --git a/packages/ui-client/src/components/MultiSelectCustom/MultiSelectCustomList.tsx b/packages/ui-client/src/components/MultiSelectCustom/MultiSelectCustomList.tsx index 4afe036e74d2..d73d9ce6b88b 100644 --- a/packages/ui-client/src/components/MultiSelectCustom/MultiSelectCustomList.tsx +++ b/packages/ui-client/src/components/MultiSelectCustom/MultiSelectCustomList.tsx @@ -4,7 +4,7 @@ import { useTranslation } from '@rocket.chat/ui-contexts'; import type { FormEvent } from 'react'; import { Fragment, useCallback, useState } from 'react'; -import type { OptionProp } from './MultiSelectCustom'; +import { type OptionProp } from './MultiSelectCustom'; import { useFilteredOptions } from './useFilteredOptions'; const MultiSelectCustomList = ({ @@ -14,7 +14,7 @@ const MultiSelectCustomList = ({ }: { options: OptionProp[]; onSelected: (item: OptionProp, e?: FormEvent) => void; - searchBarText?: TranslationKey; + searchBarText?: string; }) => { const t = useTranslation(); @@ -25,33 +25,33 @@ const MultiSelectCustomList = ({ const filteredOptions = useFilteredOptions(text, options); return ( - + {searchBarText && ( - + )} {filteredOptions.map((option) => ( - {option.hasOwnProperty('checked') ? ( + {!option.hasOwnProperty('checked') ? ( + + {t(option.text as TranslationKey)} + + ) : ( - ) : ( - - {t(option.text as TranslationKey)} - )} ))} diff --git a/packages/ui-client/src/components/MultiSelectCustom/MultiSelectCustomListWrapper.tsx b/packages/ui-client/src/components/MultiSelectCustom/MultiSelectCustomListWrapper.tsx index d8e2379772cc..8477492d2fa9 100644 --- a/packages/ui-client/src/components/MultiSelectCustom/MultiSelectCustomListWrapper.tsx +++ b/packages/ui-client/src/components/MultiSelectCustom/MultiSelectCustomListWrapper.tsx @@ -6,7 +6,7 @@ const MultiSelectCustomListWrapper = forwardRef + {children} ); diff --git a/packages/ui-client/src/components/PasswordVerifier/PasswordVerifier.tsx b/packages/ui-client/src/components/PasswordVerifier/PasswordVerifier.tsx index bf53360a2351..c2ed984512fa 100644 --- a/packages/ui-client/src/components/PasswordVerifier/PasswordVerifier.tsx +++ b/packages/ui-client/src/components/PasswordVerifier/PasswordVerifier.tsx @@ -8,6 +8,7 @@ import { PasswordVerifierItem } from './PasswordVerifierItem'; type PasswordVerifierProps = { password: string | undefined; id?: string; + vertical?: boolean; }; type PasswordVerificationProps = { @@ -16,7 +17,7 @@ type PasswordVerificationProps = { limit?: number; }[]; -export const PasswordVerifier = ({ password, id }: PasswordVerifierProps) => { +export const PasswordVerifier = ({ password, id, vertical }: PasswordVerifierProps) => { const { t } = useTranslation(); const uniqueId = useUniqueId(); @@ -37,7 +38,13 @@ export const PasswordVerifier = ({ password, id }: PasswordVerifierProps) => { {passwordVerifications.map(({ isValid, limit, name }) => ( - + ))} diff --git a/packages/ui-client/src/components/PasswordVerifier/PasswordVerifierItem.tsx b/packages/ui-client/src/components/PasswordVerifier/PasswordVerifierItem.tsx index 97499c0eaf73..c622fc74e6c8 100644 --- a/packages/ui-client/src/components/PasswordVerifier/PasswordVerifierItem.tsx +++ b/packages/ui-client/src/components/PasswordVerifier/PasswordVerifierItem.tsx @@ -20,13 +20,14 @@ const variants: { export const PasswordVerifierItem = ({ text, isValid, + vertical, ...props -}: { text: string; isValid: boolean } & Omit, 'is'>) => { +}: { text: string; isValid: boolean; vertical: boolean } & Omit, 'is'>) => { const { icon, color } = variants[isValid ? 'success' : 'error']; return (