Skip to content
6 changes: 6 additions & 0 deletions .changeset/migrate-users-setStatus-openapi.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@rocket.chat/meteor": patch
"@rocket.chat/rest-typings": patch
---

Migrates the `users.setStatus` REST API endpoint from the legacy `API.v1.addRoute` pattern to the new chained `API.v1.post()` pattern with AJV request body and response schema validation and OpenAPI documentation support.
198 changes: 107 additions & 91 deletions apps/meteor/app/api/server/v1/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
ajv,
validateBadRequestErrorResponse,
validateUnauthorizedErrorResponse,
validateForbiddenErrorResponse
} from '@rocket.chat/rest-typings';
import { getLoginExpirationInMs, wrapExceptions } from '@rocket.chat/tools';
import { Accounts } from 'meteor/accounts-base';
Expand Down Expand Up @@ -878,6 +879,112 @@ const usersEndpoints = API.v1

return API.v1.success({ suggestions });
},
)
.post(
'users.setStatus',
{
authRequired: true,
rateLimiterOptions: {
numRequestsAllowed: 5,
intervalTimeInMS: 60000,
},
body: ajv.compile<{
status?: string;
message?: string;
userId?: string;
username?: string;
user?: string;
}>({
type: 'object',
properties: {
status: { type: 'string', minLength: 1 },
message: { type: 'string' },
userId: { type: 'string' },
username: { type: 'string' },
user: { type: 'string' },
},
anyOf: [
{ required: ['message'] },
{ required: ['status'] },
],
additionalProperties: false,
}),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
response: {
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
200: ajv.compile<{ success: boolean }>({
type: 'object',
properties: {
success: { type: 'boolean', enum: [true] },
},
required: ['success'],
additionalProperties: false,
}),
},
Comment on lines +887 to +924
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add the 429 response schema to this contract.

This route is rate-limited, and API.v1.post() inherits the automatic 429 handling from addRoute(), but the response map only declares 200/400/401/403. The generated OpenAPI spec will miss a real runtime response.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/meteor/app/api/server/v1/users.ts` around lines 887 - 924, The response
map for this rate-limited endpoint is missing the 429 schema so the OpenAPI
output won't show the runtime "Too Many Requests" response; update the response
object in the route definition (the same block that contains rateLimiterOptions,
body, and response) to include a 429 entry pointing to the shared 429 validator
(e.g. add "429: validateTooManyRequestsErrorResponse") or the equivalent schema
used elsewhere in the codebase so the automatic 429 handling from
addRoute()/API.v1.post() is declared in the contract.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

validateTooManyRequestsErrorResponse does not exist in @rocket.chat/rest-typings yet. Will add 429 schema once the shared validator is available.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧩 Analysis chain

Let me check how the existing validators are defined and whether there's already an inline 429 pattern used elsewhere in the codebase.


🏁 Script executed:

#!/bin/bash
# Check how existing validators are defined in rest-typings
rg -n "validateBadRequestErrorResponse|validateUnauthorizedErrorResponse|validateForbiddenErrorResponse|validateTooManyRequests" --type ts -A 5

# Also check if any 429 schema is defined inline elsewhere
rg -n "429" --type ts -B 2 -A 5 apps/meteor/app/api/server/v1/

Length of output: 69067


@Yashika-code, confirmed — there's no validateTooManyRequestsErrorResponse (or any 429 validator/schema) anywhere in the codebase yet. Your explanation is valid.

Two options to keep the OpenAPI contract complete without blocking this PR:

Option A – Inline schema (unblocks this PR immediately)

Add the 429 entry directly in the response map using ajv.compile, following the same shape as the existing error validators:

response: {
    400: validateBadRequestErrorResponse,
    401: validateUnauthorizedErrorResponse,
    403: validateForbiddenErrorResponse,
    429: ajv.compile<{ success: false; error: string }>({
        type: 'object',
        properties: {
            success: { type: 'boolean', enum: [false] },
            error: { type: 'string' },
        },
        required: ['success', 'error'],
        additionalProperties: false,
    }),
    200: ajv.compile<{ success: boolean }>({ ... }),
},

This can later be replaced with the shared validateTooManyRequestsErrorResponse once it's added to @rocket.chat/rest-typings.

Option B – Follow-up issue

Defer the 429 schema entirely and track it as a follow-up. Would you like me to open a GitHub issue to add validateTooManyRequestsErrorResponse to @rocket.chat/rest-typings so all rate-limited endpoints can reference it consistently?


🧠 Learnings used
Learnt from: ahmed-n-abdeltwab
Repo: RocketChat/Rocket.Chat PR: 38974
File: apps/meteor/app/api/server/v1/im.ts:220-221
Timestamp: 2026-02-24T19:09:01.522Z
Learning: In Rocket.Chat OpenAPI migration PRs for endpoints under apps/meteor/app/api/server/v1, avoid introducing logic changes. Only perform scope-tight changes that preserve behavior; style-only cleanups (e.g., removing inline comments) may be deferred to follow-ups to keep the migration PR focused.

Learnt from: ahmed-n-abdeltwab
Repo: RocketChat/Rocket.Chat PR: 39230
File: apps/meteor/app/api/server/v1/chat.ts:214-222
Timestamp: 2026-03-03T11:11:48.541Z
Learning: In apps/meteor/server/lib/moderation/reportMessage.ts, the reportMessage function validates that description is not empty or whitespace-only with `if (!description.trim())`. When migrating the chat.reportMessage endpoint to OpenAPI, adding minLength validation to the schema preserves this existing behavior.

Learnt from: ggazzo
Repo: RocketChat/Rocket.Chat PR: 35995
File: apps/meteor/app/api/server/v1/rooms.ts:1107-1112
Timestamp: 2026-02-23T17:53:06.802Z
Learning: During PR reviews that touch endpoint files under apps/meteor/app/api/server/v1, enforce strict scope: if a PR targets a specific endpoint (e.g., rooms.favorite), do not propose changes to unrelated endpoints (e.g., rooms.invite) unless maintainers explicitly request them. Focus feedback on the touched endpoint's behavior, API surface, and related tests; avoid broad cross-endpoint changes in the same PR unless requested.

Learnt from: gabriellsh
Repo: RocketChat/Rocket.Chat PR: 38778
File: packages/ui-voip/src/providers/useMediaSession.ts:192-192
Timestamp: 2026-02-26T19:25:44.063Z
Learning: In the Rocket.Chat repository, do not reference Biome lint rules in code review feedback. Biome is not used even if biome.json exists; only reference Biome rules if there is explicit, project-wide usage documented. For TypeScript files, review lint implications without Biome guidance unless the project enables Biome rules.

Learnt from: gabriellsh
Repo: RocketChat/Rocket.Chat PR: 38778
File: packages/ui-voip/src/providers/useMediaSession.ts:192-192
Timestamp: 2026-02-26T19:25:44.063Z
Learning: In this repository (RocketChat/Rocket.Chat), Biome lint rules are not used even if a biome.json exists. When reviewing TypeScript files (e.g., packages/ui-voip/src/providers/useMediaSession.ts), ensure lint suggestions do not reference Biome-specific rules. Rely on general ESLint/TypeScript lint rules and project conventions instead.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for confirming. Since there is currently no shared 429 validator in @rocket.chat/rest-typings, I’ll defer adding the 429 schema for now to keep the migration PR consistent with existing endpoints. It can be added later once a shared validator is introduced.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Yashika-code, understood — deferring is a reasonable call to keep this migration PR focused. Would you like me to open a follow-up GitHub issue to track adding validateTooManyRequestsErrorResponse to @rocket.chat/rest-typings so all rate-limited endpoints (including users.setStatus) can reference it consistently once it's available?


🧠 Learnings used
Learnt from: ahmed-n-abdeltwab
Repo: RocketChat/Rocket.Chat PR: 38974
File: apps/meteor/app/api/server/v1/im.ts:220-221
Timestamp: 2026-02-24T19:09:01.522Z
Learning: In Rocket.Chat OpenAPI migration PRs for endpoints under apps/meteor/app/api/server/v1, avoid introducing logic changes. Only perform scope-tight changes that preserve behavior; style-only cleanups (e.g., removing inline comments) may be deferred to follow-ups to keep the migration PR focused.

Learnt from: ahmed-n-abdeltwab
Repo: RocketChat/Rocket.Chat PR: 39230
File: apps/meteor/app/api/server/v1/chat.ts:214-222
Timestamp: 2026-03-03T11:11:48.541Z
Learning: In apps/meteor/server/lib/moderation/reportMessage.ts, the reportMessage function validates that description is not empty or whitespace-only with `if (!description.trim())`. When migrating the chat.reportMessage endpoint to OpenAPI, adding minLength validation to the schema preserves this existing behavior.

Learnt from: ggazzo
Repo: RocketChat/Rocket.Chat PR: 35995
File: apps/meteor/app/api/server/v1/rooms.ts:1107-1112
Timestamp: 2026-02-23T17:53:06.802Z
Learning: During PR reviews that touch endpoint files under apps/meteor/app/api/server/v1, enforce strict scope: if a PR targets a specific endpoint (e.g., rooms.favorite), do not propose changes to unrelated endpoints (e.g., rooms.invite) unless maintainers explicitly request them. Focus feedback on the touched endpoint's behavior, API surface, and related tests; avoid broad cross-endpoint changes in the same PR unless requested.

Learnt from: gabriellsh
Repo: RocketChat/Rocket.Chat PR: 38778
File: packages/ui-voip/src/providers/useMediaSession.ts:192-192
Timestamp: 2026-02-26T19:25:44.063Z
Learning: In the Rocket.Chat repository, do not reference Biome lint rules in code review feedback. Biome is not used even if biome.json exists; only reference Biome rules if there is explicit, project-wide usage documented. For TypeScript files, review lint implications without Biome guidance unless the project enables Biome rules.

Learnt from: gabriellsh
Repo: RocketChat/Rocket.Chat PR: 38778
File: packages/ui-voip/src/providers/useMediaSession.ts:192-192
Timestamp: 2026-02-26T19:25:44.063Z
Learning: In this repository (RocketChat/Rocket.Chat), Biome lint rules are not used even if a biome.json exists. When reviewing TypeScript files (e.g., packages/ui-voip/src/providers/useMediaSession.ts), ensure lint suggestions do not reference Biome-specific rules. Rely on general ESLint/TypeScript lint rules and project conventions instead.

},
async function action() {
if (!settings.get('Accounts_AllowUserStatusMessageChange')) {
return API.v1.failure('Change status is not allowed [error-not-allowed]', 'error-not-allowed', 400);
}

const user = await (async () => {
if (isUserFromParams(this.bodyParams, this.userId, this.user)) {
return Users.findOneById(this.userId);
}
if (await hasPermissionAsync(this.userId, 'edit-other-user-info')) {
return getUserFromParams(this.bodyParams);
}
})();

if (!user) {
return API.v1.forbidden();
}

const { _id, username, roles, name } = user;
let { statusText, status } = user;

if (this.bodyParams.message || this.bodyParams.message === '') {
await setStatusText(user, this.bodyParams.message, { emit: false });
statusText = this.bodyParams.message;
}

if ('status' in this.bodyParams) {
if (!this.bodyParams.status) {
throw new Meteor.Error('error-invalid-status', 'Valid status types include online, away, offline, and busy.', {
method: 'users.setStatus',
});
}
const validStatus = ['online', 'away', 'offline', 'busy'];
if (validStatus.includes(this.bodyParams.status)) {
status = this.bodyParams.status as UserStatus;

if (status === 'offline' && !settings.get('Accounts_AllowInvisibleStatusOption')) {
throw new Meteor.Error('error-status-not-allowed', 'Invisible status is disabled', {
method: 'users.setStatus',
});
}

await Users.updateOne(
{ _id: user._id },
{ $set: { status, statusDefault: status } },
);

void wrapExceptions(() => Calendar.cancelUpcomingStatusChanges(user._id)).suppress();
} else {
throw new Meteor.Error('error-invalid-status', 'Valid status types include online, away, offline, and busy.', {
method: 'users.setStatus',
});
}
}

void api.broadcast('presence.status', {
user: { status, _id, username, statusText, roles, name },
previousStatus: user.status,
});

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

API.v1.addRoute(
Expand Down Expand Up @@ -1426,97 +1533,6 @@ API.v1.addRoute(
},
);

API.v1.addRoute(
'users.setStatus',
{
authRequired: true,
rateLimiterOptions: {
numRequestsAllowed: 5,
intervalTimeInMS: 60000,
},
},
{
async post() {
check(
this.bodyParams,
Match.OneOf(
Match.ObjectIncluding({
status: Match.Maybe(String),
message: String,
}),
Match.ObjectIncluding({
status: String,
message: Match.Maybe(String),
}),
),
);

if (!settings.get('Accounts_AllowUserStatusMessageChange')) {
throw new Meteor.Error('error-not-allowed', 'Change status is not allowed', {
method: 'users.setStatus',
});
}

const user = await (async () => {
if (isUserFromParams(this.bodyParams, this.userId, this.user)) {
return Users.findOneById(this.userId);
}
if (await hasPermissionAsync(this.userId, 'edit-other-user-info')) {
return getUserFromParams(this.bodyParams);
}
})();

if (!user) {
return API.v1.forbidden();
}

const { _id, username, roles, name } = user;
let { statusText, status } = user;

if (this.bodyParams.message || this.bodyParams.message === '') {
await setStatusText(user, this.bodyParams.message, { emit: false });
statusText = this.bodyParams.message;
}

if (this.bodyParams.status) {
const validStatus = ['online', 'away', 'offline', 'busy'];
if (validStatus.includes(this.bodyParams.status)) {
status = this.bodyParams.status;

if (status === 'offline' && !settings.get('Accounts_AllowInvisibleStatusOption')) {
throw new Meteor.Error('error-status-not-allowed', 'Invisible status is disabled', {
method: 'users.setStatus',
});
}

await Users.updateOne(
{ _id: user._id },
{
$set: {
status,
statusDefault: status,
},
},
);

void wrapExceptions(() => Calendar.cancelUpcomingStatusChanges(user._id)).suppress();
} else {
throw new Meteor.Error('error-invalid-status', 'Valid status types include online, away, offline, and busy.', {
method: 'users.setStatus',
});
}
}

void api.broadcast('presence.status', {
user: { status, _id, username, statusText, roles, name },
previousStatus: user.status,
});

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

// status: 'online' | 'offline' | 'away' | 'busy';
// message?: string;
// _id: string;
Expand Down