Skip to content

Commit dc8f9f2

Browse files
committed
refactor: Migrate livechat/users endpoints to OpenAPI with AJV validation
1 parent 1cac8c3 commit dc8f9f2

File tree

3 files changed

+189
-153
lines changed

3 files changed

+189
-153
lines changed
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@rocket.chat/meteor": minor
3+
"@rocket.chat/rest-typings": minor
4+
---
5+
6+
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.

apps/meteor/app/livechat/imports/server/rest/users.ts

Lines changed: 174 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,139 @@
11
import { Users } from '@rocket.chat/models';
2-
import { isLivechatUsersManagerGETProps, isPOSTLivechatUsersTypeProps } from '@rocket.chat/rest-typings';
3-
import { check } from 'meteor/check';
2+
import { ajv, validateBadRequestErrorResponse, validateUnauthorizedErrorResponse, validateForbiddenErrorResponse } from '@rocket.chat/rest-typings';
43

54
import { API } from '../../../../api/server';
5+
import type { ExtractRoutesFromAPI } from '../../../../api/server/ApiClass';
66
import { getPaginationItems } from '../../../../api/server/helpers/getPaginationItems';
77
import { hasAtLeastOnePermissionAsync } from '../../../../authorization/server/functions/hasPermission';
88
import { findAgents, findManagers } from '../../../server/api/lib/users';
99
import { addManager, addAgent, removeAgent, removeManager } from '../../../server/lib/omni-users';
1010

11+
// --- Local types and AJV schemas (moved from rest-typings) ---
12+
13+
type LivechatUsersManagerGETLocal = {
14+
text?: string;
15+
fields?: string;
16+
onlyAvailable?: boolean;
17+
excludeId?: string;
18+
showIdleAgents?: boolean;
19+
count?: number;
20+
offset?: number;
21+
sort?: string;
22+
query?: string;
23+
};
24+
25+
const LivechatUsersManagerGETLocalSchema = {
26+
type: 'object',
27+
properties: {
28+
text: { type: 'string', nullable: true },
29+
onlyAvailable: { type: 'boolean', nullable: true },
30+
excludeId: { type: 'string', nullable: true },
31+
showIdleAgents: { type: 'boolean', nullable: true },
32+
count: { type: 'number', nullable: true },
33+
offset: { type: 'number', nullable: true },
34+
sort: { type: 'string', nullable: true },
35+
query: { type: 'string', nullable: true },
36+
fields: { type: 'string', nullable: true },
37+
},
38+
required: [],
39+
additionalProperties: false,
40+
};
41+
42+
const isLivechatUsersManagerGETLocal = ajv.compile<LivechatUsersManagerGETLocal>(LivechatUsersManagerGETLocalSchema);
43+
44+
type POSTLivechatUsersTypeLocal = {
45+
username: string;
46+
};
47+
48+
const POSTLivechatUsersTypeLocalSchema = {
49+
type: 'object',
50+
properties: {
51+
username: { type: 'string' },
52+
},
53+
required: ['username'],
54+
additionalProperties: false,
55+
};
56+
57+
const isPOSTLivechatUsersTypeLocal = ajv.compile<POSTLivechatUsersTypeLocal>(POSTLivechatUsersTypeLocalSchema);
58+
59+
// --- Response schemas ---
60+
61+
const paginatedUsersResponseSchema = ajv.compile<{
62+
users: object[];
63+
count: number;
64+
offset: number;
65+
total: number;
66+
}>({
67+
type: 'object',
68+
properties: {
69+
// ILivechatAgent is not registered in typia (extends IUser with livechat-specific fields),
70+
// so we use { type: 'object' } as a fallback.
71+
users: { type: 'array', items: { type: 'object' } },
72+
count: { type: 'number' },
73+
offset: { type: 'number' },
74+
total: { type: 'number' },
75+
success: { type: 'boolean', enum: [true] },
76+
},
77+
required: ['users', 'count', 'offset', 'total', 'success'],
78+
additionalProperties: false,
79+
});
80+
81+
const postUserResponseSchema = ajv.compile<{ user: object }>({
82+
type: 'object',
83+
properties: {
84+
user: { type: 'object' },
85+
success: { type: 'boolean', enum: [true] },
86+
},
87+
required: ['user', 'success'],
88+
additionalProperties: false,
89+
});
90+
91+
const successOnlyResponseSchema = ajv.compile<void>({
92+
type: 'object',
93+
properties: {
94+
success: { type: 'boolean', enum: [true] },
95+
},
96+
required: ['success'],
97+
additionalProperties: false,
98+
});
99+
100+
const getUserByIdResponseSchema = ajv.compile<{ user: object | null }>({
101+
type: 'object',
102+
properties: {
103+
user: { type: ['object', 'null'] },
104+
success: { type: 'boolean', enum: [true] },
105+
},
106+
required: ['user', 'success'],
107+
additionalProperties: false,
108+
});
109+
11110
const emptyStringArray: string[] = [];
12111

