Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/migrate-livechat-users-openapi.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@rocket.chat/meteor": minor
"@rocket.chat/rest-typings": minor
---

Add OpenAPI support for the livechat/users/:type and livechat/users/:type/:_id API endpoints by migrating to chained route definitions with AJV body, query, and response validation.
194 changes: 163 additions & 31 deletions apps/meteor/app/livechat/imports/server/rest/users.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,123 @@
import { Users } from '@rocket.chat/models';
import { isLivechatUsersManagerGETProps, isPOSTLivechatUsersTypeProps } from '@rocket.chat/rest-typings';
import { check } from 'meteor/check';
import { ajv, validateBadRequestErrorResponse, validateUnauthorizedErrorResponse, validateForbiddenErrorResponse } from '@rocket.chat/rest-typings';

import { API } from '../../../../api/server';
import type { ExtractRoutesFromAPI } from '../../../../api/server/ApiClass';
import { getPaginationItems } from '../../../../api/server/helpers/getPaginationItems';
import { hasAtLeastOnePermissionAsync } from '../../../../authorization/server/functions/hasPermission';
import { findAgents, findManagers } from '../../../server/api/lib/users';
import { addManager, addAgent, removeAgent, removeManager } from '../../../server/lib/omni-users';

type LivechatUsersManagerGETProps = {
text?: string;
fields?: string;
onlyAvailable?: boolean;
excludeId?: string;
showIdleAgents?: boolean;
count?: number;
offset?: number;
sort?: string;
query?: string;
};

const LivechatUsersManagerGETSchema = {
type: 'object',
properties: {
text: { type: 'string', nullable: true },
onlyAvailable: { type: 'boolean', nullable: true },
excludeId: { type: 'string', nullable: true },
showIdleAgents: { type: 'boolean', nullable: true },
count: { type: 'number', nullable: true },
offset: { type: 'number', nullable: true },
sort: { type: 'string', nullable: true },
query: { type: 'string', nullable: true },
fields: { type: 'string', nullable: true },
},
required: [],
additionalProperties: false,
};

const isLivechatUsersManagerGETProps = ajv.compile<LivechatUsersManagerGETProps>(LivechatUsersManagerGETSchema);

type POSTLivechatUsersTypeProps = {
username: string;
};

const POSTLivechatUsersTypePropsSchema = {
type: 'object',
properties: {
username: { type: 'string' },
},
required: ['username'],
additionalProperties: false,
};

const isPOSTLivechatUsersTypeProps = ajv.compile<POSTLivechatUsersTypeProps>(POSTLivechatUsersTypePropsSchema);

const paginatedUsersResponseSchema = ajv.compile<{
users: object[];
count: number;
offset: number;
total: number;
}>({
type: 'object',
properties: {
users: { type: 'array', items: { $ref: '#/components/schemas/IUser' } },
count: { type: 'number' },
offset: { type: 'number' },
total: { type: 'number' },
success: { type: 'boolean', enum: [true] },
},
required: ['users', 'count', 'offset', 'total', 'success'],
additionalProperties: false,
});

const postUserResponseSchema = ajv.compile<{ user: object }>({
type: 'object',
properties: {
user: { $ref: '#/components/schemas/IUser' },
success: { type: 'boolean', enum: [true] },
},
required: ['user', 'success'],
additionalProperties: false,
});

const successOnlyResponseSchema = ajv.compile<void>({
type: 'object',
properties: {
success: { type: 'boolean', enum: [true] },
},
required: ['success'],
additionalProperties: false,
});

const getUserByIdResponseSchema = ajv.compile<{ user: object | null }>({
type: 'object',
properties: {
user: { oneOf: [{ $ref: '#/components/schemas/IUser' }, { type: 'null' }] },
success: { type: 'boolean', enum: [true] },
},
required: ['user', 'success'],
additionalProperties: false,
});

const emptyStringArray: string[] = [];

