Skip to content

Commit bc76410

Browse files
smirk-devclaude
andcommitted
refactor(api): migrate push endpoints to chained API pattern
Migrate push.get and push.info endpoints from legacy API.v1.addRoute() to the new chained router API with AJV validators and typed response schemas. Merges them into the existing push endpoint chain. Part of #38876 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 0e45814 commit bc76410

File tree

2 files changed

+236
-79
lines changed

2 files changed

+236
-79
lines changed
Lines changed: 234 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -1,107 +1,247 @@
1-
import type { IAppsTokens } from '@rocket.chat/core-typings';
2-
import { Messages, AppsTokens, Users, Rooms, Settings } from '@rocket.chat/models';
3-
import { Random } from '@rocket.chat/random';
4-
import { ajv, validateBadRequestErrorResponse, validateUnauthorizedErrorResponse } from '@rocket.chat/rest-typings';
5-
import { Match, check } from 'meteor/check';
1+
import { Push } from '@rocket.chat/core-services';
2+
import type { IPushToken } from '@rocket.chat/core-typings';
3+
import { Messages, PushToken, Users, Rooms, Settings } from '@rocket.chat/models';
4+
import {
5+
ajv,
6+
isPushGetProps,
7+
validateNotFoundErrorResponse,
8+
validateBadRequestErrorResponse,
9+
validateUnauthorizedErrorResponse,
10+
validateForbiddenErrorResponse,
11+
} from '@rocket.chat/rest-typings';
12+
import type { JSONSchemaType } from 'ajv';
613
import { Meteor } from 'meteor/meteor';
714

815
import { executePushTest } from '../../../../server/lib/pushConfig';
916
import { canAccessRoomAsync } from '../../../authorization/server/functions/canAccessRoom';
10-
import { pushUpdate } from '../../../push/server/methods';
1117
import PushNotification from '../../../push-notifications/server/lib/PushNotification';
1218
import { settings } from '../../../settings/server';
1319
import type { ExtractRoutesFromAPI } from '../ApiClass';
1420
import { API } from '../api';
21+
import type { SuccessResult } from '../definition';
1522