13-
API.v1.addRoute(
14-
'livechat/users/:type',
15-
{
16-
authRequired: true,
17-
permissionsRequired: {
18-
'POST': ['view-livechat-manager'],
19-
'*': emptyStringArray,
112+
const livechatUsersEndpoints = API.v1
113+
.get(
114+
'livechat/users/:type',
115+
{
116+
authRequired: true,
117+
permissionsRequired: emptyStringArray,
118+
query: isLivechatUsersManagerGETLocal,
119+
response: {
120+
200: paginatedUsersResponseSchema,
121+
400: validateBadRequestErrorResponse,
122+
401: validateUnauthorizedErrorResponse,
123+
403: validateForbiddenErrorResponse,
124+
},
20125
},
21-
validateParams: {
22-
GET: isLivechatUsersManagerGETProps,
23-
POST: isPOSTLivechatUsersTypeProps,
24-
},
25-
},
26-
{
27-
async get() {
28-
check(this.urlParams, {
29-
type: String,
30-
});
126+
async function action() {
31127
const { offset, count } = await getPaginationItems(this.queryParams);
32128
const { sort } = await this.parseJsonQuery();
33129
const { text } = this.queryParams;
34130

35131
if (this.urlParams.type === 'agent') {
36132
if (!(await hasAtLeastOnePermissionAsync(this.userId, ['transfer-livechat-guest', 'edit-omnichannel-contact']))) {
37-
return API.v1.forbidden();
133+
return API.v1.forbidden('error-not-authorized');
38134
}
39135

40-
const { onlyAvailable, excludeId, showIdleAgents } = this.queryParams;
136+
const { onlyAvailable = false, excludeId, showIdleAgents } = this.queryParams;
41137
return API.v1.success(
42138
await findAgents({
43139
text,
@@ -54,7 +150,7 @@ API.v1.addRoute(
54150
}
55151
if (this.urlParams.type === 'manager') {
56152
if (!(await hasAtLeastOnePermissionAsync(this.userId, ['view-livechat-manager']))) {
57-
return API.v1.forbidden();
153+
return API.v1.forbidden('error-not-authorized');
58154
}
59155

60156
return API.v1.success(
@@ -70,7 +166,21 @@ API.v1.addRoute(
70166
}
71167
throw new Error('Invalid type');
72168
},
73-
async post() {
169+
)
170+
.post(
171+
'livechat/users/:type',
172+
{
173+
authRequired: true,
174+
permissionsRequired: ['view-livechat-manager'],
175+
body: isPOSTLivechatUsersTypeLocal,
176+
response: {
177+
200: postUserResponseSchema,
178+
400: validateBadRequestErrorResponse,
179+
401: validateUnauthorizedErrorResponse,
180+
403: validateForbiddenErrorResponse,
181+
},
182+
},
183+
async function action() {
74184
if (this.urlParams.type === 'agent') {
75185
const user = await addAgent(this.bodyParams.username);
76186
if (user) {
@@ -87,14 +197,21 @@ API.v1.addRoute(
87197

88198
return API.v1.failure();
89199
},
90-
},
91-
);
92-
93-
API.v1.addRoute(
94-
'livechat/users/:type/:_id',
95-
{ authRequired: true, permissionsRequired: ['view-livechat-manager'] },
96-
{
97-
async get() {
200+
)
201+
.get(
202+
'livechat/users/:type/:_id',
203+
{
204+
authRequired: true,
205+
permissionsRequired: ['view-livechat-manager'],
206+
query: undefined,
207+
response: {
208+
200: getUserByIdResponseSchema,
209+
400: validateBadRequestErrorResponse,
210+
401: validateUnauthorizedErrorResponse,
211+
403: validateForbiddenErrorResponse,
212+
},
213+
},
214+
async function action() {
98215
if (!['agent', 'manager'].includes(this.urlParams.type)) {
99216
throw new Error('Invalid type');
100217
}
@@ -107,7 +224,21 @@ API.v1.addRoute(
107224
// TODO: throw error instead of returning null
108225
return API.v1.success({ user });
109226
},
110-
async delete() {
227+
)
228+
.delete(
229+
'livechat/users/:type/:_id',
230+
{
231+
authRequired: true,
232+
permissionsRequired: ['view-livechat-manager'],
233+
query: undefined,
234+
response: {
235+
200: successOnlyResponseSchema,
236+
400: validateBadRequestErrorResponse,
237+
401: validateUnauthorizedErrorResponse,
238+
403: validateForbiddenErrorResponse,
239+
},
240+
},
241+
async function action() {
111242
if (this.urlParams.type === 'agent') {
112243
if (await removeAgent(this.urlParams._id)) {
113244
return API.v1.success();
@@ -120,7 +251,15 @@ API.v1.addRoute(
120251
throw new Error('Invalid type');
121252
}
122253

123-
return API.v1.failure();
254+
return API.v1.failure('error-removing-user');
124255
},
125-
},
126-
);
256+
);
257+
258+
// --- Type augmentation ---
259+
260+
type LivechatUsersEndpoints = ExtractRoutesFromAPI<typeof livechatUsersEndpoints>;
261+
262+
declare module '@rocket.chat/rest-typings' {
263+
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface
264+
interface Endpoints extends LivechatUsersEndpoints {}
265+
}

0 commit comments

Comments
 (0)