API.v1.addRoute(
'livechat/users/:type',
{
authRequired: true,
permissionsRequired: {
'POST': ['view-livechat-manager'],
'*': emptyStringArray,
const livechatUsersEndpoints = API.v1
.get(
'livechat/users/:type',
{
authRequired: true,
permissionsRequired: emptyStringArray,
query: isLivechatUsersManagerGETProps,
response: {
200: paginatedUsersResponseSchema,
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
},
},
validateParams: {
GET: isLivechatUsersManagerGETProps,
POST: isPOSTLivechatUsersTypeProps,
},
},
{
async get() {
check(this.urlParams, {
type: String,
});
async function action() {
const { offset, count } = await getPaginationItems(this.queryParams);
const { sort } = await this.parseJsonQuery();
const { text } = this.queryParams;
Expand Down Expand Up @@ -70,7 +160,21 @@ API.v1.addRoute(
}
throw new Error('Invalid type');
},
async post() {
)
.post(
'livechat/users/:type',
{
authRequired: true,
permissionsRequired: ['view-livechat-manager'],
body: isPOSTLivechatUsersTypeProps,
response: {
200: postUserResponseSchema,
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
},
},
async function action() {
if (this.urlParams.type === 'agent') {
const user = await addAgent(this.bodyParams.username);
if (user) {
Expand All @@ -87,14 +191,21 @@ API.v1.addRoute(

return API.v1.failure();
},
},
);

API.v1.addRoute(
'livechat/users/:type/:_id',
{ authRequired: true, permissionsRequired: ['view-livechat-manager'] },
{
async get() {
)
.get(
'livechat/users/:type/:_id',
{
authRequired: true,
permissionsRequired: ['view-livechat-manager'],
query: undefined,
response: {
200: getUserByIdResponseSchema,
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
},
},
async function action() {
if (!['agent', 'manager'].includes(this.urlParams.type)) {
throw new Error('Invalid type');
}
Expand All @@ -107,7 +218,21 @@ API.v1.addRoute(
// TODO: throw error instead of returning null
return API.v1.success({ user });
},
async delete() {
)
.delete(
'livechat/users/:type/:_id',
{
authRequired: true,
permissionsRequired: ['view-livechat-manager'],
query: undefined,
response: {
200: successOnlyResponseSchema,
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
},
},
async function action() {
if (this.urlParams.type === 'agent') {
if (await removeAgent(this.urlParams._id)) {
return API.v1.success();
Expand All @@ -122,5 +247,12 @@ API.v1.addRoute(

return API.v1.failure();
},
},
);
);

type LivechatUsersEndpoints = ExtractRoutesFromAPI<typeof livechatUsersEndpoints>;

declare module '@rocket.chat/rest-typings' {
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface
interface Endpoints extends LivechatUsersEndpoints {}
}

Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { omnichannelQueryKeys } from '../../../lib/queryKeys';
const AgentEditWithData = ({ uid }: { uid: ILivechatAgent['_id'] }): ReactElement => {
const { t } = useTranslation();

const getAgentById = useEndpoint('GET', '/v1/livechat/users/agent/:_id', { _id: uid });
const getAgentById = useEndpoint('GET', '/v1/livechat/users/:type/:_id', { type: 'agent', _id: uid });
const getAgentDepartments = useEndpoint('GET', '/v1/livechat/agents/:agentId/departments', { agentId: uid });

const { data, isPending, error } = useQuery({
Expand Down
10 changes: 6 additions & 4 deletions apps/meteor/client/views/omnichannel/agents/AgentInfo.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { ILivechatAgent, Serialized } from '@rocket.chat/core-typings';
import { Box, Margins, ButtonGroup } from '@rocket.chat/fuselage';
import {
ContextualbarTitle,
Expand Down Expand Up @@ -26,7 +27,7 @@ type AgentInfoProps = {
const AgentInfo = ({ uid }: AgentInfoProps) => {
const { t } = useTranslation();
const router = useRouter();
const getAgentById = useEndpoint('GET', '/v1/livechat/users/agent/:_id', { _id: uid });
const getAgentById = useEndpoint('GET', '/v1/livechat/users/:type/:_id', { type: 'agent', _id: uid });
const { data, isPending, isError } = useQuery({
queryKey: ['livechat-getAgentInfoById', uid],
queryFn: async () => getAgentById(),
Expand All @@ -39,11 +40,12 @@ const AgentInfo = ({ uid }: AgentInfoProps) => {
return <ContextualbarSkeletonBody />;
}

if (isError) {
if (isError || !data?.user) {
return <Box mbs={16}>{t('User_not_found')}</Box>;
}

const { username, statusLivechat, status: userStatus } = data?.user;
const user = data.user as Serialized<ILivechatAgent>;
const { username, statusLivechat, status: userStatus } = user;

return (
<>
Expand Down Expand Up @@ -77,7 +79,7 @@ const AgentInfo = ({ uid }: AgentInfoProps) => {
<InfoPanelText>{statusLivechat === 'available' ? t('Available') : t('Not_Available')}</InfoPanelText>
</>
)}
{MaxChatsPerAgentDisplay && <MaxChatsPerAgentDisplay maxNumberSimultaneousChat={data.user.livechat?.maxNumberSimultaneousChat} />}
{MaxChatsPerAgentDisplay && <MaxChatsPerAgentDisplay maxNumberSimultaneousChat={user.livechat?.maxNumberSimultaneousChat} />}
</Margins>
</ContextualbarScrollableContent>
</>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ const AddAgent = () => {

const usernameFieldId = useId();

const { mutateAsync: saveAction } = useEndpointMutation('POST', '/v1/livechat/users/agent', {
const { mutateAsync: saveAction } = useEndpointMutation('POST', '/v1/livechat/users/:type', {
keys: { type: 'agent' },
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: omnichannelQueryKeys.agents() });
setUsername('');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { useQuery } from '@tanstack/react-query';
import { omnichannelQueryKeys } from '../../../../lib/queryKeys';

export const useAgentsQuery = (query: PaginatedRequest = {}) => {
const getAgents = useEndpoint('GET', '/v1/livechat/users/agent');
const getAgents = useEndpoint('GET', '/v1/livechat/users/:type', { type: 'agent' });

return useQuery({
queryKey: omnichannelQueryKeys.agents(query),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export const useRemoveAgent = (uid: ILivechatAgent['_id']) => {
const queryClient = useQueryClient();
const dispatchToastMessage = useToastMessageDispatch();

const deleteAction = useEndpoint('DELETE', '/v1/livechat/users/agent/:_id', { _id: uid });
const deleteAction = useEndpoint('DELETE', '/v1/livechat/users/:type/:_id', { type: 'agent', _id: uid });

const handleDelete = useEffectEvent(() => {
const onDeleteAgent = async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,9 @@ const mockDepartmentAgent = {

const AppRoot = mockAppRoot()
.withEndpoint('GET', '/v1/livechat/department', () => ({ departments: [mockDepartment], count: 1, offset: 0, total: 1 }))
.withEndpoint('GET', '/v1/livechat/users/agent', () => ({ users: [{ ...mockAgent, departments: [] }], count: 1, offset: 0, total: 1 }))
.withEndpoint('GET', '/v1/livechat/users/:type', () => ({ users: [{ ...mockAgent, departments: [] }], count: 1, offset: 0, total: 1 }))
.withEndpoint('GET', '/v1/livechat/department/:_id', () => ({ department: mockDepartment, agents: [mockDepartmentAgent] }))
.withEndpoint('GET', '/v1/livechat/users/agent/:_id', () => ({ user: mockAgent }))
.withEndpoint('GET', '/v1/livechat/users/:type/:_id', () => ({ user: mockAgent }))
.withEndpoint('GET', '/v1/omnichannel/outbound/providers', () => ({ providers: [providerMock] }))
.withEndpoint('GET', '/v1/omnichannel/outbound/providers/:id/metadata', () => ({ metadata: providerMock }))
.withEndpoint('GET', '/v1/omnichannel/contacts.get', () => ({ contact: contactMock }))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,9 @@ const mockDepartmentAgent = {

const AppRoot = mockAppRoot()
.withEndpoint('GET', '/v1/livechat/department', () => ({ departments: [mockDepartment], count: 1, offset: 0, total: 1 }))
.withEndpoint('GET', '/v1/livechat/users/agent', () => ({ users: [{ ...mockAgent, departments: [] }], count: 1, offset: 0, total: 1 }))
.withEndpoint('GET', '/v1/livechat/users/:type', () => ({ users: [{ ...mockAgent, departments: [] }], count: 1, offset: 0, total: 1 }))
.withEndpoint('GET', '/v1/livechat/department/:_id', () => ({ department: mockDepartment, agents: [mockDepartmentAgent] }))
.withEndpoint('GET', '/v1/livechat/users/agent/:_id', () => ({ user: mockAgent }))
.withEndpoint('GET', '/v1/livechat/users/:type/:_id', () => ({ user: mockAgent }))
.build();

const meta = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ function AddAgent({ agentList, onAdd, 'aria-labelledby': ariaLabelledBy }: AddAg

const [userId, setUserId] = useState('');

const { mutateAsync: getAgent } = useEndpointMutation('GET', '/v1/livechat/users/agent/:_id', { keys: { _id: userId } });
const { mutateAsync: getAgent } = useEndpointMutation('GET', '/v1/livechat/users/:type/:_id', { keys: { type: 'agent', _id: userId } });

const dispatchToastMessage = useToastMessageDispatch();

Expand All @@ -30,9 +30,12 @@ function AddAgent({ agentList, onAdd, 'aria-labelledby': ariaLabelledBy }: AddAg
return;
}

const {
user: { _id, username, name },
} = await getAgent();
const { user } = await getAgent();
if (!user) {
dispatchToastMessage({ type: 'error', message: t('User_not_found') });
return;
}
const { _id, username, name } = user;

if (!agentList.some(({ agentId }) => agentId === _id)) {
setUserId('');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ const mockGetAgents = jest.fn();

const appRoot = new MockedAppRootBuilder()
.withTranslations('en', 'core', { All: 'All', Empty_no_agent_selected: 'Empty, no agent selected' })
.withEndpoint('GET', '/v1/livechat/users/agent', mockGetAgents);
.withEndpoint('GET', '/v1/livechat/users/:type', mockGetAgents);

afterEach(() => {
jest.clearAllMocks();
Expand Down
Loading