16-
API.v1.addRoute(
17-
'push.token',
18-
{ authRequired: true },
19-
{
20-
async post() {
21-
const { id, type, value, appName } = this.bodyParams;
23+
type PushTokenPOST = {
24+
id?: string;
25+
type: 'apn' | 'gcm';
26+
value: string;
27+
appName: string;
28+
};
2229

23-
if (id && typeof id !== 'string') {
24-
throw new Meteor.Error('error-id-param-not-valid', 'The required "id" body param is invalid.');
25-
}
30+
const PushTokenPOSTSchema: JSONSchemaType<PushTokenPOST> = {
31+
type: 'object',
32+
properties: {
33+
id: {
34+
type: 'string',
35+
nullable: true,
36+
},
37+
type: {
38+
type: 'string',
39+
enum: ['apn', 'gcm'],
40+
},
41+
value: {
42+
type: 'string',
43+
minLength: 1,
44+
},
45+
appName: {
46+
type: 'string',
47+
minLength: 1,
48+
},
49+
},
50+
required: ['type', 'value', 'appName'],
51+
additionalProperties: false,
52+
};
2653

27-
const deviceId = id || Random.id();
54+
export const isPushTokenPOSTProps = ajv.compile<PushTokenPOST>(PushTokenPOSTSchema);
2855

29-
if (!type || (type !== 'apn' && type !== 'gcm')) {
30-
throw new Meteor.Error('error-type-param-not-valid', 'The required "type" body param is missing or invalid.');
31-
}
56+
type PushTokenDELETE = {
57+
token: string;
58+
};
3259

33-
if (!value || typeof value !== 'string') {
34-
throw new Meteor.Error('error-token-param-not-valid', 'The required "value" body param is missing or invalid.');
35-
}
60+
const PushTokenDELETESchema: JSONSchemaType<PushTokenDELETE> = {
61+
type: 'object',
62+
properties: {
63+
token: {
64+
type: 'string',
65+
minLength: 1,
66+
},
67+
},
68+
required: ['token'],
69+
additionalProperties: false,
70+
};
3671

37-
if (!appName || typeof appName !== 'string') {
38-
throw new Meteor.Error('error-appName-param-not-valid', 'The required "appName" body param is missing or invalid.');
39-
}
72+
export const isPushTokenDELETEProps = ajv.compile<PushTokenDELETE>(PushTokenDELETESchema);
4073

41-
const authToken = this.request.headers.get('x-auth-token');
42-
if (!authToken) {
74+
type PushTokenResult = Pick<IPushToken, '_id' | 'token' | 'appName' | 'userId' | 'enabled' | 'createdAt' | '_updatedAt'>;
75+
76+
/**
77+
* Pick only the attributes we actually want to return on the endpoint, ensuring nothing from older schemas get mixed in
78+
*/
79+
function cleanTokenResult(result: Omit<IPushToken, 'authToken'>): PushTokenResult {
80+
const { _id, token, appName, userId, enabled, createdAt, _updatedAt } = result;
81+
82+
return {
83+
_id,
84+
token,
85+
appName,
86+
userId,
87+
enabled,
88+
createdAt,
89+
_updatedAt,
90+
};
91+
}
92+
93+
const pushEndpoints = API.v1
94+
.post(
95+
'push.token',
96+
{
97+
response: {
98+
200: ajv.compile<SuccessResult<{ result: PushTokenResult }>['body']>({
99+
additionalProperties: false,
100+
type: 'object',
101+
properties: {
102+
success: {
103+
type: 'boolean',
104+
description: 'Indicates if the request was successful.',
105+
},
106+
result: {
107+
type: 'object',
108+
description: 'The updated token data for this device',
109+
properties: {
110+
_id: {
111+
type: 'string',
112+
},
113+
token: {
114+
type: 'object',
115+
properties: {
116+
apn: {
117+
type: 'string',
118+
},
119+
gcm: {
120+
type: 'string',
121+
},
122+
},
123+
required: [],
124+
additionalProperties: false,
125+
},
126+
appName: {
127+
type: 'string',
128+
},
129+
userId: {
130+
type: 'string',
131+
nullable: true,
132+
},
133+
enabled: {
134+
type: 'boolean',
135+
},
136+
createdAt: {
137+
type: 'string',
138+
},
139+
_updatedAt: {
140+
type: 'string',
141+
},
142+
},
143+
additionalProperties: false,
144+
},
145+
},
146+
required: ['success', 'result'],
147+
}),
148+
400: validateBadRequestErrorResponse,
149+
401: validateUnauthorizedErrorResponse,
150+
403: validateForbiddenErrorResponse,
151+
},
152+
body: isPushTokenPOSTProps,
153+
authRequired: true,
154+
},
155+
async function action() {
156+
const { id, type, value, appName } = this.bodyParams;
157+
158+
const rawToken = this.request.headers.get('x-auth-token');
159+
if (!rawToken) {
43160
throw new Meteor.Error('error-authToken-param-not-valid', 'The required "authToken" header param is missing or invalid.');
44161
}
162+
const authToken = Accounts._hashLoginToken(rawToken);
45163

46-
const result = await pushUpdate({
47-
id: deviceId,
48-
token: { [type]: value } as IAppsTokens['token'],
164+
const result = await Push.registerPushToken({
165+
...(id && { _id: id }),
166+
token: { [type]: value } as IPushToken['token'],
49167
authToken,
50168
appName,
51169
userId: this.userId,
52170
});
53171

54-
return API.v1.success({ result });
172+
return API.v1.success({ result: cleanTokenResult(result) });
173+
},
174+
)
175+
.delete(
176+
'push.token',
177+
{
178+
response: {
179+
200: ajv.compile<void>({
180+
additionalProperties: false,
181+
type: 'object',
182+
properties: {
183+
success: {
184+
type: 'boolean',
185+
},
186+
},
187+
required: ['success'],
188+
}),
189+
400: validateBadRequestErrorResponse,
190+
401: validateUnauthorizedErrorResponse,
191+
403: validateForbiddenErrorResponse,
192+
404: validateNotFoundErrorResponse,
193+
},
194+
body: isPushTokenDELETEProps,
195+
authRequired: true,
55196
},
56-
async delete() {
197+
async function action() {
57198
const { token } = this.bodyParams;
58199

59-
if (!token || typeof token !== 'string') {
60-
throw new Meteor.Error('error-token-param-not-valid', 'The required "token" body param is missing or invalid.');
61-
}
62-
63-
const affectedRecords = (
64-
await AppsTokens.deleteMany({
65-
$or: [
66-
{
67-
'token.apn': token,
68-
},
69-
{
70-
'token.gcm': token,
71-
},
72-
],
73-
userId: this.userId,
74-
})
75-
).deletedCount;
200+
const removeResult = await PushToken.removeAllByTokenStringAndUserId(token, this.userId);
76201

77-
if (affectedRecords === 0) {
202+
if (removeResult.deletedCount === 0) {
78203
return API.v1.notFound();
79204
}
80205

81206
return API.v1.success();
82207
},
83-
},
84-
);
85-
86-
API.v1.addRoute(
87-
'push.get',
88-
{ authRequired: true },
89-
{
90-
async get() {
91-
const params = this.queryParams;
92-
check(
93-
params,
94-
Match.ObjectIncluding({
95-
id: String,
208+
)
209+
.get(
210+
'push.get',
211+
{
212+
authRequired: true,
213+
query: isPushGetProps,
214+
response: {
215+
200: ajv.compile<{ data: { message: object; notification: object }; success: true }>({
216+
type: 'object',
217+
properties: {
218+
data: {
219+
type: 'object',
220+
properties: {
221+
message: { type: 'object', additionalProperties: true },
222+
notification: { type: 'object', additionalProperties: true },
223+
},
224+
required: ['message', 'notification'],
225+
additionalProperties: false,
226+
},
227+
success: { type: 'boolean', enum: [true] },
228+
},
229+
required: ['data', 'success'],
230+
additionalProperties: false,
96231
}),
97-
);
232+
400: validateBadRequestErrorResponse,
233+
401: validateUnauthorizedErrorResponse,
234+
},
235+
},
236+
async function action() {
237+
const { id } = this.queryParams;
98238

99239
const receiver = await Users.findOneById(this.userId);
100240
if (!receiver) {
101241
throw new Error('error-user-not-found');
102242
}
103243

104-
const message = await Messages.findOneById(params.id);
244+
const message = await Messages.findOneById(id);
105245
if (!message) {
106246
throw new Error('error-message-not-found');
107247
}
@@ -119,25 +259,36 @@ API.v1.addRoute(
119259

120260
return API.v1.success({ data });
121261
},
122-
},
123-
);
124-
125-
API.v1.addRoute(
126-
'push.info',
127-
{ authRequired: true },
128-
{
129-
async get() {
262+
)
263+
.get(
264+
'push.info',
265+
{
266+
authRequired: true,
267+
response: {
268+
200: ajv.compile<{ pushGatewayEnabled: boolean; defaultPushGateway: boolean; success: true }>({
269+
type: 'object',
270+
properties: {
271+
pushGatewayEnabled: { type: 'boolean' },
272+
defaultPushGateway: { type: 'boolean' },
273+
success: { type: 'boolean', enum: [true] },
274+
},
275+
required: ['pushGatewayEnabled', 'defaultPushGateway', 'success'],
276+
additionalProperties: false,
277+
}),
278+
401: validateUnauthorizedErrorResponse,
279+
},
280+
},
281+
async function action() {
130282
const defaultGateway = (await Settings.findOneById('Push_gateway', { projection: { packageValue: 1 } }))?.packageValue;
131283
const defaultPushGateway = settings.get('Push_gateway') === defaultGateway;
132284
return API.v1.success({
133-
pushGatewayEnabled: settings.get('Push_enable'),
285+
pushGatewayEnabled: settings.get<boolean>('Push_enable'),
134286
defaultPushGateway,
135287
});
136288
},
137-
},
138-
);
289+
);
139290

140-
const pushEndpoints = API.v1.post(
291+
const pushTestEndpoints = API.v1.post(
141292
'push.test',
142293
{
143294
authRequired: true,
@@ -177,9 +328,13 @@ const pushEndpoints = API.v1.post(
177328
},
178329
);
179330

180-
export type PushEndpoints = ExtractRoutesFromAPI<typeof pushEndpoints>;
331+
type PushTestEndpoints = ExtractRoutesFromAPI<typeof pushTestEndpoints>;
332+
333+
type PushTokenEndpoints = ExtractRoutesFromAPI<typeof pushEndpoints>;
334+
335+
type PushAllEndpoints = PushTestEndpoints & PushTokenEndpoints;
181336

182337
declare module '@rocket.chat/rest-typings' {
183338
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface
184-
interface Endpoints extends PushEndpoints {}
339+
interface Endpoints extends PushAllEndpoints {}
185340
}

packages/rest-typings/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,5 +257,7 @@ export * from './v1/cloud';
257257
export * from './v1/banners';
258258
export * from './default';
259259

260+
export * from './v1/push';
261+
260262
// Export the ajv instance for use in other packages
261263
export * from './v1/Ajv';

0 commit comments

Comments
 (0)