diff --git a/.changeset/cuddly-ties-run.md b/.changeset/cuddly-ties-run.md new file mode 100644 index 000000000000..cb3873899841 --- /dev/null +++ b/.changeset/cuddly-ties-run.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +fix: custom-css injection diff --git a/.changeset/hip-pans-argue.md b/.changeset/hip-pans-argue.md new file mode 100644 index 000000000000..af8050383467 --- /dev/null +++ b/.changeset/hip-pans-argue.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +fix: Omnichannel webhook is not retrying requests diff --git a/.changeset/lazy-shoes-teach.md b/.changeset/lazy-shoes-teach.md new file mode 100644 index 000000000000..7737f39cd671 --- /dev/null +++ b/.changeset/lazy-shoes-teach.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': minor +--- + +chore: adding some portugueses translations to the app details page diff --git a/.changeset/rotten-dryers-allow.md b/.changeset/rotten-dryers-allow.md new file mode 100644 index 000000000000..154dea572780 --- /dev/null +++ b/.changeset/rotten-dryers-allow.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Add pagination & tooltips to agent's dropdown on forwarding modal diff --git a/apps/meteor/app/cloud/server/functions/buildRegistrationData.ts b/apps/meteor/app/cloud/server/functions/buildRegistrationData.ts index c2bd91e82dd8..ea94db8d17a1 100644 --- a/apps/meteor/app/cloud/server/functions/buildRegistrationData.ts +++ b/apps/meteor/app/cloud/server/functions/buildRegistrationData.ts @@ -38,6 +38,7 @@ export type WorkspaceRegistrationData = { MAC: number; // activeContactsBillingMonth: number; // activeContactsYesterday: number; + statsToken?: string; }; export async function buildWorkspaceRegistrationData(contactEmail: T): Promise> { @@ -92,5 +93,6 @@ export async function buildWorkspaceRegistrationData { - const query = {}; + const query: FilterOperators = {}; + const orConditions: FilterOperators['$or'] = []; if (text) { const filterReg = new RegExp(escapeRegExp(text), 'i'); - Object.assign(query, { - $or: [{ username: filterReg }, { name: filterReg }, { 'emails.address': filterReg }], - }); + orConditions.push({ $or: [{ username: filterReg }, { name: filterReg }, { 'emails.address': filterReg }] }); + } + + if (onlyAvailable) { + query.statusLivechat = 'available'; + } + + if (excludeId) { + query._id = { $ne: excludeId }; + } + + if (!showIdleAgents) { + orConditions.push({ $or: [{ status: { $exists: true, $ne: 'offline' }, roles: { $ne: 'bot' } }, { roles: 'bot' }] }); + } + + if (orConditions.length) { + query.$and = orConditions; } const [ @@ -52,14 +74,23 @@ async function findUsers({ } export async function findAgents({ text, + onlyAvailable = false, + excludeId, + showIdleAgents = true, pagination: { offset, count, sort }, }: { text?: string; + onlyAvailable: boolean; + excludeId?: string; + showIdleAgents?: boolean; pagination: { offset: number; count: number; sort: any }; }): Promise> { return findUsers({ role: 'livechat-agent', text, + onlyAvailable, + excludeId, + showIdleAgents, pagination: { offset, count, diff --git a/apps/meteor/app/livechat/server/lib/LivechatTyped.ts b/apps/meteor/app/livechat/server/lib/LivechatTyped.ts index 32cb5c83acd9..0d25616bda60 100644 --- a/apps/meteor/app/livechat/server/lib/LivechatTyped.ts +++ b/apps/meteor/app/livechat/server/lib/LivechatTyped.ts @@ -796,12 +796,15 @@ class LivechatClass { attempts = 10, ) { if (!attempts) { + Livechat.logger.error({ msg: 'Omnichannel webhook call failed. Max attempts reached' }); return; } const timeout = settings.get('Livechat_http_timeout'); const secretToken = settings.get('Livechat_secret_token'); + const webhookUrl = settings.get('Livechat_webhookUrl'); try { - const result = await fetch(settings.get('Livechat_webhookUrl'), { + Livechat.webhookLogger.debug({ msg: 'Sending webhook request', postData }); + const result = await fetch(webhookUrl, { method: 'POST', headers: { ...(secretToken && { 'X-RocketChat-Livechat-Token': secretToken }), @@ -812,17 +815,20 @@ class LivechatClass { if (result.status === 200) { metrics.totalLivechatWebhooksSuccess.inc(); - } else { - metrics.totalLivechatWebhooksFailures.inc(); + return result; } - return result; + + metrics.totalLivechatWebhooksFailures.inc(); + throw new Error(await result.text()); } catch (err) { - Livechat.webhookLogger.error({ msg: `Response error on ${11 - attempts} try ->`, err }); + const retryAfter = timeout * 4; + Livechat.webhookLogger.error({ msg: `Error response on ${11 - attempts} try ->`, err }); // try 10 times after 20 seconds each - attempts - 1 && Livechat.webhookLogger.warn(`Will try again in ${(timeout / 1000) * 4} seconds ...`); + attempts - 1 && + Livechat.webhookLogger.warn({ msg: `Webhook call failed. Retrying`, newAttemptAfterSeconds: retryAfter / 1000, webhookUrl }); setTimeout(async () => { await Livechat.sendRequest(postData, attempts - 1); - }, timeout * 4); + }, retryAfter); } } diff --git a/apps/meteor/app/statistics/server/lib/statistics.ts b/apps/meteor/app/statistics/server/lib/statistics.ts index 1d60def846b5..9e63c18506ac 100644 --- a/apps/meteor/app/statistics/server/lib/statistics.ts +++ b/apps/meteor/app/statistics/server/lib/statistics.ts @@ -598,7 +598,9 @@ export const statistics = { async save(): Promise { const rcStatistics = await statistics.get(); rcStatistics.createdAt = new Date(); - await Statistics.insertOne(rcStatistics); + const { insertedId } = await Statistics.insertOne(rcStatistics); + rcStatistics._id = insertedId; + return rcStatistics; }, }; diff --git a/apps/meteor/app/ui-master/server/index.js b/apps/meteor/app/ui-master/server/index.js index f9e335451d74..34a9618d2db6 100644 --- a/apps/meteor/app/ui-master/server/index.js +++ b/apps/meteor/app/ui-master/server/index.js @@ -120,8 +120,6 @@ Meteor.startup(() => { })(__meteor_runtime_config__.ROOT_URL_PATH_PREFIX); injectIntoHead('base', ``); - - injectIntoHead('css-theme', ''); }); const renderDynamicCssList = withDebouncing({ wait: 500 })(async () => { diff --git a/apps/meteor/client/components/AutoCompleteAgent.tsx b/apps/meteor/client/components/AutoCompleteAgent.tsx index b4e287bcc4ae..f2cbebe46920 100644 --- a/apps/meteor/client/components/AutoCompleteAgent.tsx +++ b/apps/meteor/client/components/AutoCompleteAgent.tsx @@ -11,18 +11,26 @@ type AutoCompleteAgentProps = { value: string; error?: string; placeholder?: string; - onChange: (value: string) => void; haveAll?: boolean; haveNoAgentsSelectedOption?: boolean; + excludeId?: string; + showIdleAgents?: boolean; + onlyAvailable?: boolean; + withTitle?: boolean; + onChange: (value: string) => void; }; const AutoCompleteAgent = ({ value, error, placeholder, - onChange, haveAll = false, haveNoAgentsSelectedOption = false, + excludeId, + showIdleAgents = false, + onlyAvailable = false, + withTitle = false, + onChange, }: AutoCompleteAgentProps): ReactElement => { const [agentsFilter, setAgentsFilter] = useState(''); @@ -30,26 +38,16 @@ const AutoCompleteAgent = ({ const { itemsList: AgentsList, loadMoreItems: loadMoreAgents } = useAgentsList( useMemo( - () => ({ text: debouncedAgentsFilter, haveAll, haveNoAgentsSelectedOption }), - [debouncedAgentsFilter, haveAll, haveNoAgentsSelectedOption], + () => ({ text: debouncedAgentsFilter, onlyAvailable, haveAll, haveNoAgentsSelectedOption, excludeId, showIdleAgents }), + [debouncedAgentsFilter, excludeId, haveAll, haveNoAgentsSelectedOption, onlyAvailable, showIdleAgents], ), ); const { phase: agentsPhase, itemCount: agentsTotal, items: agentsItems } = useRecordList(AgentsList); - const sortedByName = agentsItems.sort((a, b) => { - if (a.label > b.label) { - return 1; - } - if (a.label < b.label) { - return -1; - } - - return 0; - }); - return ( void} - options={sortedByName} + options={agentsItems} data-qa='autocomplete-agent' endReached={ agentsPhase === AsyncStatePhase.LOADING ? (): void => undefined : (start): void => loadMoreAgents(start, Math.min(50, agentsTotal)) diff --git a/apps/meteor/client/components/Omnichannel/hooks/useAgentsList.ts b/apps/meteor/client/components/Omnichannel/hooks/useAgentsList.ts index b854866184f7..e2f6f80f2355 100644 --- a/apps/meteor/client/components/Omnichannel/hooks/useAgentsList.ts +++ b/apps/meteor/client/components/Omnichannel/hooks/useAgentsList.ts @@ -9,6 +9,9 @@ type AgentsListOptions = { text: string; haveAll: boolean; haveNoAgentsSelectedOption: boolean; + excludeId?: string; + showIdleAgents?: boolean; + onlyAvailable?: boolean; }; type AgentOption = { value: string; label: string; _updatedAt: Date; _id: string }; @@ -26,6 +29,7 @@ export const useAgentsList = ( const reload = useCallback(() => setItemsList(new RecordList()), []); const getAgents = useEndpoint('GET', '/v1/livechat/users/agent'); + const { text, onlyAvailable = false, showIdleAgents = false, excludeId, haveAll, haveNoAgentsSelectedOption } = options; useComponentDidUpdate(() => { options && reload(); @@ -34,7 +38,10 @@ export const useAgentsList = ( const fetchData = useCallback( async (start, end) => { const { users: agents, total } = await getAgents({ - ...(options.text && { text: options.text }), + ...(text && { text }), + ...(excludeId && { excludeId }), + showIdleAgents, + onlyAvailable, offset: start, count: end + start, sort: `{ "name": 1 }`, @@ -43,14 +50,14 @@ export const useAgentsList = ( const items = agents.map((agent) => { const agentOption = { _updatedAt: new Date(agent._updatedAt), - label: agent.username || agent._id, + label: `${agent.name || agent._id} (@${agent.username})`, value: agent._id, _id: agent._id, }; return agentOption; }); - options.haveAll && + haveAll && items.unshift({ label: t('All'), value: 'all', @@ -58,7 +65,7 @@ export const useAgentsList = ( _id: 'all', }); - options.haveNoAgentsSelectedOption && + haveNoAgentsSelectedOption && items.unshift({ label: t('Empty_no_agent_selected'), value: 'no-agent-selected', @@ -71,7 +78,7 @@ export const useAgentsList = ( itemCount: total + 1, }; }, - [getAgents, options.haveAll, options.haveNoAgentsSelectedOption, options.text, t], + [excludeId, getAgents, haveAll, haveNoAgentsSelectedOption, onlyAvailable, showIdleAgents, t, text], ); const { loadMoreItems, initialItemCount } = useScrollableRecordList(itemsList, fetchData, 25); diff --git a/apps/meteor/client/components/Omnichannel/modals/ForwardChatModal.tsx b/apps/meteor/client/components/Omnichannel/modals/ForwardChatModal.tsx index bdbde6b05acd..a4d095fdb90d 100644 --- a/apps/meteor/client/components/Omnichannel/modals/ForwardChatModal.tsx +++ b/apps/meteor/client/components/Omnichannel/modals/ForwardChatModal.tsx @@ -19,7 +19,7 @@ import { useForm } from 'react-hook-form'; import { useRecordList } from '../../../hooks/lists/useRecordList'; import { AsyncStatePhase } from '../../../hooks/useAsyncState'; -import UserAutoComplete from '../../UserAutoComplete'; +import AutoCompleteAgent from '../../AutoCompleteAgent'; import { useDepartmentsList } from '../hooks/useDepartmentsList'; const ForwardChatModal = ({ @@ -53,28 +53,6 @@ const ForwardChatModal = ({ ); const { phase: departmentsPhase, items: departments, itemCount: departmentsTotal } = useRecordList(departmentsList); - const _id = { $ne: room.servedBy?._id }; - const conditions = { - _id, - ...(!idleAgentsAllowedForForwarding && { - $or: [ - { - status: { - $exists: true, - $ne: 'offline', - }, - roles: { - $ne: 'bot', - }, - }, - { - roles: 'bot', - }, - ], - }), - statusLivechat: 'available', - }; - const endReached = useCallback( (start) => { if (departmentsPhase !== AsyncStatePhase.LOADING) { @@ -134,13 +112,16 @@ const ForwardChatModal = ({ {t('Forward_to_user')} - { setValue('username', value); }} - value={getValues().username} /> diff --git a/apps/meteor/client/hooks/useLicense.ts b/apps/meteor/client/hooks/useLicense.ts index 1549b431eeb7..ec07e1702bed 100644 --- a/apps/meteor/client/hooks/useLicense.ts +++ b/apps/meteor/client/hooks/useLicense.ts @@ -1,23 +1,33 @@ +import type { Serialized } from '@rocket.chat/core-typings'; +import { useDebouncedCallback } from '@rocket.chat/fuselage-hooks'; import type { OperationResult } from '@rocket.chat/rest-typings'; -import { useEndpoint, usePermission } from '@rocket.chat/ui-contexts'; +import { useEndpoint, useSingleStream } from '@rocket.chat/ui-contexts'; import type { UseQueryResult } from '@tanstack/react-query'; -import { useQuery } from '@tanstack/react-query'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { useEffect } from 'react'; -export const useLicense = (): UseQueryResult> => { +type LicenseDataType = Awaited>['license']; + +export const useLicense = (): UseQueryResult> => { const getLicenses = useEndpoint('GET', '/v1/licenses.info'); - const canViewLicense = usePermission('view-privileged-setting'); - return useQuery( - ['licenses', 'getLicenses'], + const queryClient = useQueryClient(); + + const invalidate = useDebouncedCallback( () => { - if (!canViewLicense) { - throw new Error('unauthorized api call'); - } - return getLicenses({}); - }, - { - staleTime: Infinity, - keepPreviousData: true, + queryClient.invalidateQueries(['licenses', 'getLicenses']); }, + 5000, + [], ); + + const notify = useSingleStream('notify-all'); + + useEffect(() => notify('license', () => invalidate()), [notify, invalidate]); + + return useQuery(['licenses', 'getLicenses'], () => getLicenses({}), { + staleTime: Infinity, + keepPreviousData: true, + select: (data) => data.license, + }); }; diff --git a/apps/meteor/client/views/admin/info/LicenseCard.tsx b/apps/meteor/client/views/admin/info/LicenseCard.tsx index 8aab636f4720..195121932e80 100644 --- a/apps/meteor/client/views/admin/info/LicenseCard.tsx +++ b/apps/meteor/client/views/admin/info/LicenseCard.tsx @@ -56,7 +56,7 @@ const LicenseCard = (): ReactElement => { ); } - const { activeModules } = request.data.license; + const { activeModules } = request.data; const hasEngagement = activeModules.includes('engagement-dashboard'); const hasOmnichannel = activeModules.includes('livechat-enterprise'); diff --git a/apps/meteor/client/views/hooks/useUpgradeTabParams.ts b/apps/meteor/client/views/hooks/useUpgradeTabParams.ts index 1d152b08d5b9..abfa9da251dc 100644 --- a/apps/meteor/client/views/hooks/useUpgradeTabParams.ts +++ b/apps/meteor/client/views/hooks/useUpgradeTabParams.ts @@ -13,11 +13,11 @@ export const useUpgradeTabParams = (): { tabType: UpgradeTabVariant | false; tri const { data: registrationStatusData, isSuccess: isSuccessRegistrationStatus } = useRegistrationStatus(); const registered = registrationStatusData?.registrationStatus?.workspaceRegistered ?? false; - const hasValidLicense = Boolean(licensesData?.license?.license ?? false); + const hasValidLicense = Boolean(licensesData?.license ?? false); const hadExpiredTrials = cloudWorkspaceHadTrial ?? false; - const isTrial = Boolean(licensesData?.license?.trial); - const trialEndDateStr = licensesData?.license?.license?.information?.visualExpiration; + const isTrial = Boolean(licensesData?.trial); + const trialEndDateStr = licensesData?.license?.information?.visualExpiration; const trialEndDate = trialEndDateStr ? format(new Date(trialEndDateStr), 'yyyy-MM-dd') : undefined; const upgradeTabType = getUpgradeTabType({ diff --git a/apps/meteor/ee/app/license/server/license.internalService.ts b/apps/meteor/ee/app/license/server/license.internalService.ts index 9036a9b1848c..6c179bf9797e 100644 --- a/apps/meteor/ee/app/license/server/license.internalService.ts +++ b/apps/meteor/ee/app/license/server/license.internalService.ts @@ -23,6 +23,10 @@ export class LicenseService extends ServiceClassInternal implements ILicense { License.onModule((licenseModule) => { void api.broadcast('license.module', licenseModule); }); + + this.onEvent('license.actions', (preventedActions) => License.syncShouldPreventActionResults(preventedActions)); + + this.onEvent('license.sync', () => License.sync()); } async started(): Promise { diff --git a/apps/meteor/ee/app/license/server/startup.ts b/apps/meteor/ee/app/license/server/startup.ts index 8cce4d3d1410..fc6b693e0441 100644 --- a/apps/meteor/ee/app/license/server/startup.ts +++ b/apps/meteor/ee/app/license/server/startup.ts @@ -1,3 +1,5 @@ +import { api } from '@rocket.chat/core-services'; +import type { LicenseLimitKind } from '@rocket.chat/license'; import { License } from '@rocket.chat/license'; import { Subscriptions, Users, Settings } from '@rocket.chat/models'; import { wrapExceptions } from '@rocket.chat/tools'; @@ -93,6 +95,26 @@ settings.onReady(async () => { License.onBehaviorTriggered('start_fair_policy', async (context) => syncByTrigger(`start_fair_policy_${context.limit}`)); License.onBehaviorTriggered('disable_modules', async (context) => syncByTrigger(`disable_modules_${context.limit}`)); + + License.onChange(() => api.broadcast('license.sync')); + + License.onBehaviorToggled('prevent_action', (context) => { + if (!context.limit) { + return; + } + void api.broadcast('license.actions', { + [context.limit]: true, + } as Record, boolean>); + }); + + License.onBehaviorToggled('allow_action', (context) => { + if (!context.limit) { + return; + } + void api.broadcast('license.actions', { + [context.limit]: false, + } as Record, boolean>); + }); }); License.setLicenseLimitCounter('activeUsers', () => Users.getActiveLocalUserCount()); diff --git a/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json b/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json index 46805a1f0e3e..d260037d2aab 100644 --- a/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json +++ b/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json @@ -5453,6 +5453,7 @@ "Username_title": "Register username", "Username_has_been_updated": "Username has been updated", "Username_wants_to_start_otr_Do_you_want_to_accept": "{{username}} wants to start OTR. Do you want to accept?", + "Username_name_email": "Username, name or e-mail", "Users": "Users", "Users must use Two Factor Authentication": "Users must use Two Factor Authentication", "Users_added": "The users have been added", diff --git a/apps/meteor/packages/rocketchat-i18n/i18n/fi.i18n.json b/apps/meteor/packages/rocketchat-i18n/i18n/fi.i18n.json index 79a27d83cbe6..f482e42419a4 100644 --- a/apps/meteor/packages/rocketchat-i18n/i18n/fi.i18n.json +++ b/apps/meteor/packages/rocketchat-i18n/i18n/fi.i18n.json @@ -30,6 +30,7 @@ "A_secure_and_highly_private_self-managed_solution_for_conference_calls": "Suojattu ja vahvasti yksityinen itsepalveluratkaisu neuvottelupuheluille.", "A_workspace_admin_needs_to_install_and_configure_a_conference_call_app": "Työtilan järjestelmänvalvojan on asennettava ja määritettävä neuvottelupuhelusovellus.", "An_app_needs_to_be_installed_and_configured": "Sovellus on asennettavaa ja määritettävä.", + "Accessibility_and_Appearance": "Helppokäyttöisyys ja ulkoasu", "Accept_Call": "Hyväksy puhelu", "Accept": "Hyväksy", "Accept_incoming_livechat_requests_even_if_there_are_no_online_agents": "Hyväksy saapuvat monikanavapyynnöt, vaikka agentteja ei ole paikalla", @@ -5749,4 +5750,4 @@ "Uninstall_grandfathered_app": "Poistetaanko {{appName}}?", "App_will_lose_grandfathered_status": "**Tämä {{context}}sovellus menettää aikaisemmin käytetössä olleen sovelluksen tilansa.** \n \nYhteisöversion työtiloissa voi olla käytössä enintään {{limit}} {{context}} sovellusta. aikaisemmin Aikaisemmin käytössä olleet sovellukset lasketaan mukaan rajoitukseen, mutta rajoitusta ei sovelleta niihin.", "Theme_Appearence": "Teeman ulkoasu" -} \ No newline at end of file +} diff --git a/apps/meteor/packages/rocketchat-i18n/i18n/pt-BR.i18n.json b/apps/meteor/packages/rocketchat-i18n/i18n/pt-BR.i18n.json index 8da04e7c4e67..b31c69d477e0 100644 --- a/apps/meteor/packages/rocketchat-i18n/i18n/pt-BR.i18n.json +++ b/apps/meteor/packages/rocketchat-i18n/i18n/pt-BR.i18n.json @@ -2833,6 +2833,7 @@ "Mark_read": "Marcar como Lido", "Mark_unread": "Marcar como não lido", "Marketplace": "Marketplace", + "Marketplace_app_last_updated": "Ultima atualização {{lastUpdated}}", "Marketplace_view_marketplace": "Ver Marketplace", "Marketplace_error": "Não é possível conectar à internet, ou seu espaço de trabalho pode ser uma instalação offline.", "MAU_value": "MAU {{value}}", @@ -3522,6 +3523,7 @@ "Regular_Expressions": "Expressões regulares", "Reject_call": "Rejeitar chamada", "Release": "Versão", + "Releases": "Versões", "Religious": "Religioso", "Reload": "Recarregar", "Reload_page": "Recarregar página", @@ -3575,6 +3577,10 @@ "Request_comment_when_closing_conversation": "Solicitar comentário ao encerrar a conversa", "Request_comment_when_closing_conversation_description": "Se ativado, o agente precisará informar um comentário antes que a conversa seja encerrada.", "Request_tag_before_closing_chat": "Solicitar tag(s) antes de encerrar a conversa", + "request": "solicitar", + "requests": "solicitações", + "Requests": "Solicitações", + "Requested": "Solicitado", "Requested_At": "Solicitado em", "Requested_By": "Solicitado por", "Require": "Exigir", @@ -4575,6 +4581,7 @@ "Username_Placeholder": "Digite os nomes de usuário...", "Username_title": "Cadastre um nome de usuário", "Username_wants_to_start_otr_Do_you_want_to_accept": "{{username}} quer começar OTR. Você aceita?", + "Username_name_email": "Nome de usuário, nome ou e-mail", "Users": "Usuários", "Users must use Two Factor Authentication": "Os usuários devem usar autenticação de dois fatores", "Users_added": "Os usuários foram adicionados", @@ -4972,4 +4979,4 @@ "RegisterWorkspace_Features_Omnichannel_Title": "Omnichannel", "RegisterWorkspace_Setup_Label": "E-mail da conta da nuvem", "cloud.RegisterWorkspace_Setup_Terms_Privacy": "Eu concordo com os <1>Termos e condições e a <3>Política de privacidade" -} \ No newline at end of file +} diff --git a/apps/meteor/server/cron/statistics.ts b/apps/meteor/server/cron/statistics.ts index 7e7dea6adbc7..44dcb554824c 100644 --- a/apps/meteor/server/cron/statistics.ts +++ b/apps/meteor/server/cron/statistics.ts @@ -1,5 +1,6 @@ import { cronJobs } from '@rocket.chat/cron'; import type { Logger } from '@rocket.chat/logger'; +import { Statistics } from '@rocket.chat/models'; import { serverFetch as fetch } from '@rocket.chat/server-fetch'; import { Meteor } from 'meteor/meteor'; @@ -8,9 +9,7 @@ import { settings } from '../../app/settings/server'; import { statistics } from '../../app/statistics/server'; async function generateStatistics(logger: Logger): Promise { - const cronStatistics: Record = await statistics.save(); - - cronStatistics.host = Meteor.absoluteUrl(); + const cronStatistics = await statistics.save(); if (!settings.get('Statistics_reporting')) { return; @@ -20,11 +19,20 @@ async function generateStatistics(logger: Logger): Promise { const token = await getWorkspaceAccessToken(); const headers = { ...(token && { Authorization: `Bearer ${token}` }) }; - await fetch('https://collector.rocket.chat/', { + const response = await fetch('https://collector.rocket.chat/', { method: 'POST', - body: cronStatistics, + body: { + ...cronStatistics, + host: Meteor.absoluteUrl(), + }, headers, }); + + const { statsToken } = await response.json(); + + if (statsToken != null) { + await Statistics.updateOne({ _id: cronStatistics._id }, { $set: { statsToken } }); + } } catch (error) { /* error*/ logger.warn('Failed to send usage report'); diff --git a/apps/meteor/server/modules/listeners/listeners.module.ts b/apps/meteor/server/modules/listeners/listeners.module.ts index f21081e43d0a..c580b47d7c6e 100644 --- a/apps/meteor/server/modules/listeners/listeners.module.ts +++ b/apps/meteor/server/modules/listeners/listeners.module.ts @@ -29,6 +29,9 @@ export class ListenersModule { constructor(service: IServiceClass, notifications: NotificationsModule) { const logger = new Logger('ListenersModule'); + service.onEvent('license.sync', () => notifications.notifyAllInThisInstance('license')); + service.onEvent('license.actions', () => notifications.notifyAllInThisInstance('license')); + service.onEvent('emoji.deleteCustom', (emoji) => { notifications.notifyLoggedInThisInstance('deleteEmojiCustom', { emojiData: emoji, diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-transfer-to-another-agents.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-transfer-to-another-agents.spec.ts index 3c74065a9a84..dc31f54be934 100644 --- a/apps/meteor/tests/e2e/omnichannel/omnichannel-transfer-to-another-agents.spec.ts +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-transfer-to-another-agents.spec.ts @@ -65,6 +65,7 @@ test.describe('omnichannel-transfer-to-another-agent', () => { await agent2.poHomeOmnichannel.sidenav.switchStatus('offline'); await agent1.poHomeOmnichannel.content.btnForwardChat.click(); + await agent1.poHomeOmnichannel.content.inputModalAgentUserName.click(); await agent1.poHomeOmnichannel.content.inputModalAgentUserName.type('user2'); await expect(agent1.page.locator('text=Empty')).toBeVisible(); @@ -76,8 +77,9 @@ test.describe('omnichannel-transfer-to-another-agent', () => { await agent1.poHomeOmnichannel.sidenav.getSidebarItemByName(newVisitor.name).click(); await agent1.poHomeOmnichannel.content.btnForwardChat.click(); + await agent1.poHomeOmnichannel.content.inputModalAgentUserName.click(); await agent1.poHomeOmnichannel.content.inputModalAgentUserName.type('user2'); - await agent1.page.locator('.rcx-option .rcx-option__wrapper >> text="user2"').click(); + await agent1.page.locator('.rcx-option .rcx-option__wrapper >> text="user2 (@user2)"').click(); await agent1.poHomeOmnichannel.content.inputModalAgentForwardComment.type('any_comment'); await agent1.poHomeOmnichannel.content.btnModalConfirm.click(); await expect(agent1.poHomeOmnichannel.toastSuccess).toBeVisible(); diff --git a/apps/meteor/tests/e2e/page-objects/fragments/home-content.ts b/apps/meteor/tests/e2e/page-objects/fragments/home-content.ts index cb8c8b089095..2ba8cd6428d9 100644 --- a/apps/meteor/tests/e2e/page-objects/fragments/home-content.ts +++ b/apps/meteor/tests/e2e/page-objects/fragments/home-content.ts @@ -197,7 +197,7 @@ export class HomeContent { } get inputModalAgentUserName(): Locator { - return this.page.locator('#modal-root input:nth-child(1)'); + return this.page.locator('#modal-root input[placeholder="Username, name or e-mail"]'); } get inputModalAgentForwardComment(): Locator { @@ -237,16 +237,8 @@ export class HomeContent { async openLastThreadMessageMenu(): Promise { await this.page.locator('//main//aside >> [data-qa-type="message"]').last().hover(); - await this.page - .locator('//main//aside >> [data-qa-type="message"]') - .last() - .locator('role=button[name="More"]') - .waitFor(); - await this.page - .locator('//main//aside >> [data-qa-type="message"]') - .last() - .locator('role=button[name="More"]') - .click(); + await this.page.locator('//main//aside >> [data-qa-type="message"]').last().locator('role=button[name="More"]').waitFor(); + await this.page.locator('//main//aside >> [data-qa-type="message"]').last().locator('role=button[name="More"]').click(); } async toggleAlsoSendThreadToChannel(isChecked: boolean): Promise { diff --git a/apps/meteor/tests/end-to-end/api/livechat/01-agents.ts b/apps/meteor/tests/end-to-end/api/livechat/01-agents.ts index 83efe2c96aa2..6f9120ea9094 100644 --- a/apps/meteor/tests/end-to-end/api/livechat/01-agents.ts +++ b/apps/meteor/tests/end-to-end/api/livechat/01-agents.ts @@ -94,6 +94,26 @@ describe('LIVECHAT - Agents', function () { expect(agentRecentlyCreated?._id).to.be.equal(agent._id); }); }); + it('should return an array of available agents', async () => { + await updatePermission('edit-omnichannel-contact', ['admin']); + await updatePermission('transfer-livechat-guest', ['admin']); + await updatePermission('manage-livechat-agents', ['admin']); + + await request + .get(api('livechat/users/agent')) + .set(credentials) + .expect('Content-Type', 'application/json') + .query({ onlyAvailable: true }) + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body.users).to.be.an('array'); + expect(res.body).to.have.property('offset'); + expect(res.body).to.have.property('total'); + expect(res.body).to.have.property('count'); + expect(res.body.users.every((u: { statusLivechat: string }) => u.statusLivechat === 'available')).to.be.true; + }); + }); it('should return an array of managers', async () => { await updatePermission('view-livechat-manager', ['admin']); await updatePermission('manage-livechat-agents', ['admin']); diff --git a/ee/packages/ddp-client/src/types/streams.ts b/ee/packages/ddp-client/src/types/streams.ts index 9010517faf7a..abadd53c1851 100644 --- a/ee/packages/ddp-client/src/types/streams.ts +++ b/ee/packages/ddp-client/src/types/streams.ts @@ -25,6 +25,7 @@ import type { IBanner, UiKit, } from '@rocket.chat/core-typings'; +import type { LicenseLimitKind } from '@rocket.chat/license'; type ClientAction = 'inserted' | 'updated' | 'removed' | 'changed'; @@ -69,6 +70,7 @@ export interface StreamerEvents { { key: 'public-settings-changed'; args: ['inserted' | 'updated' | 'removed' | 'changed', ISetting] }, { key: 'deleteCustomSound'; args: [{ soundData: ICustomSound }] }, { key: 'updateCustomSound'; args: [{ soundData: ICustomSound }] }, + { key: 'license'; args: [{ preventedActions: Record }] | [] }, ]; 'notify-user': [ diff --git a/ee/packages/license/__tests__/emitter.spec.ts b/ee/packages/license/__tests__/emitter.spec.ts index 5682715d6d2b..ce949365e8a6 100644 --- a/ee/packages/license/__tests__/emitter.spec.ts +++ b/ee/packages/license/__tests__/emitter.spec.ts @@ -117,6 +117,70 @@ describe('Event License behaviors', () => { }); }); + /** + * This event is used to sync multiple instances of license manager + * The sync event is triggered when the license is changed, but if the validation is running due to a previous change, no sync should be triggered, avoiding multiple/loops syncs + */ + describe('sync event', () => { + it('should emit `sync` event when the license is changed', async () => { + const licenseManager = await getReadyLicenseManager(); + const fn = jest.fn(); + + licenseManager.onChange(fn); + + const license = await new MockedLicenseBuilder().withLimits('activeUsers', [ + { + max: 10, + behavior: 'prevent_action', + }, + { + max: 20, + behavior: 'invalidate_license', + }, + ]); + + await expect(licenseManager.setLicense(await license.sign())).resolves.toBe(true); + + licenseManager.setLicenseLimitCounter('activeUsers', () => 21); + + await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(true); + + await expect(fn).toBeCalledTimes(1); + }); + + it('should not emit `sync` event when the license validation was triggered by a the sync method', async () => { + const licenseManager = await getReadyLicenseManager(); + const fn = jest.fn(); + + licenseManager.onChange(fn); + + const license = await new MockedLicenseBuilder().withLimits('activeUsers', [ + { + max: 10, + behavior: 'prevent_action', + }, + { + max: 20, + behavior: 'invalidate_license', + }, + ]); + + await expect(licenseManager.setLicense(await license.sign())).resolves.toBe(true); + + licenseManager.setLicenseLimitCounter('activeUsers', () => 21); + + await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(true); + + await expect(fn).toBeCalledTimes(1); + + fn.mockClear(); + + await expect(licenseManager.sync()).resolves.toBe(undefined); + + await expect(fn).toBeCalledTimes(0); + }); + }); + /** * this is only called when the prevent_action behavior is triggered for the first time * it will not be called again until the behavior is toggled diff --git a/ee/packages/license/src/definition/LicenseValidationOptions.ts b/ee/packages/license/src/definition/LicenseValidationOptions.ts index 6aa1e4213c62..9357b021878b 100644 --- a/ee/packages/license/src/definition/LicenseValidationOptions.ts +++ b/ee/packages/license/src/definition/LicenseValidationOptions.ts @@ -8,4 +8,5 @@ export type LicenseValidationOptions = { suppressLog?: boolean; isNewLicense?: boolean; context?: Partial<{ [K in LicenseLimitKind]: Partial> }>; + triggerSync?: boolean; }; diff --git a/ee/packages/license/src/definition/events.ts b/ee/packages/license/src/definition/events.ts index ad1114738cce..b9d211da9b7a 100644 --- a/ee/packages/license/src/definition/events.ts +++ b/ee/packages/license/src/definition/events.ts @@ -18,4 +18,5 @@ export type LicenseEvents = ModuleValidation & validate: undefined; invalidate: undefined; module: { module: LicenseModule; valid: boolean }; + sync: undefined; }; diff --git a/ee/packages/license/src/events/listeners.ts b/ee/packages/license/src/events/listeners.ts index 6c80867b7ac8..f8c291edc4be 100644 --- a/ee/packages/license/src/events/listeners.ts +++ b/ee/packages/license/src/events/listeners.ts @@ -4,6 +4,13 @@ import type { LicenseModule } from '../definition/LicenseModule'; import type { LicenseManager } from '../license'; import { hasModule } from '../modules'; +/** + * Invoked when the license changes some internal state. it's called to sync the license with other instances. + */ +export function onChange(this: LicenseManager, cb: () => void) { + this.on('sync', cb); +} + export function onValidFeature(this: LicenseManager, feature: LicenseModule, cb: () => void) { this.on(`valid:${feature}`, cb); diff --git a/ee/packages/license/src/index.ts b/ee/packages/license/src/index.ts index 92b30c4af40d..e590ce7722b2 100644 --- a/ee/packages/license/src/index.ts +++ b/ee/packages/license/src/index.ts @@ -10,6 +10,7 @@ import { onInvalidateLicense, onLimitReached, onModule, + onChange, onToggledFeature, onValidFeature, onValidateLicense, @@ -82,6 +83,8 @@ export class LicenseImp extends LicenseManager implements License { return this.shouldPreventAction(action, 0, context); } + onChange = onChange; + onValidFeature = onValidFeature; onInvalidFeature = onInvalidFeature; diff --git a/ee/packages/license/src/license.ts b/ee/packages/license/src/license.ts index 5987065bd697..fb290d541cfb 100644 --- a/ee/packages/license/src/license.ts +++ b/ee/packages/license/src/license.ts @@ -27,6 +27,7 @@ import { isBehaviorsInResult } from './validation/isBehaviorsInResult'; import { isReadyForValidation } from './validation/isReadyForValidation'; import { runValidation } from './validation/runValidation'; import { validateFormat } from './validation/validateFormat'; +import { validateLicenseLimits } from './validation/validateLicenseLimits'; const globalLimitKinds: LicenseLimitKind[] = ['activeUsers', 'guestUsers', 'privateApps', 'marketplaceApps', 'monthlyActiveContacts']; @@ -95,7 +96,27 @@ export class LicenseManager extends Emitter { } try { - await this.validateLicense({ ...options, isNewLicense: false }); + await this.validateLicense({ ...options, isNewLicense: false, triggerSync: true }); + } catch (e) { + if (e instanceof InvalidLicenseError) { + this.invalidateLicense(); + this.emit('sync'); + } + } + } + + /** + * The sync method should be called when a license from a different instance is has changed, so the local instance + * needs to be updated. This method will validate the license and update the local instance if the license is valid, but will not trigger the onSync event. + */ + + public async sync(options: Omit = {}): Promise { + if (!this.hasValidLicense()) { + return; + } + + try { + await this.validateLicense({ ...options, isNewLicense: false, triggerSync: false }); } catch (e) { if (e instanceof InvalidLicenseError) { this.invalidateLicense(); @@ -152,7 +173,11 @@ export class LicenseManager extends Emitter { return Boolean(this._lockedLicense && this._lockedLicense === encryptedLicense); } - private async validateLicense(options: LicenseValidationOptions = {}): Promise { + private async validateLicense( + options: LicenseValidationOptions = { + triggerSync: true, + }, + ): Promise { if (!this._license) { throw new InvalidLicenseError(); } @@ -195,6 +220,16 @@ export class LicenseManager extends Emitter { } licenseValidated.call(this); + + // If something changed in the license and the sync option is enabled, trigger a sync + if ( + ((!options.isNewLicense && + filterBehaviorsResult(validationResult, ['invalidate_license', 'start_fair_policy', 'prevent_installation'])) || + modulesChanged) && + options.triggerSync + ) { + this.emit('sync'); + } } public async setLicense(encryptedLicense: string, isNewLicense = true): Promise { @@ -263,6 +298,51 @@ export class LicenseManager extends Emitter { } } + public syncShouldPreventActionResults(actions: Record): void { + for (const [action, shouldPreventAction] of Object.entries(actions)) { + this.shouldPreventActionResults.set(action as LicenseLimitKind, shouldPreventAction); + } + } + + public async shouldPreventActionResultsMap(): Promise<{ + [key in LicenseLimitKind]: boolean; + }> { + const keys: LicenseLimitKind[] = [ + 'activeUsers', + 'guestUsers', + 'roomsPerGuest', + 'privateApps', + 'marketplaceApps', + 'monthlyActiveContacts', + ]; + + const items = await Promise.all( + keys.map(async (limit) => { + const cached = this.shouldPreventActionResults.get(limit as LicenseLimitKind); + + if (cached !== undefined) { + return [limit as LicenseLimitKind, cached]; + } + + const fresh = this._license + ? isBehaviorsInResult( + await validateLicenseLimits.call(this, this._license, { + behaviors: ['prevent_action'], + limits: [limit], + }), + ['prevent_action'], + ) + : false; + + this.shouldPreventActionResults.set(limit as LicenseLimitKind, fresh); + + return [limit as LicenseLimitKind, fresh]; + }), + ); + + return Object.fromEntries(items); + } + public async shouldPreventAction( action: T, extraCount = 0, @@ -360,7 +440,7 @@ export class LicenseManager extends Emitter { return { license: (includeLicense && license) || undefined, activeModules, - preventedActions: Object.fromEntries(this.shouldPreventActionResults.entries()) as Record, + preventedActions: await this.shouldPreventActionResultsMap(), limits: limits as Record, tags: license?.information.tags || [], trial: Boolean(license?.information.trial), diff --git a/packages/core-services/src/Events.ts b/packages/core-services/src/Events.ts index 2aa39588d2f0..c5f36d921655 100644 --- a/packages/core-services/src/Events.ts +++ b/packages/core-services/src/Events.ts @@ -34,6 +34,7 @@ import type { ILivechatVisitor, UiKit, } from '@rocket.chat/core-typings'; +import type { LicenseLimitKind } from '@rocket.chat/license'; import type { AutoUpdateRecord } from './types/IMeteor'; @@ -55,6 +56,9 @@ export type EventSignatures = { 'emoji.deleteCustom'(emoji: IEmoji): void; 'emoji.updateCustom'(emoji: IEmoji): void; 'license.module'(data: { module: string; valid: boolean }): void; + 'license.sync'(): void; + 'license.actions'(actions: Record, boolean>): void; + 'livechat-inquiry-queue-observer'(data: { action: string; inquiry: IInquiry }): void; 'message'(data: { action: string; message: IMessage }): void; 'meteor.clientVersionUpdated'(data: AutoUpdateRecord): void; diff --git a/packages/core-typings/src/IStats.ts b/packages/core-typings/src/IStats.ts index 7fd5cd8218bc..0df389f2dd86 100644 --- a/packages/core-typings/src/IStats.ts +++ b/packages/core-typings/src/IStats.ts @@ -227,4 +227,5 @@ export interface IStats { webRTCEnabled: boolean; webRTCEnabledForOmnichannel: boolean; omnichannelWebRTCCalls: number; + statsToken?: string; } diff --git a/packages/livechat/src/lib/triggers.js b/packages/livechat/src/lib/triggers.js index 4cf01090587c..efa9e9bdc65d 100644 --- a/packages/livechat/src/lib/triggers.js +++ b/packages/livechat/src/lib/triggers.js @@ -34,7 +34,7 @@ const getAgent = (triggerAction) => { let agent; try { - agent = await Livechat.nextAgent(department); + agent = await Livechat.nextAgent({ department }); } catch (error) { return reject(error); } diff --git a/packages/rest-typings/src/v1/omnichannel.ts b/packages/rest-typings/src/v1/omnichannel.ts index bebea2856861..3baeae111202 100644 --- a/packages/rest-typings/src/v1/omnichannel.ts +++ b/packages/rest-typings/src/v1/omnichannel.ts @@ -749,7 +749,13 @@ const LivechatDepartmentsByUnitIdSchema = { export const isLivechatDepartmentsByUnitIdProps = ajv.compile(LivechatDepartmentsByUnitIdSchema); -type LivechatUsersManagerGETProps = PaginatedRequest<{ text?: string; fields?: string }>; +type LivechatUsersManagerGETProps = PaginatedRequest<{ + text?: string; + fields?: string; + onlyAvailable?: boolean; + excludeId?: string; + showIdleAgents?: boolean; +}>; const LivechatUsersManagerGETSchema = { type: 'object', @@ -758,6 +764,18 @@ const LivechatUsersManagerGETSchema = { type: 'string', nullable: true, }, + onlyAvailable: { + type: 'string', + nullable: true, + }, + excludeId: { + type: 'string', + nullable: true, + }, + showIdleAgents: { + type: 'boolean', + nullable: true, + }, count: { type: 'number', nullable: true, @@ -3386,7 +3404,9 @@ export type OmnichannelEndpoints = { }; '/v1/livechat/users/agent': { - GET: (params: PaginatedRequest<{ text?: string }>) => PaginatedResult<{ + GET: ( + params: PaginatedRequest<{ text?: string; onlyAvailable?: boolean; excludeId?: string; showIdleAgents?: boolean }>, + ) => PaginatedResult<{ users: (ILivechatAgent & { departments: string[] })[]; }>; POST: (params: LivechatUsersManagerPOSTProps) => { success: boolean };