Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,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 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 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
4 changes: 2 additions & 2 deletions apps/meteor/client/views/omnichannel/hooks/useAgentsList.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ const DEFAULT_QUERY_LIMIT = 25;

export const useAgentsList = (options: AgentsListOptions) => {
const { t } = useTranslation();
const getAgents = useEndpoint('GET', '/v1/livechat/users/agent');
const getAgents = useEndpoint('GET', '/v1/livechat/users/:type', { type: 'agent' });
const {
filter,
onlyAvailable = false,
Expand All @@ -41,7 +41,7 @@ export const useAgentsList = (options: AgentsListOptions) => {
});

return useInfiniteQuery({
queryKey: ['/v1/livechat/users/agent', { filter, onlyAvailable, showIdleAgents, excludeId, haveAll, haveNoAgentsSelectedOption }],
queryKey: ['/v1/livechat/users/:type', { type: 'agent', filter, onlyAvailable, showIdleAgents, excludeId, haveAll, haveNoAgentsSelectedOption }],
queryFn: async ({ pageParam: offset = 0 }) => {
const { users, ...data } = await getAgents({
...(filter && { text: filter }),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ const AddManager = (): ReactElement => {

const queryClient = useQueryClient();

const { mutateAsync: saveAction } = useEndpointMutation('POST', '/v1/livechat/users/manager', {
const { mutateAsync: saveAction } = useEndpointMutation('POST', '/v1/livechat/users/:type', {
keys: { type: 'manager' },
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: omnichannelQueryKeys.managers() });
setUsername('');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ const ManagersTable = () => {
500,
);

const getManagers = useEndpoint('GET', '/v1/livechat/users/manager');
const getManagers = useEndpoint('GET', '/v1/livechat/users/:type', { type: 'manager' });
const { data, isLoading, isSuccess, isError, refetch } = useQuery({
queryKey: omnichannelQueryKeys.managers(query),
queryFn: async () => getManagers(query),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ import { omnichannelQueryKeys } from '../../../lib/queryKeys';
const RemoveManagerButton = ({ _id }: { _id: string }): ReactElement => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const { mutateAsync: deleteAction } = useEndpointMutation('DELETE', '/v1/livechat/users/manager/:_id', {
keys: { _id },
const { mutateAsync: deleteAction } = useEndpointMutation('DELETE', '/v1/livechat/users/:type/:_id', {
keys: { type: 'manager', _id },
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: omnichannelQueryKeys.managers() });
},
Expand Down
Loading