diff --git a/apps/meteor/client/views/omnichannel/businessHours/EditBusinessHours.tsx b/apps/meteor/client/views/omnichannel/businessHours/EditBusinessHours.tsx index 1139c15fd2543..ba00c6aeff353 100644 --- a/apps/meteor/client/views/omnichannel/businessHours/EditBusinessHours.tsx +++ b/apps/meteor/client/views/omnichannel/businessHours/EditBusinessHours.tsx @@ -27,7 +27,7 @@ const getInitialData = (businessHourData: Serialized | un })), departmentsToApplyBusinessHour: '', active: businessHourData?.active ?? true, - departments: businessHourData?.departments?.map(({ _id, name }) => ({ value: _id, label: name })) || [], + departments: businessHourData?.departments?.map(({ _id, name }) => ({ value: _id, label: name ?? '' })) || [], }); export type EditBusinessHoursProps = { diff --git a/apps/meteor/ee/server/api/v1/omnichannel/business-hours.ts b/apps/meteor/ee/server/api/v1/omnichannel/business-hours.ts index 252622f4dc8c3..60c1a031a8dfd 100644 --- a/apps/meteor/ee/server/api/v1/omnichannel/business-hours.ts +++ b/apps/meteor/ee/server/api/v1/omnichannel/business-hours.ts @@ -1,5 +1,12 @@ import type { ILivechatBusinessHour } from '@rocket.chat/core-typings'; import type { PaginatedRequest } from '@rocket.chat/rest-typings'; +import { + ajv, + ajvQuery, + validateBadRequestErrorResponse, + validateForbiddenErrorResponse, + validateUnauthorizedErrorResponse, +} from '@rocket.chat/rest-typings'; import { API } from '../../../../../server/api'; import { getPaginationItems } from '../../../../../server/api/lib/getPaginationItems'; @@ -9,7 +16,7 @@ declare module '@rocket.chat/rest-typings' { // eslint-disable-next-line @typescript-eslint/naming-convention interface Endpoints { '/v1/livechat/business-hours': { - GET: (params: PaginatedRequest) => { + GET: (params: PaginatedRequest<{ name?: string }>) => { businessHours: ILivechatBusinessHour[]; count: number; offset: number; @@ -19,26 +26,65 @@ declare module '@rocket.chat/rest-typings' { } } -API.v1.addRoute( +const businessHoursQueryValidator = ajvQuery.compile>({ + type: 'object', + properties: { + count: { type: 'number' }, + offset: { type: 'number' }, + sort: { type: 'string' }, + query: { type: 'string' }, + name: { type: 'string' }, + }, + additionalProperties: false, +}); + +const businessHoursResponseSchema = ajv.compile<{ + businessHours: ILivechatBusinessHour[]; + count: number; + offset: number; + total: number; +}>({ + type: 'object', + properties: { + businessHours: { type: 'array', items: { $ref: '#/components/schemas/ILivechatBusinessHour' } }, + count: { type: 'number' }, + offset: { type: 'number' }, + total: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['businessHours', 'count', 'offset', 'total', 'success'], + additionalProperties: false, +}); + +API.v1.get( 'livechat/business-hours', - { authRequired: true, permissionsRequired: ['view-livechat-business-hours'], license: ['livechat-enterprise'] }, { - async get() { - const { offset, count } = await getPaginationItems(this.queryParams); - const { sort } = await this.parseJsonQuery(); - const { name } = this.queryParams; - - return API.v1.success( - await findBusinessHours( - this.userId, - { - offset, - count, - sort, - }, - name, - ), - ); + authRequired: true, + permissionsRequired: ['view-livechat-business-hours'], + license: ['livechat-enterprise'], + query: businessHoursQueryValidator, + response: { + 200: businessHoursResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, }, }, + async function action() { + const { offset, count } = await getPaginationItems(this.queryParams); + const { sort } = await this.parseJsonQuery(); + const { name } = this.queryParams; + + return API.v1.success({ + ...(await findBusinessHours( + this.userId, + { + offset, + count, + sort, + }, + name, + )), + }); + }, ); diff --git a/apps/meteor/ee/server/api/v1/omnichannel/inquiries.ts b/apps/meteor/ee/server/api/v1/omnichannel/inquiries.ts index 1a1583e85d639..e394ba42c2325 100644 --- a/apps/meteor/ee/server/api/v1/omnichannel/inquiries.ts +++ b/apps/meteor/ee/server/api/v1/omnichannel/inquiries.ts @@ -1,7 +1,31 @@ +import { + ajv, + validateBadRequestErrorResponse, + validateForbiddenErrorResponse, + validateUnauthorizedErrorResponse, +} from '@rocket.chat/rest-typings'; + import { setSLAToInquiry } from './lib/inquiries'; import { API } from '../../../../../server/api'; -API.v1.addRoute( +const isPUTLivechatInquirySetSlaParams = ajv.compile<{ roomId: string; sla: string }>({ + type: 'object', + properties: { + roomId: { type: 'string' }, + sla: { type: 'string' }, + }, + required: ['roomId', 'sla'], + additionalProperties: false, +}); + +const inquirySetSlaResponseSchema = ajv.compile({ + type: 'object', + properties: { success: { type: 'boolean', enum: [true] } }, + required: ['success'], + additionalProperties: false, +}); + +API.v1.put( 'livechat/inquiry.setSLA', { authRequired: true, @@ -9,19 +33,21 @@ API.v1.addRoute( PUT: { permissions: ['view-l-room', 'manage-livechat-sla'], operation: 'hasAny' }, }, license: ['livechat-enterprise'], - }, - { - async put() { - const { roomId, sla } = this.bodyParams; - if (!roomId) { - return API.v1.failure("The 'roomId' param is required"); - } - await setSLAToInquiry({ - userId: this.userId, - roomId, - sla, - }); - return API.v1.success(); + body: isPUTLivechatInquirySetSlaParams, + response: { + 200: inquirySetSlaResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, }, }, + async function action() { + const { roomId, sla } = this.bodyParams; + await setSLAToInquiry({ + userId: this.userId, + roomId, + sla, + }); + return API.v1.success(); + }, ); diff --git a/apps/meteor/ee/server/api/v1/omnichannel/transcript.ts b/apps/meteor/ee/server/api/v1/omnichannel/transcript.ts index 00acf2a1d415a..b3cdf46ad94af 100644 --- a/apps/meteor/ee/server/api/v1/omnichannel/transcript.ts +++ b/apps/meteor/ee/server/api/v1/omnichannel/transcript.ts @@ -1,37 +1,62 @@ import type { IOmnichannelRoom } from '@rocket.chat/core-typings'; import { LivechatRooms } from '@rocket.chat/models'; +import { + ajv, + validateBadRequestErrorResponse, + validateForbiddenErrorResponse, + validateUnauthorizedErrorResponse, +} from '@rocket.chat/rest-typings'; import { API } from '../../../../../server/api'; +import type { ExtractRoutesFromAPI } from '../../../../../server/api/ApiClass'; import { canAccessRoomAsync } from '../../../../../server/lib/authorization/canAccessRoom'; import { requestPdfTranscript } from '../../../lib/omnichannel/requestPdfTranscript'; -API.v1.addRoute( +const requestTranscriptResponseSchema = ajv.compile({ + type: 'object', + properties: { success: { type: 'boolean', enum: [true] } }, + required: ['success'], + additionalProperties: false, +}); + +const requestTranscriptEndpoints = API.v1.post( 'omnichannel/:rid/request-transcript', - { authRequired: true, permissionsRequired: ['request-pdf-transcript'], license: ['livechat-enterprise'] }, { - async post() { - const room = await LivechatRooms.findOneById>( - this.urlParams.rid, - { - projection: { _id: 1, open: 1, v: 1, t: 1, pdfTranscriptFileId: 1 }, - }, - ); - if (!room) { - throw new Error('error-invalid-room'); - } - - if (!(await canAccessRoomAsync(room, { _id: this.userId }))) { - throw new Error('error-not-allowed'); - } - - // Flow is as follows: - // 1. On Test Mode, call Transcript.workOnPdf directly - // 2. On Normal Mode, call QueueWorker.queueWork to queue the work - // 3. OmnichannelTranscript.workOnPdf will be called by the worker to generate the transcript - // 4. We be happy :) - await requestPdfTranscript(room, this.userId); - - return API.v1.success(); + authRequired: true, + permissionsRequired: ['request-pdf-transcript'], + license: ['livechat-enterprise'], + body: ajv.compile({ type: 'object', additionalProperties: false }), + response: { + 200: requestTranscriptResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, }, }, + async function action() { + const room = await LivechatRooms.findOneById>( + this.urlParams.rid, + { + projection: { _id: 1, open: 1, v: 1, t: 1, pdfTranscriptFileId: 1 }, + }, + ); + if (!room) { + return API.v1.failure('error-invalid-room'); + } + + if (!(await canAccessRoomAsync(room, { _id: this.userId }))) { + return API.v1.failure('error-not-allowed'); + } + + await requestPdfTranscript(room, this.userId); + + return API.v1.success(); + }, ); + +type RequestTranscriptEndpoints = ExtractRoutesFromAPI; + +declare module '@rocket.chat/rest-typings' { + // eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface + interface Endpoints extends RequestTranscriptEndpoints {} +} diff --git a/apps/meteor/server/api/v1/omnichannel/agentDepartments.ts b/apps/meteor/server/api/v1/omnichannel/agentDepartments.ts index c8420708e4174..d5a34879e3458 100644 --- a/apps/meteor/server/api/v1/omnichannel/agentDepartments.ts +++ b/apps/meteor/server/api/v1/omnichannel/agentDepartments.ts @@ -1,19 +1,56 @@ -import { isGETLivechatAgentsAgentIdDepartmentsParams } from '@rocket.chat/rest-typings'; +import type { ILivechatDepartmentAgents } from '@rocket.chat/core-typings'; +import { + ajv, + isGETLivechatAgentsAgentIdDepartmentsParams, + validateBadRequestErrorResponse, + validateForbiddenErrorResponse, + validateUnauthorizedErrorResponse, +} from '@rocket.chat/rest-typings'; import { API } from '../..'; import { findAgentDepartments } from './lib/agents'; -API.v1.addRoute( +const agentDepartmentsResponseSchema = ajv.compile<{ departments: (ILivechatDepartmentAgents & { departmentName: string })[] }>({ + type: 'object', + properties: { + departments: { + type: 'array', + items: { + allOf: [ + { $ref: '#/components/schemas/ILivechatDepartmentAgents' }, + { + type: 'object', + properties: { departmentName: { type: 'string' } }, + required: ['departmentName'], + }, + ], + }, + }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['departments', 'success'], + additionalProperties: false, +}); + +API.v1.get( 'livechat/agents/:agentId/departments', - { authRequired: true, permissionsRequired: ['view-l-room'], validateParams: isGETLivechatAgentsAgentIdDepartmentsParams }, { - async get() { - const departments = await findAgentDepartments({ - enabledDepartmentsOnly: this.queryParams.enabledDepartmentsOnly && this.queryParams.enabledDepartmentsOnly === 'true', - agentId: this.urlParams.agentId, - }); - - return API.v1.success(departments); + authRequired: true, + permissionsRequired: ['view-l-room'], + query: isGETLivechatAgentsAgentIdDepartmentsParams, + response: { + 200: agentDepartmentsResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, }, }, + async function action() { + const departments = await findAgentDepartments({ + enabledDepartmentsOnly: this.queryParams.enabledDepartmentsOnly && this.queryParams.enabledDepartmentsOnly === 'true', + agentId: this.urlParams.agentId, + }); + + return API.v1.success(departments); + }, ); diff --git a/apps/meteor/server/api/v1/omnichannel/appearance.ts b/apps/meteor/server/api/v1/omnichannel/appearance.ts index b43f5431266ae..b216203e04591 100644 --- a/apps/meteor/server/api/v1/omnichannel/appearance.ts +++ b/apps/meteor/server/api/v1/omnichannel/appearance.ts @@ -1,6 +1,12 @@ -import type { ISettingSelectOption } from '@rocket.chat/core-typings'; +import type { ISetting, ISettingSelectOption } from '@rocket.chat/core-typings'; import { Settings } from '@rocket.chat/models'; -import { isPOSTLivechatAppearanceParams } from '@rocket.chat/rest-typings'; +import { + ajv, + isPOSTLivechatAppearanceParams, + validateBadRequestErrorResponse, + validateForbiddenErrorResponse, + validateUnauthorizedErrorResponse, +} from '@rocket.chat/rest-typings'; import { isTruthy } from '@rocket.chat/tools'; import { API } from '../..'; @@ -8,109 +14,145 @@ import { findAppearance } from './lib/appearance'; import { notifyOnSettingChangedById } from '../../../lib/notifyListener'; import { updateAuditedByUser } from '../../../settings/lib/auditedSettingUpdates'; -API.v1.addRoute( +// TODO: `ISetting.value` is a `SettingValue` union (string | number | boolean | Date | string[] | ...). +// typia emits it as a `oneOf` whose Date (format: date-time) and string branches overlap, so real values +// fail AJV `oneOf` validation (same class as the documented `Date | string` pitfall). Until the setting +// schema's `value` is reworked (e.g. an ajv.ts patch collapsing the union), items stay unconstrained here. +const appearanceResponseSchema = ajv.compile<{ appearance: ISetting[] }>({ + type: 'object', + properties: { + appearance: { type: 'array', items: { type: 'object' } }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['appearance', 'success'], + additionalProperties: false, +}); + +const appearancePostResponseSchema = ajv.compile({ + type: 'object', + properties: { success: { type: 'boolean', enum: [true] } }, + required: ['success'], + additionalProperties: false, +}); + +API.v1.get( 'livechat/appearance', { authRequired: true, permissionsRequired: ['view-livechat-manager'], - validateParams: { - POST: isPOSTLivechatAppearanceParams, + response: { + 200: appearanceResponseSchema, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, }, }, - { - async get() { - const { appearance } = await findAppearance(); + async function action() { + const { appearance } = await findAppearance(); + + return API.v1.success({ + appearance, + }); + }, +); - return API.v1.success({ - appearance, - }); +API.v1.post( + 'livechat/appearance', + { + authRequired: true, + permissionsRequired: ['view-livechat-manager'], + body: isPOSTLivechatAppearanceParams, + response: { + 200: appearancePostResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, }, - async post() { - const settings = this.bodyParams; - - const validSettingList = [ - 'Livechat_title', - 'Livechat_title_color', - 'Livechat_enable_message_character_limit', - 'Livechat_message_character_limit', - 'Livechat_show_agent_info', - 'Livechat_show_agent_email', - 'Livechat_display_offline_form', - 'Livechat_offline_form_unavailable', - 'Livechat_offline_message', - 'Livechat_offline_success_message', - 'Livechat_offline_title', - 'Livechat_offline_title_color', - 'Livechat_offline_email', - 'Livechat_conversation_finished_message', - 'Livechat_conversation_finished_text', - 'Livechat_registration_form', - 'Livechat_name_field_registration_form', - 'Livechat_email_field_registration_form', - 'Livechat_registration_form_message', - 'Livechat_hide_watermark', - 'Livechat_background', - 'Livechat_widget_position', - 'Livechat_hide_system_messages', - 'Omnichannel_allow_visitors_to_close_conversation', - 'Livechat_hide_expand_chat', - ]; - - const valid = settings.every((setting) => validSettingList.includes(setting._id)); - - if (!valid) { - throw new Error('invalid-setting'); - } + }, + async function action() { + const settings = this.bodyParams; + + const validSettingList = [ + 'Livechat_title', + 'Livechat_title_color', + 'Livechat_enable_message_character_limit', + 'Livechat_message_character_limit', + 'Livechat_show_agent_info', + 'Livechat_show_agent_email', + 'Livechat_display_offline_form', + 'Livechat_offline_form_unavailable', + 'Livechat_offline_message', + 'Livechat_offline_success_message', + 'Livechat_offline_title', + 'Livechat_offline_title_color', + 'Livechat_offline_email', + 'Livechat_conversation_finished_message', + 'Livechat_conversation_finished_text', + 'Livechat_registration_form', + 'Livechat_name_field_registration_form', + 'Livechat_email_field_registration_form', + 'Livechat_registration_form_message', + 'Livechat_hide_watermark', + 'Livechat_background', + 'Livechat_widget_position', + 'Livechat_hide_system_messages', + 'Omnichannel_allow_visitors_to_close_conversation', + 'Livechat_hide_expand_chat', + ]; + + const valid = settings.every((setting) => validSettingList.includes(setting._id)); + + if (!valid) { + return API.v1.failure('invalid-setting'); + } + + const dbSettings = await Settings.findByIds(validSettingList, { projection: { _id: 1, value: 1, type: 1, values: 1 } }) + .map((dbSetting) => { + const setting = settings.find(({ _id }) => _id === dbSetting._id); + if (!setting || dbSetting.value === setting.value) { + return; + } - const dbSettings = await Settings.findByIds(validSettingList, { projection: { _id: 1, value: 1, type: 1, values: 1 } }) - .map((dbSetting) => { - const setting = settings.find(({ _id }) => _id === dbSetting._id); - if (!setting || dbSetting.value === setting.value) { - return; - } - - if (dbSetting.type === 'multiSelect' && (!Array.isArray(setting.value) || !validateValues(setting.value, dbSetting.values))) { - return; - } - - switch (dbSetting?.type) { - case 'boolean': - return { - _id: dbSetting._id, - value: setting.value === 'true' || setting.value === true, - }; - case 'int': - return { - _id: dbSetting._id, - value: coerceInt(setting.value), - }; - default: - return { - _id: dbSetting._id, - value: setting?.value, - }; - } - }) - .toArray(); - - const eligibleSettings = dbSettings.filter(isTruthy); - - const auditSettingOperation = updateAuditedByUser({ - _id: this.userId, - username: this.user.username, - ip: this.requestIp, - useragent: this.request.headers.get('user-agent') || '', - }); - - const promises = eligibleSettings.map(({ _id, value }) => auditSettingOperation(Settings.updateValueById, _id, value)); - (await Promise.all(promises)).forEach((value, index) => { - if (value?.modifiedCount) { - void notifyOnSettingChangedById(eligibleSettings[index]._id); + if (dbSetting.type === 'multiSelect' && (!Array.isArray(setting.value) || !validateValues(setting.value, dbSetting.values))) { + return; } - }); - return API.v1.success(); - }, + switch (dbSetting?.type) { + case 'boolean': + return { + _id: dbSetting._id, + value: setting.value === 'true' || setting.value === true, + }; + case 'int': + return { + _id: dbSetting._id, + value: coerceInt(setting.value), + }; + default: + return { + _id: dbSetting._id, + value: setting?.value, + }; + } + }) + .toArray(); + + const eligibleSettings = dbSettings.filter(isTruthy); + + const auditSettingOperation = updateAuditedByUser({ + _id: this.userId, + username: this.user.username ?? '', + ip: this.requestIp ?? '', + useragent: this.request.headers.get('user-agent') || '', + }); + + const promises = eligibleSettings.map(({ _id, value }) => auditSettingOperation(Settings.updateValueById, _id, value)); + (await Promise.all(promises)).forEach((value, index) => { + if (value?.modifiedCount) { + void notifyOnSettingChangedById(eligibleSettings[index]._id); + } + }); + + return API.v1.success(); }, ); diff --git a/apps/meteor/server/api/v1/omnichannel/businessHours.ts b/apps/meteor/server/api/v1/omnichannel/businessHours.ts index b851a24804e03..a4d66c3a915e4 100644 --- a/apps/meteor/server/api/v1/omnichannel/businessHours.ts +++ b/apps/meteor/server/api/v1/omnichannel/businessHours.ts @@ -1,11 +1,13 @@ import type { ILivechatBusinessHour } from '@rocket.chat/core-typings'; import { + ajv, isGETBusinessHourParams, isPOSTLivechatBusinessHoursSaveParams, isPOSTLivechatBusinessHoursRemoveParams, POSTLivechatBusinessHoursRemoveSuccessResponse, POSTLivechatBusinessHoursSaveSuccessResponse, validateBadRequestErrorResponse, + validateForbiddenErrorResponse, validateUnauthorizedErrorResponse, } from '@rocket.chat/rest-typings'; @@ -14,18 +16,36 @@ import { businessHourManager } from '../../../lib/omnichannel/business-hour'; import type { ExtractRoutesFromAPI } from '../../ApiClass'; import { findLivechatBusinessHour } from './lib/businessHours'; -API.v1.addRoute( +const businessHourResponseSchema = ajv.compile<{ businessHour?: ILivechatBusinessHour }>({ + type: 'object', + properties: { + businessHour: { $ref: '#/components/schemas/ILivechatBusinessHour' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['success'], + additionalProperties: false, +}); + +API.v1.get( 'livechat/business-hour', - { authRequired: true, permissionsRequired: ['view-livechat-business-hours'], validateParams: isGETBusinessHourParams }, { - async get() { - const { _id, type } = this.queryParams; - const { businessHour } = await findLivechatBusinessHour(_id, type); - return API.v1.success({ - businessHour, - }); + authRequired: true, + permissionsRequired: ['view-livechat-business-hours'], + query: isGETBusinessHourParams, + response: { + 200: businessHourResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, }, }, + async function action() { + const { _id, type } = this.queryParams; + const { businessHour } = await findLivechatBusinessHour(_id, type); + return API.v1.success({ + businessHour, + }); + }, ); const livechatBusinessHoursEndpoints = API.v1 diff --git a/apps/meteor/server/api/v1/omnichannel/config.ts b/apps/meteor/server/api/v1/omnichannel/config.ts index 399b1f9147ee7..5e4499795118b 100644 --- a/apps/meteor/server/api/v1/omnichannel/config.ts +++ b/apps/meteor/server/api/v1/omnichannel/config.ts @@ -1,4 +1,4 @@ -import { GETLivechatConfigRouting, isGETLivechatConfigParams } from '@rocket.chat/rest-typings'; +import { ajv, GETLivechatConfigRouting, isGETLivechatConfigParams, validateBadRequestErrorResponse } from '@rocket.chat/rest-typings'; import mem from 'mem'; import { API } from '../..'; @@ -13,34 +13,54 @@ const cachedSettings = mem(settings, { cacheKey: JSON.stringify, }); -API.v1.addRoute( +// TODO: `config` is the full Livechat widget config assembled at runtime (enabled, a nested +// `settings` object, `theme`, `triggers`, `departments`, `resources`, plus optional `guest`/`room`/`agent`). +// The manual `Endpoints` entry types it as `{ [k]: string | boolean } & { room?; agent? }`, which does +// not match the actual nested shape, so neither that type nor a simple `additionalProperties` schema is +// faithful. Left as a relaxed `object` until the return type is corrected in rest-typings and a full +// schema (or a registered core-typings type) is written for it. +const livechatConfigResponseSchema = ajv.compile<{ config: Record }>({ + type: 'object', + properties: { + config: { type: 'object' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['config', 'success'], + additionalProperties: false, +}); + +API.v1.get( 'livechat/config', - { validateParams: isGETLivechatConfigParams }, { - async get() { - const enabled = serverSettings.get('Livechat_enabled'); + query: isGETLivechatConfigParams, + response: { + 200: livechatConfigResponseSchema, + 400: validateBadRequestErrorResponse, + }, + }, + async function action() { + const enabled = serverSettings.get('Livechat_enabled'); - if (!enabled) { - return API.v1.success({ config: { enabled: false } }); - } + if (!enabled) { + return API.v1.success({ config: { enabled: false } }); + } - const { token, department, businessUnit } = this.queryParams; - const [config, status, guest] = await Promise.all([ - cachedSettings({ businessUnit }), - online(department), - token ? findGuestWithoutActivity(token) : null, - ]); + const { token, department, businessUnit } = this.queryParams; + const [config, status, guest] = await Promise.all([ + cachedSettings({ businessUnit }), + online(department), + token ? findGuestWithoutActivity(token) : null, + ]); - const room = guest ? await findOpenRoom(guest.token, undefined, this.userId) : undefined; - const agentPromise = room?.servedBy ? findAgent(room.servedBy._id) : null; - const extraInfoPromise = getExtraConfigInfo({ room }); + const room = guest ? await findOpenRoom(guest.token, undefined, this.userId) : undefined; + const agentPromise = room?.servedBy ? findAgent(room.servedBy._id) : null; + const extraInfoPromise = getExtraConfigInfo({ room }); - const [agent, extraInfo] = await Promise.all([agentPromise, extraInfoPromise]); + const [agent, extraInfo] = await Promise.all([agentPromise, extraInfoPromise]); - return API.v1.success({ - config: { ...config, online: status, ...extraInfo, ...(guest && { guest }), ...(room && { room }), ...(agent && { agent }) }, - }); - }, + return API.v1.success({ + config: { ...config, online: status, ...extraInfo, ...(guest && { guest }), ...(room && { room }), ...(agent && { agent }) }, + }); }, ); diff --git a/apps/meteor/server/api/v1/omnichannel/integration.ts b/apps/meteor/server/api/v1/omnichannel/integration.ts index 666ce24dafd5d..85b0a885f7e1a 100644 --- a/apps/meteor/server/api/v1/omnichannel/integration.ts +++ b/apps/meteor/server/api/v1/omnichannel/integration.ts @@ -1,81 +1,102 @@ import { Settings } from '@rocket.chat/models'; -import { isPOSTomnichannelIntegrations } from '@rocket.chat/rest-typings'; +import { + ajv, + isPOSTomnichannelIntegrations, + validateBadRequestErrorResponse, + validateForbiddenErrorResponse, + validateUnauthorizedErrorResponse, +} from '@rocket.chat/rest-typings'; import { API } from '../..'; import { trim } from '../../../../lib/utils/stringUtils'; import { notifyOnSettingChangedById } from '../../../lib/notifyListener'; import { updateAuditedByUser } from '../../../settings/lib/auditedSettingUpdates'; -API.v1.addRoute( +const omnichannelIntegrationsResponseSchema = ajv.compile({ + type: 'object', + properties: { success: { type: 'boolean', enum: [true] } }, + required: ['success'], + additionalProperties: false, +}); + +API.v1.post( 'omnichannel/integrations', - { authRequired: true, permissionsRequired: ['view-livechat-manager'], validateParams: { POST: isPOSTomnichannelIntegrations } }, { - async post() { - const { - LivechatWebhookUrl, - LivechatSecretToken, - LivechatHttpTimeout, - LivechatWebhookOnStart, - LivechatWebhookOnClose, - LivechatWebhookOnChatTaken, - LivechatWebhookOnChatQueued, - LivechatWebhookOnForward, - LivechatWebhookOnOfflineMsg, - LivechatWebhookOnVisitorMessage, - LivechatWebhookOnAgentMessage, - } = this.bodyParams; + authRequired: true, + permissionsRequired: ['view-livechat-manager'], + body: isPOSTomnichannelIntegrations, + response: { + 200: omnichannelIntegrationsResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const { + LivechatWebhookUrl, + LivechatSecretToken, + LivechatHttpTimeout, + LivechatWebhookOnStart, + LivechatWebhookOnClose, + LivechatWebhookOnChatTaken, + LivechatWebhookOnChatQueued, + LivechatWebhookOnForward, + LivechatWebhookOnOfflineMsg, + LivechatWebhookOnVisitorMessage, + LivechatWebhookOnAgentMessage, + } = this.bodyParams; - const settingsIds = [ - typeof LivechatWebhookUrl !== 'undefined' && { - _id: 'Livechat_webhookUrl', - value: trim(String(LivechatWebhookUrl ?? '')), - }, - typeof LivechatSecretToken !== 'undefined' && { - _id: 'Livechat_secret_token', - value: trim(String(LivechatSecretToken ?? '')), - }, - typeof LivechatHttpTimeout !== 'undefined' && { - _id: 'Livechat_http_timeout', - value: Number(LivechatHttpTimeout ?? 0), - }, - typeof LivechatWebhookOnStart !== 'undefined' && { _id: 'Livechat_webhook_on_start', value: !!LivechatWebhookOnStart }, - typeof LivechatWebhookOnClose !== 'undefined' && { _id: 'Livechat_webhook_on_close', value: !!LivechatWebhookOnClose }, - typeof LivechatWebhookOnChatTaken !== 'undefined' && { _id: 'Livechat_webhook_on_chat_taken', value: !!LivechatWebhookOnChatTaken }, - typeof LivechatWebhookOnChatQueued !== 'undefined' && { - _id: 'Livechat_webhook_on_chat_queued', - value: !!LivechatWebhookOnChatQueued, - }, - typeof LivechatWebhookOnForward !== 'undefined' && { _id: 'Livechat_webhook_on_forward', value: !!LivechatWebhookOnForward }, - typeof LivechatWebhookOnOfflineMsg !== 'undefined' && { - _id: 'Livechat_webhook_on_offline_msg', - value: !!LivechatWebhookOnOfflineMsg, - }, - typeof LivechatWebhookOnVisitorMessage !== 'undefined' && { - _id: 'Livechat_webhook_on_visitor_message', - value: !!LivechatWebhookOnVisitorMessage, - }, - typeof LivechatWebhookOnAgentMessage !== 'undefined' && { - _id: 'Livechat_webhook_on_agent_message', - value: !!LivechatWebhookOnAgentMessage, - }, - ].filter(Boolean) as unknown as { _id: string; value: any }[]; + const settingsIds = [ + typeof LivechatWebhookUrl !== 'undefined' && { + _id: 'Livechat_webhookUrl', + value: trim(String(LivechatWebhookUrl ?? '')), + }, + typeof LivechatSecretToken !== 'undefined' && { + _id: 'Livechat_secret_token', + value: trim(String(LivechatSecretToken ?? '')), + }, + typeof LivechatHttpTimeout !== 'undefined' && { + _id: 'Livechat_http_timeout', + value: Number(LivechatHttpTimeout ?? 0), + }, + typeof LivechatWebhookOnStart !== 'undefined' && { _id: 'Livechat_webhook_on_start', value: !!LivechatWebhookOnStart }, + typeof LivechatWebhookOnClose !== 'undefined' && { _id: 'Livechat_webhook_on_close', value: !!LivechatWebhookOnClose }, + typeof LivechatWebhookOnChatTaken !== 'undefined' && { _id: 'Livechat_webhook_on_chat_taken', value: !!LivechatWebhookOnChatTaken }, + typeof LivechatWebhookOnChatQueued !== 'undefined' && { + _id: 'Livechat_webhook_on_chat_queued', + value: !!LivechatWebhookOnChatQueued, + }, + typeof LivechatWebhookOnForward !== 'undefined' && { _id: 'Livechat_webhook_on_forward', value: !!LivechatWebhookOnForward }, + typeof LivechatWebhookOnOfflineMsg !== 'undefined' && { + _id: 'Livechat_webhook_on_offline_msg', + value: !!LivechatWebhookOnOfflineMsg, + }, + typeof LivechatWebhookOnVisitorMessage !== 'undefined' && { + _id: 'Livechat_webhook_on_visitor_message', + value: !!LivechatWebhookOnVisitorMessage, + }, + typeof LivechatWebhookOnAgentMessage !== 'undefined' && { + _id: 'Livechat_webhook_on_agent_message', + value: !!LivechatWebhookOnAgentMessage, + }, + ].filter(Boolean) as unknown as { _id: string; value: any }[]; - const auditSettingOperation = updateAuditedByUser({ - _id: this.userId, - username: this.user.username, - ip: this.requestIp, - useragent: this.request.headers.get('user-agent') || '', - }); + const auditSettingOperation = updateAuditedByUser({ + _id: this.userId, + username: this.user.username ?? '', + ip: this.requestIp ?? '', + useragent: this.request.headers.get('user-agent') || '', + }); - const promises = settingsIds.map((setting) => auditSettingOperation(Settings.updateValueById, setting._id, setting.value)); + const promises = settingsIds.map((setting) => auditSettingOperation(Settings.updateValueById, setting._id, setting.value)); - (await Promise.all(promises)).forEach((value, index) => { - if (value?.modifiedCount) { - void notifyOnSettingChangedById(settingsIds[index]._id); - } - }); + (await Promise.all(promises)).forEach((value, index) => { + if (value?.modifiedCount) { + void notifyOnSettingChangedById(settingsIds[index]._id); + } + }); - return API.v1.success(); - }, + return API.v1.success(); }, ); diff --git a/apps/meteor/server/api/v1/omnichannel/integrations.ts b/apps/meteor/server/api/v1/omnichannel/integrations.ts index 78a885e1c7f4a..2117507b77d8e 100644 --- a/apps/meteor/server/api/v1/omnichannel/integrations.ts +++ b/apps/meteor/server/api/v1/omnichannel/integrations.ts @@ -1,12 +1,35 @@ +import type { ISetting } from '@rocket.chat/core-typings'; +import { ajv, validateForbiddenErrorResponse, validateUnauthorizedErrorResponse } from '@rocket.chat/rest-typings'; + import { API } from '../..'; import { findIntegrationSettings } from './lib/integrations'; -API.v1.addRoute( +// TODO: `ISetting.value` is a `SettingValue` union (string | number | boolean | Date | string[] | ...). +// typia emits it as a `oneOf` whose Date (format: date-time) and string branches overlap, so real values +// fail AJV `oneOf` validation (same class as the documented `Date | string` pitfall). Until the setting +// schema's `value` is reworked (e.g. an ajv.ts patch collapsing the union), items stay unconstrained here. +const integrationSettingsResponseSchema = ajv.compile<{ settings: ISetting[] }>({ + type: 'object', + properties: { + settings: { type: 'array', items: { type: 'object' } }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['settings', 'success'], + additionalProperties: false, +}); + +API.v1.get( 'livechat/integrations.settings', - { authRequired: true, permissionsRequired: ['view-livechat-manager'] }, { - async get() { - return API.v1.success(await findIntegrationSettings()); + authRequired: true, + permissionsRequired: ['view-livechat-manager'], + response: { + 200: integrationSettingsResponseSchema, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, }, }, + async function action() { + return API.v1.success(await findIntegrationSettings()); + }, ); diff --git a/apps/meteor/server/api/v1/omnichannel/offlineMessage.ts b/apps/meteor/server/api/v1/omnichannel/offlineMessage.ts index 6de03900a4a6a..3cda978e5dc84 100644 --- a/apps/meteor/server/api/v1/omnichannel/offlineMessage.ts +++ b/apps/meteor/server/api/v1/omnichannel/offlineMessage.ts @@ -1,24 +1,36 @@ -import { isPOSTLivechatOfflineMessageParams } from '@rocket.chat/rest-typings'; +import { ajv, isPOSTLivechatOfflineMessageParams, validateBadRequestErrorResponse } from '@rocket.chat/rest-typings'; import { API } from '../..'; import { i18n } from '../../../lib/i18n'; import { sendOfflineMessage } from '../../../lib/omnichannel/messages'; -API.v1.addRoute( +const offlineMessageResponseSchema = ajv.compile<{ message: string }>({ + type: 'object', + properties: { + message: { type: 'string' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['message', 'success'], + additionalProperties: false, +}); + +API.v1.post( 'livechat/offline.message', { - validateParams: isPOSTLivechatOfflineMessageParams, + body: isPOSTLivechatOfflineMessageParams, rateLimiterOptions: { numRequestsAllowed: 1, intervalTimeInMS: 5000 }, - }, - { - async post() { - const { name, email, message, department, host } = this.bodyParams; - try { - await sendOfflineMessage({ name, email, message, department, host }); - return API.v1.success({ message: i18n.t('Livechat_offline_message_sent') }); - } catch (e) { - return API.v1.failure(i18n.t('Error_sending_livechat_offline_message')); - } + response: { + 200: offlineMessageResponseSchema, + 400: validateBadRequestErrorResponse, }, }, + async function action() { + const { name, email, message, department, host } = this.bodyParams; + try { + await sendOfflineMessage({ name, email, message, department, host }); + return API.v1.success({ message: i18n.t('Livechat_offline_message_sent') }); + } catch (e) { + return API.v1.failure(i18n.t('Error_sending_livechat_offline_message')); + } + }, ); diff --git a/apps/meteor/server/api/v1/omnichannel/pageVisited.ts b/apps/meteor/server/api/v1/omnichannel/pageVisited.ts index 84296cbaf6a1e..619c03eb8c9f2 100644 --- a/apps/meteor/server/api/v1/omnichannel/pageVisited.ts +++ b/apps/meteor/server/api/v1/omnichannel/pageVisited.ts @@ -1,23 +1,67 @@ import type { IOmnichannelSystemMessage } from '@rocket.chat/core-typings'; -import { isPOSTLivechatPageVisitedParams } from '@rocket.chat/rest-typings'; +import { ajv, isPOSTLivechatPageVisitedParams, validateBadRequestErrorResponse } from '@rocket.chat/rest-typings'; import { API } from '../..'; import { savePageHistory } from '../../../lib/omnichannel/tracking'; -API.v1.addRoute( +const pageVisitedResponseSchema = ajv.compile<{ page: Pick } | void>({ + type: 'object', + properties: { + page: { + type: 'object', + properties: { + msg: { type: 'string' }, + navigation: { + type: 'object', + properties: { + page: { + type: 'object', + properties: { + title: { type: 'string' }, + change: { type: 'string' }, + location: { + type: 'object', + properties: { href: { type: 'string' } }, + required: ['href'], + additionalProperties: false, + }, + }, + required: ['title', 'change', 'location'], + additionalProperties: false, + }, + token: { type: 'string' }, + }, + required: ['page', 'token'], + additionalProperties: false, + }, + }, + required: ['msg'], + additionalProperties: false, + }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['success'], + additionalProperties: false, +}); + +API.v1.post( 'livechat/page.visited', - { validateParams: isPOSTLivechatPageVisitedParams }, { - async post() { - const { token, rid, pageInfo } = this.bodyParams; + body: isPOSTLivechatPageVisitedParams, + response: { + 200: pageVisitedResponseSchema, + 400: validateBadRequestErrorResponse, + }, + }, + async function action() { + const { token, rid, pageInfo } = this.bodyParams; - const message = await savePageHistory(token, rid, pageInfo); - if (!message) { - return API.v1.success(); - } + const message = await savePageHistory(token, rid, pageInfo); + if (!message) { + return API.v1.success(); + } - const { msg, navigation } = message as IOmnichannelSystemMessage; - return API.v1.success({ page: { msg, navigation } }); - }, + const { msg, navigation } = message as IOmnichannelSystemMessage; + return API.v1.success({ page: { msg, navigation } }); }, ); diff --git a/apps/meteor/server/api/v1/omnichannel/queue.ts b/apps/meteor/server/api/v1/omnichannel/queue.ts index 6ed9540957fb7..df55b125d0bda 100644 --- a/apps/meteor/server/api/v1/omnichannel/queue.ts +++ b/apps/meteor/server/api/v1/omnichannel/queue.ts @@ -1,29 +1,93 @@ -import { isGETLivechatQueueParams } from '@rocket.chat/rest-typings'; +import type { PaginatedResult } from '@rocket.chat/rest-typings'; +import { + ajv, + isGETLivechatQueueParams, + validateBadRequestErrorResponse, + validateForbiddenErrorResponse, + validateUnauthorizedErrorResponse, +} from '@rocket.chat/rest-typings'; import { API } from '../..'; import { findQueueMetrics } from './lib/queue'; import { getPaginationItems } from '../../lib/getPaginationItems'; -API.v1.addRoute( - 'livechat/queue', - { authRequired: true, permissionsRequired: ['view-l-room'], validateParams: isGETLivechatQueueParams }, - { - async get() { - const { offset, count } = await getPaginationItems(this.queryParams); - const { sort } = await this.parseJsonQuery(); - const { agentId, includeOfflineAgents, departmentId } = this.queryParams; - const users = await findQueueMetrics({ - agentId, - includeOfflineAgents: includeOfflineAgents === 'true', - departmentId, - pagination: { - offset, - count, - sort, +const queueMetricsResponseSchema = ajv.compile< + PaginatedResult<{ + queue: { + user: { _id: string; username: string; status: string }; + department: { _id?: string; name?: string }; + chats: number; + }[]; + }> +>({ + type: 'object', + properties: { + queue: { + type: 'array', + items: { + type: 'object', + properties: { + user: { + type: 'object', + properties: { + _id: { type: 'string' }, + username: { type: 'string' }, + status: { type: 'string' }, + }, + required: ['_id', 'username', 'status'], + additionalProperties: false, + }, + department: { + type: 'object', + properties: { + _id: { type: 'string' }, + name: { type: 'string' }, + }, + additionalProperties: false, + }, + chats: { type: 'number' }, }, - }); + required: ['user', 'department', 'chats'], + additionalProperties: false, + }, + }, + count: { type: 'number' }, + offset: { type: 'number' }, + total: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['queue', 'count', 'offset', 'total', 'success'], + additionalProperties: false, +}); - return API.v1.success(users); +API.v1.get( + 'livechat/queue', + { + authRequired: true, + permissionsRequired: ['view-l-room'], + query: isGETLivechatQueueParams, + response: { + 200: queueMetricsResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, }, }, + async function action() { + const { offset, count } = await getPaginationItems(this.queryParams); + const { sort } = await this.parseJsonQuery(); + const { agentId, includeOfflineAgents, departmentId } = this.queryParams; + const users = await findQueueMetrics({ + agentId, + includeOfflineAgents: includeOfflineAgents === 'true', + departmentId, + pagination: { + offset, + count, + sort, + }, + }); + + return API.v1.success(users); + }, ); diff --git a/apps/meteor/server/api/v1/omnichannel/transfer.ts b/apps/meteor/server/api/v1/omnichannel/transfer.ts index 2364bf5258a58..3c5e8c0ff834e 100644 --- a/apps/meteor/server/api/v1/omnichannel/transfer.ts +++ b/apps/meteor/server/api/v1/omnichannel/transfer.ts @@ -1,33 +1,101 @@ +import type { IOmnichannelSystemMessage } from '@rocket.chat/core-typings'; import { LivechatRooms } from '@rocket.chat/models'; +import type { PaginatedResult } from '@rocket.chat/rest-typings'; +import { + ajv, + validateBadRequestErrorResponse, + validateForbiddenErrorResponse, + validateUnauthorizedErrorResponse, +} from '@rocket.chat/rest-typings'; import { API } from '../..'; import { findLivechatTransferHistory } from './lib/transfer'; import { getPaginationItems } from '../../lib/getPaginationItems'; -API.v1.addRoute( +const transferHistoryResponseSchema = ajv.compile>({ + type: 'object', + properties: { + history: { + type: 'array', + items: { + type: 'object', + properties: { + comment: { type: 'string' }, + ts: { type: 'string' }, + transferredBy: { + type: 'object', + properties: { + _id: { type: 'string' }, + name: { type: 'string' }, + username: { type: 'string' }, + userType: { type: 'string' }, + }, + required: ['username'], + additionalProperties: false, + }, + transferredTo: { + type: 'object', + properties: { + _id: { type: 'string' }, + name: { type: 'string' }, + username: { type: 'string' }, + userType: { type: 'string' }, + }, + required: ['username'], + additionalProperties: false, + }, + nextDepartment: { + type: 'object', + properties: { _id: { type: 'string' }, name: { type: 'string' } }, + required: ['_id'], + additionalProperties: false, + }, + scope: { type: 'string', enum: ['department', 'agent', 'queue'] }, + }, + required: ['comment', 'transferredBy', 'scope'], + additionalProperties: false, + }, + }, + count: { type: 'number' }, + offset: { type: 'number' }, + total: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['history', 'count', 'offset', 'total', 'success'], + additionalProperties: false, +}); + +API.v1.get( 'livechat/transfer.history/:rid', - { authRequired: true, permissionsRequired: ['view-livechat-rooms'] }, { - async get() { - const { rid } = this.urlParams; + authRequired: true, + permissionsRequired: ['view-livechat-rooms'], + response: { + 200: transferHistoryResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const { rid } = this.urlParams; - const room = await LivechatRooms.findOneById(rid, { projection: { _id: 1 } }); - if (!room) { - throw new Error('invalid-room'); - } - const { offset, count } = await getPaginationItems(this.queryParams); - const { sort } = await this.parseJsonQuery(); + const room = await LivechatRooms.findOneById(rid, { projection: { _id: 1 } }); + if (!room) { + return API.v1.failure('invalid-room'); + } + const { offset, count } = await getPaginationItems(this.queryParams); + const { sort } = await this.parseJsonQuery(); - const history = await findLivechatTransferHistory({ - rid, - pagination: { - offset, - count, - sort, - }, - }); + const history = await findLivechatTransferHistory({ + rid, + pagination: { + offset, + count, + sort, + }, + }); - return API.v1.success(history); - }, + return API.v1.success(history); }, ); diff --git a/apps/meteor/server/api/v1/omnichannel/webhooks.ts b/apps/meteor/server/api/v1/omnichannel/webhooks.ts index d77910ea72785..ff8edfd87a129 100644 --- a/apps/meteor/server/api/v1/omnichannel/webhooks.ts +++ b/apps/meteor/server/api/v1/omnichannel/webhooks.ts @@ -1,95 +1,123 @@ import { Logger } from '@rocket.chat/logger'; +import { + ajv, + validateBadRequestErrorResponse, + validateForbiddenErrorResponse, + validateUnauthorizedErrorResponse, +} from '@rocket.chat/rest-typings'; import type { ExtendedFetchOptions } from '@rocket.chat/server-fetch'; import { serverFetch as fetch } from '@rocket.chat/server-fetch'; import { API } from '../..'; import { settings } from '../../../settings'; +import type { ExtractRoutesFromAPI } from '../../ApiClass'; const logger = new Logger('WebhookTest'); -API.v1.addRoute( +const webhookTestResponseSchema = ajv.compile({ + type: 'object', + properties: { success: { type: 'boolean', enum: [true] } }, + required: ['success'], + additionalProperties: false, +}); + +const webhookEndpoints = API.v1.post( 'livechat/webhook.test', - { authRequired: true, permissionsRequired: ['view-livechat-webhooks'] }, { - async post() { - const sampleData = { - type: 'LivechatSession', - _id: 'fasd6f5a4sd6f8a4sdf', - label: 'title', - topic: 'asiodojf', - createdAt: new Date(), - lastMessageAt: new Date(), - tags: ['tag1', 'tag2', 'tag3'], + authRequired: true, + permissionsRequired: ['view-livechat-webhooks'], + response: { + 200: webhookTestResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const sampleData = { + type: 'LivechatSession', + _id: 'fasd6f5a4sd6f8a4sdf', + label: 'title', + topic: 'asiodojf', + createdAt: new Date(), + lastMessageAt: new Date(), + tags: ['tag1', 'tag2', 'tag3'], + customFields: { + productId: '123456', + }, + visitor: { + _id: '', + name: 'visitor name', + username: 'visitor-username', + department: 'department', + email: 'email@address.com', + phone: '192873192873', + ip: '123.456.7.89', + browser: 'Chrome', + os: 'Linux', customFields: { - productId: '123456', + customerId: '123456', }, - visitor: { - _id: '', - name: 'visitor name', + }, + agent: { + _id: 'asdf89as6df8', + username: 'agent.username', + name: 'Agent Name', + email: 'agent@email.com', + }, + messages: [ + { username: 'visitor-username', - department: 'department', - email: 'email@address.com', - phone: '192873192873', - ip: '123.456.7.89', - browser: 'Chrome', - os: 'Linux', - customFields: { - customerId: '123456', - }, + msg: 'message content', + ts: new Date(), }, - agent: { - _id: 'asdf89as6df8', + { username: 'agent.username', - name: 'Agent Name', - email: 'agent@email.com', + agentId: 'asdf89as6df8', + msg: 'message content from agent', + ts: new Date(), }, - messages: [ - { - username: 'visitor-username', - msg: 'message content', - ts: new Date(), - }, - { - username: 'agent.username', - agentId: 'asdf89as6df8', - msg: 'message content from agent', - ts: new Date(), - }, - ], - }; - const options = { - method: 'POST', - headers: { - 'X-RocketChat-Livechat-Token': settings.get('Livechat_secret_token'), - 'Accept': 'application/json', - }, - body: sampleData, - // SECURITY: Webhooks can only be configured by users with enough privileges. It's ok to disable this check here. - ignoreSsrfValidation: true, - size: 10 * 1024 * 1024, - } as ExtendedFetchOptions; - - const webhookUrl = settings.get('Livechat_webhookUrl'); + ], + }; + const options = { + method: 'POST', + headers: { + 'X-RocketChat-Livechat-Token': settings.get('Livechat_secret_token'), + 'Accept': 'application/json', + }, + body: sampleData, + // SECURITY: Webhooks can only be configured by users with enough privileges. It's ok to disable this check here. + ignoreSsrfValidation: true, + size: 10 * 1024 * 1024, + } as ExtendedFetchOptions; - if (!webhookUrl) { - return API.v1.failure('Webhook_URL_not_set'); - } + const webhookUrl = settings.get('Livechat_webhookUrl'); - try { - logger.debug({ msg: 'Testing webhook', webhookUrl }); - const request = await fetch(webhookUrl, options); - const response = await request.text(); + if (!webhookUrl) { + return API.v1.failure('Webhook_URL_not_set'); + } - logger.debug({ msg: 'Webhook response', response }); - if (request.status === 200) { - return API.v1.success(); - } + try { + logger.debug({ msg: 'Testing webhook', webhookUrl }); + const request = await fetch(webhookUrl, options); + const response = await request.text(); - throw new Error('Invalid status code'); - } catch (error) { - logger.error({ msg: 'Error testing webhook', err: error }); - throw new Error('error-invalid-webhook-response'); + logger.debug({ msg: 'Webhook response', response }); + if (request.status === 200) { + return API.v1.success(); } - }, + + throw new Error('Invalid status code'); + } catch (error) { + logger.error({ msg: 'Error testing webhook', err: error }); + return API.v1.failure('error-invalid-webhook-response'); + } }, ); + +type WebhookEndpoints = ExtractRoutesFromAPI; + +declare module '@rocket.chat/rest-typings' { + // eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface + interface Endpoints extends WebhookEndpoints {} +} diff --git a/docs/api-endpoint-migration.md b/docs/api-endpoint-migration.md index b65829978ccaa..b3964a4828b1d 100644 --- a/docs/api-endpoint-migration.md +++ b/docs/api-endpoint-migration.md @@ -37,7 +37,7 @@ API.v1.addRoute( ); ``` -Source: `apps/meteor/app/api/server/v1/channels.ts` +Source: `apps/meteor/server/api/v1/channels.ts` ## New Pattern (AFTER) @@ -123,7 +123,7 @@ API.v1.get( ); ``` -Source: `apps/meteor/app/api/server/v1/moderation.ts` +Source: `apps/meteor/server/api/v1/moderation.ts` ## Step-by-Step Migration @@ -254,7 +254,7 @@ For response fields that use complex types already defined in `@rocket.chat/core >(); ``` -2. **`apps/meteor/app/api/server/ajv.ts`** registers all generated schemas into the shared AJV instance: +2. **`apps/meteor/server/api/validation/ajv.ts`** registers all generated schemas into the shared AJV instance: ```typescript import { schemas } from '@rocket.chat/core-typings'; @@ -290,7 +290,7 @@ For response fields that use complex types already defined in `@rocket.chat/core }); ``` -Source: `apps/meteor/app/api/server/v1/custom-sounds.ts` +Source: `apps/meteor/server/api/v1/custom-sounds.ts` ### Available `$ref` schemas @@ -313,6 +313,9 @@ These types are already registered and available via `$ref`: - `#/components/schemas/IModerationAudit` - `#/components/schemas/IModerationReport` - `#/components/schemas/IBanner` +- `#/components/schemas/ILivechatBusinessHour` +- `#/components/schemas/ILivechatDepartmentAgents` +- `#/components/schemas/ISettingBase` (and the other `ISetting*` variants — `ISetting` is a union, so typia emits one schema per variant). ⚠️ Do not `$ref` these to validate settings responses: `ISettingBase.value` is a `SettingValue` union whose typia `oneOf` (Date `date-time` vs `string`) overlaps and fails AJV validation — the same class as the [`Date | string` pitfall](#known-pitfall-date--string-unions). Leave `value`/whole setting items relaxed until that union is reworked. - `#/components/schemas/CallHistoryItem` - `#/components/schemas/ICustomUserStatus` - `#/components/schemas/SlashCommand` @@ -447,7 +450,7 @@ If you need a `$ref` for a type that is not yet registered: 1. Edit `packages/core-typings/src/Ajv.ts` 2. Import the type and add it to the `typia.json.schemas<[...]>()` type parameter list 3. Rebuild `core-typings`: `yarn workspace @rocket.chat/core-typings run build` -4. The new schema will be automatically registered at startup via `apps/meteor/app/api/server/ajv.ts` +4. The new schema will be automatically registered at startup via `apps/meteor/server/api/validation/ajv.ts` ## Chaining Endpoints and Type Augmentation @@ -514,7 +517,7 @@ declare module '@rocket.chat/rest-typings' { } ``` -Source: `apps/meteor/app/api/server/v1/invites.ts` +Source: `apps/meteor/server/api/v1/invites.ts` ### Rules @@ -533,6 +536,85 @@ Once the `Endpoints` interface is augmented, the entire stack benefits: - **Tests**: response shape is inferred from the endpoint definition - **OpenAPI**: routes appear in the generated documentation +## Migrating an Endpoint Already Declared in `Endpoints` + +The chaining + augmentation pattern above is for **new** endpoints that have no type yet (e.g. `invites.ts`, `custom-sounds.ts`). Many legacy `addRoute` endpoints, however, are **already** declared manually in `packages/rest-typings/src/v1/*.ts` (e.g. the `OmnichannelEndpoints` type). For those, **do not** also augment via `ExtractRoutesFromAPI`. + +Two declarations of the same route key in the `Endpoints` interface produce: + +``` +TS2717: Subsequent property declarations must have the same type. +``` + +So when the route already has a manual entry, migrate it the way `apps/meteor/server/api/v1/moderation.ts` does: + +1. Convert `addRoute` → `API.v1.get/post/put/delete` (a plain statement, **not** chained into a `const`). +2. Move `validateParams` → `query` / `body`. +3. Add the `response` schemas. +4. **Leave the manual `rest-typings` entry untouched** — no `ExtractRoutesFromAPI`, no `declare module`. + +### How to decide + +``` +Is the route key already present in packages/rest-typings/src/v1/*.ts? + ├─ yes → keep the manual entry, DO NOT augment (moderation pattern) + └─ no → chain + augment with ExtractRoutesFromAPI (invites pattern) +``` + +Quick check: `git grep "'/v1/'" -- packages/rest-typings`. + +## Common Type Errors When Migrating + +### `success` is missing / `SuccessResult` mismatch + +`SuccessResult` merges the `success: true` flag into the body **only when `T extends Record`**: + +```typescript +body: T extends Record ? { success: true } & T : T; +``` + +Object literals and type aliases (e.g. `PaginatedResult`) satisfy that constraint, but a named **`interface` does not** (interfaces have no implicit index signature). So returning a lib function whose return type is an `interface` fails: + +``` +Type 'IResponse' is not assignable to type '{ success: true } & { ... }'. + Property 'success' is missing in type 'IResponse'. +``` + +**Fix:** spread the result into an object literal so it satisfies `Record`: + +```typescript +// fails when findBusinessHours(): Promise (an interface) +return API.v1.success(await findBusinessHours(...)); + +// works +return API.v1.success({ ...(await findBusinessHours(...)) }); +``` + +Passing an inline object literal or a `PaginatedResult`-typed value works directly. + +### `this.user.username` / `this.requestIp` are `string | undefined` + +The new `TypedAction` `this` types these request-context fields as optional (they were effectively `string` under `addRoute`). Use `?? ''` when a callee expects `string` (precedent: `assets.ts`, `misc.ts` feeding `updateAuditedByUser`): + +```typescript +username: this.user.username ?? '', +ip: this.requestIp ?? '', +``` + +### `API.v1.failure(...)` requires `400` in the `response` block + +If the handler can call `API.v1.failure(...)`, the action's return type is not assignable to `TypedAction` unless `400: validateBadRequestErrorResponse` is declared, because `failure()` resolves to a `400` response. + +### Method-scoped permissions (`hasAny` / OR semantics) + +`permissionsRequired` as an array is AND (all required). To keep OR semantics or per-method permissions, the object form is supported in the new API too: + +```typescript +permissionsRequired: { + PUT: { permissions: ['view-l-room', 'manage-livechat-sla'], operation: 'hasAny' }, +}, +``` + ## Endpoints with Multiple HTTP Methods When an `addRoute` registers both GET and POST (or other combinations), split them into separate calls: @@ -598,17 +680,20 @@ When migrating an endpoint, search for its tests and update: ## Reference Files -| Pattern | File | -| -------------------------------- | ------------------------------------------------ | -| Chaining + augmentation | `apps/meteor/app/api/server/v1/invites.ts` | -| Chaining + augmentation + `$ref` | `apps/meteor/app/api/server/v1/custom-sounds.ts` | -| GET with `$ref` to typia schemas | `apps/meteor/app/api/server/v1/custom-sounds.ts` | -| GET with pagination | `apps/meteor/app/api/server/v1/moderation.ts` | -| POST endpoint | `apps/meteor/app/api/server/v1/import.ts` | -| Multiple endpoints (misc) | `apps/meteor/app/api/server/v1/misc.ts` | -| GET with permissions | `apps/meteor/app/api/server/v1/permissions.ts` | -| Typia schema generation | `packages/core-typings/src/Ajv.ts` | -| AJV schema registration | `apps/meteor/app/api/server/ajv.ts` | -| Error response validators | `packages/rest-typings/src/v1/Ajv.ts` | -| Request validators (examples) | `packages/rest-typings/src/v1/moderation/` | -| Router implementation | `packages/http-router/src/Router.ts` | +> Paths below reflect the backend reorganization (#41126): REST endpoints now live under `apps/meteor/server/api/` (and `apps/meteor/ee/server/api/`), not the old `apps/meteor/app/api/server/`. + +| Pattern | File | +| ----------------------------------------------- | -------------------------------------------- | +| Chaining + augmentation (new endpoint) | `apps/meteor/server/api/v1/invites.ts` | +| Chaining + augmentation + `$ref` | `apps/meteor/server/api/v1/custom-sounds.ts` | +| GET with `$ref` to typia schemas | `apps/meteor/server/api/v1/custom-sounds.ts` | +| GET with pagination | `apps/meteor/server/api/v1/moderation.ts` | +| Pre-existing typed endpoint (keep manual entry) | `apps/meteor/server/api/v1/moderation.ts` | +| POST endpoint | `apps/meteor/server/api/v1/import.ts` | +| Multiple endpoints (misc) | `apps/meteor/server/api/v1/misc.ts` | +| GET with permissions | `apps/meteor/server/api/v1/permissions.ts` | +| Typia schema generation | `packages/core-typings/src/Ajv.ts` | +| AJV schema registration | `apps/meteor/server/api/validation/ajv.ts` | +| Error response validators | `packages/rest-typings/src/v1/Ajv.ts` | +| Request validators (examples) | `packages/rest-typings/src/v1/moderation/` | +| Router implementation | `packages/http-router/src/Router.ts` | diff --git a/packages/core-typings/src/Ajv.ts b/packages/core-typings/src/Ajv.ts index 7d9a8cebbee81..08cdcc42c2241 100644 --- a/packages/core-typings/src/Ajv.ts +++ b/packages/core-typings/src/Ajv.ts @@ -11,6 +11,8 @@ import type { IEmojiCustom } from './IEmojiCustom'; import type { IIntegration } from './IIntegration'; import type { IIntegrationHistory } from './IIntegrationHistory'; import type { IInvite } from './IInvite'; +import type { ILivechatBusinessHour } from './ILivechatBusinessHour'; +import type { ILivechatDepartmentAgents } from './ILivechatDepartmentAgents'; import type { IMeApiUser } from './IMeApiUser'; import type { IMessage } from './IMessage'; import type { IModerationAudit, IModerationReport } from './IModerationReport'; @@ -19,6 +21,7 @@ import type { IPermission } from './IPermission'; import type { IReadReceiptWithUser } from './IReadReceipt'; import type { IRole } from './IRole'; import type { IRoom, IDirectoryChannelResult, IRoomAdmin } from './IRoom'; +import type { ISetting } from './ISetting'; import type { ISubscription } from './ISubscription'; import type { IUser, IDirectoryUserResult } from './IUser'; import type { VideoConference, VideoConferenceInstructions } from './IVideoConference'; @@ -60,6 +63,9 @@ export const schemas = typia.json.schemas< | IIntegrationHistory | IMeApiUser | IReadReceiptWithUser + | ILivechatBusinessHour + | ILivechatDepartmentAgents + | ISetting ), CallHistoryItem, ICustomUserStatus, diff --git a/packages/core-typings/src/ILivechatBusinessHour.ts b/packages/core-typings/src/ILivechatBusinessHour.ts index e2f705f784b31..490190caa2409 100644 --- a/packages/core-typings/src/ILivechatBusinessHour.ts +++ b/packages/core-typings/src/ILivechatBusinessHour.ts @@ -22,7 +22,7 @@ export interface IBusinessHourWorkHour { start: IBusinessHourTime; finish: IBusinessHourTime; open: boolean; - code: unknown; + code?: unknown; } export interface IBusinessHourTimezone { @@ -37,5 +37,6 @@ export interface ILivechatBusinessHour extends IRocketChatRecord { timezone: IBusinessHourTimezone; ts: Date; workHours: IBusinessHourWorkHour[]; - departments?: ILivechatDepartment[]; + // Populated as a projection: default business hours attach `{ _id }`, custom ones `{ _id, name }`. + departments?: (Pick & { name?: string })[]; }