Skip to content
6 changes: 6 additions & 0 deletions .changeset/rooms-join-endpoint.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@rocket.chat/meteor": minor
"@rocket.chat/rest-typings": minor
---

Added a new `rooms.join` REST endpoint that lets a user join any room type, replicating the behavior of the deprecated `joinRoom` DDP method. Unlike `channels.join`, it resolves all room types through the shared `Room.join` service (access checks, join codes, federation and omnichannel rules). The client now uses `rooms.join` instead of `channels.join`.
34 changes: 33 additions & 1 deletion apps/meteor/app/api/server/v1/rooms.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { FederationMatrix, MeteorError, Team } from '@rocket.chat/core-services';
import { FederationMatrix, MeteorError, Room, Team } from '@rocket.chat/core-services';
import {
type IRoom,
type IRoomAbacRedaction,
Expand All @@ -24,6 +24,7 @@ import {
isRoomsIsMemberProps,
isRoomsCleanHistoryProps,
isRoomsOpenProps,
isRoomsJoinProps,
isRoomsMembersOrderedByRoleProps,
isRoomsChangeArchivationStateProps,
isRoomsHideProps,
Expand Down Expand Up @@ -1251,6 +1252,37 @@ API.v1.post(
},
);

API.v1.post(
'rooms.join',
{
authRequired: true,
body: isRoomsJoinProps,
response: {
200: ajv.compile<{ room: IRoom }>({
type: 'object',
properties: {
room: { type: 'object' },
success: { type: 'boolean', enum: [true] },
},
required: ['room', 'success'],
additionalProperties: false,
}),
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
},
},
async function action() {
const { joinCode, ...params } = this.bodyParams;
const room = await findRoomByIdOrName({ params });

await Room.join({ room, user: this.user, joinCode });

return API.v1.success({
room: await findRoomByIdOrName({ params }),
});
},
);

API.v1.post(
'rooms.hide',
{
Expand Down
2 changes: 1 addition & 1 deletion apps/meteor/app/lib/server/methods/joinRoom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ declare module '@rocket.chat/ddp-client' {

Meteor.methods<ServerMethods>({
async joinRoom(rid, code) {
methodDeprecationLogger.method('joinRoom', '9.0.0', '/v1/channels.join');
methodDeprecationLogger.method('joinRoom', '9.0.0', '/v1/rooms.join');
check(rid, String);

const user = await Meteor.userAsync();
Expand Down
6 changes: 1 addition & 5 deletions apps/meteor/client/hooks/useJoinRoom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,7 @@ type UseJoinRoomMutationFunctionProps = {
export const useJoinRoom = () => {
const queryClient = useQueryClient();
const dispatchToastMessage = useToastMessageDispatch();
// TODO(ddp-removal): /v1/channels.join only resolves public channels; non-`c`
// rooms will error here (same as DDP `joinRoom` would, just via REST).
// Replace with a unified `/v1/rooms.join` (or per-type endpoints) before
// the 9.0.0 sweep removes the DDP method.
const joinChannel = useEndpoint('POST', '/v1/channels.join');
const joinChannel = useEndpoint('POST', '/v1/rooms.join');

return useMutation({
mutationFn: async ({ rid, reference, type }: UseJoinRoomMutationFunctionProps) => {
Expand Down
5 changes: 1 addition & 4 deletions apps/meteor/client/lib/chats/data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -274,10 +274,7 @@ export const createDataAPI = ({ rid, tmid }: { rid: IRoom['_id']; tmid: IMessage
const isSubscribedToRoom = async (): Promise<boolean> => !!Subscriptions.state.find((record) => record.rid === rid);

const joinRoom = async (): Promise<void> => {
// TODO(ddp-removal): only public channels resolve through this endpoint;
// private groups, DMs and livechat used to error via DDP too — REST keeps
// that behavior. Replace with a unified `/v1/rooms.join` when available.
await sdk.rest.post('/v1/channels.join', { roomId: rid });
await sdk.rest.post('/v1/rooms.join', { roomId: rid });
};

const findDiscussionByID = async (drid: IRoom['_id']): Promise<IRoom | undefined> =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ const ComposerJoinWithPassword = () => {
const room = useRoom();
const dispatchToastMessage = useToastMessageDispatch();

const joinChannelEndpoint = useEndpoint('POST', '/v1/channels.join');
const joinChannelEndpoint = useEndpoint('POST', '/v1/rooms.join');
const {
control,
handleSubmit,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ const ComposerReadOnly = () => {
const { t } = useTranslation();
const room = useRoom();
const isSubscribed = useUserIsSubscribed();
const joinChannel = useEndpoint('POST', '/v1/channels.join');
const joinChannel = useEndpoint('POST', '/v1/rooms.join');

const dispatchToastMessage = useToastMessageDispatch();

Expand Down
45 changes: 45 additions & 0 deletions packages/rest-typings/src/v1/rooms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -764,6 +764,45 @@ const roomsOpenSchema = {

export const isRoomsOpenProps = ajv.compile<RoomsOpenProps>(roomsOpenSchema);

export type RoomsJoinProps = { roomId: string; joinCode?: string } | { roomName: string; joinCode?: string };

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.

P2: RoomsJoinProps is out of sync with its Ajv schema: schema accepts joinCode: null but the type does not. This creates unsound typing for consumers of isRoomsJoinProps and downstream joinCode handling.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/rest-typings/src/v1/rooms.ts, line 767:

<comment>`RoomsJoinProps` is out of sync with its Ajv schema: schema accepts `joinCode: null` but the type does not. This creates unsound typing for consumers of `isRoomsJoinProps` and downstream `joinCode` handling.</comment>

<file context>
@@ -764,6 +764,45 @@ const roomsOpenSchema = {
 
 export const isRoomsOpenProps = ajv.compile<RoomsOpenProps>(roomsOpenSchema);
 
+export type RoomsJoinProps = { roomId: string; joinCode?: string } | { roomName: string; joinCode?: string };
+
+const roomsJoinSchema = {
</file context>
Suggested change
export type RoomsJoinProps = { roomId: string; joinCode?: string } | { roomName: string; joinCode?: string };
export type RoomsJoinProps = { roomId: string; joinCode?: string | null } | { roomName: string; joinCode?: string | null };


const roomsJoinSchema = {
oneOf: [
{
type: 'object',
properties: {
roomId: {
type: 'string',
minLength: 1,
},
joinCode: {
type: 'string',
nullable: true,
},
},
required: ['roomId'],
additionalProperties: false,
},
{
type: 'object',
properties: {
roomName: {
type: 'string',
minLength: 1,
},
joinCode: {
type: 'string',
nullable: true,
},
},
required: ['roomName'],
additionalProperties: false,
},
],
};

export const isRoomsJoinProps = ajv.compile<RoomsJoinProps>(roomsJoinSchema);

type MembersOrderedByRoleProps = {
roomId?: IRoom['_id'];
roomName?: IRoom['name'];
Expand Down Expand Up @@ -1000,6 +1039,12 @@ export type RoomsEndpoints = {
POST: (params: RoomsOpenProps) => void;
};

'/v1/rooms.join': {
POST: (params: RoomsJoinProps) => {
room: IRoom;
};
};

'/v1/rooms.membersOrderedByRole': {
GET: (params: RoomsMembersOrderedByRoleProps) => PaginatedResult<{
members: (IUser & { subscription: Pick<ISubscription, '_id' | 'status' | 'ts' | 'roles'> })[];
Expand Down
Loading