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
76 changes: 76 additions & 0 deletions apps/meteor/tests/e2e/rooms-join.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { Users } from './fixtures/userStates';
import { HomeChannel } from './page-objects';
import { createTargetChannel, createTargetDiscussion, deleteRoom, sendTargetChannelMessage } from './utils';
import { test, expect } from './utils/test';

test.use({ storageState: Users.admin.state });

test.describe.serial('rooms.join', () => {
let poHomeChannel: HomeChannel;

test.beforeAll(async ({ api }) => {
// restrict preview to admin so a regular user lands on the "not subscribed"
// screen, whose "Join channel" button drives /v1/rooms.join via useJoinRoom
await api.post('/permissions.update', { permissions: [{ _id: 'preview-c-room', roles: ['admin'] }] });
});

test.afterAll(async ({ api }) => {
await api.post('/permissions.update', { permissions: [{ _id: 'preview-c-room', roles: ['admin', 'user', 'anonymous'] }] });
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

test.beforeEach(async ({ page }) => {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Resolved
poHomeChannel = new HomeChannel(page);
await page.goto('/home');
});

test.describe('public channel', () => {
test.use({ storageState: Users.user1.state });

let targetChannel: string;

test.beforeAll(async ({ api }) => {
targetChannel = await createTargetChannel(api);
await sendTargetChannelMessage(api, targetChannel, { msg: 'message from a channel the user has not joined' });
});

test.afterAll(async ({ api }) => {
await api.post('/channels.delete', { roomName: targetChannel });
});

test('should let a non-member join a public channel', async ({ page }) => {
await page.goto(`/channel/${targetChannel}`);

await expect(poHomeChannel.btnJoinChannel).toBeVisible();

await poHomeChannel.btnJoinChannel.click();

await expect(poHomeChannel.btnJoinChannel).not.toBeVisible();
await expect(poHomeChannel.composer.inputMessage).toBeEnabled();
});
});

test.describe('discussion', () => {
test.use({ storageState: Users.user1.state });

let discussion: Record<string, string>;

test.beforeAll(async ({ api }) => {
discussion = await createTargetDiscussion(api);
});

test.afterAll(async ({ api }) => {
await deleteRoom(api, discussion._id);
});

test('should let a non-member join a discussion', async ({ page }) => {
await page.goto(`/channel/${discussion.name}`);

await expect(poHomeChannel.btnJoinChannel).toBeVisible();

await poHomeChannel.btnJoinChannel.click();

await expect(poHomeChannel.btnJoinChannel).not.toBeVisible();
await expect(poHomeChannel.composer.inputMessage).toBeEnabled();
});
});
});
153 changes: 153 additions & 0 deletions apps/meteor/tests/end-to-end/api/rooms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1966,6 +1966,159 @@ describe('[Rooms]', () => {
});
});

describe('[/rooms.join]', () => {
let testChannel: IRoom;
let testGroup: IRoom;
let testChannelWithCode: IRoom;
let testDiscussion: IRoom;
let testUser: TestUser<IUser>;
let testUserCredentials: Credentials;

before(async () => {
testUser = await createUser();
testUserCredentials = await login(testUser.username, password);
testChannel = (await createRoom({ type: 'c', name: `rooms.join.channel.${Date.now()}` })).body.channel;
testGroup = (await createRoom({ type: 'p', name: `rooms.join.group.${Date.now()}` })).body.group;
testChannelWithCode = (await createRoom({ type: 'c', name: `rooms.join.code.${Date.now()}` })).body.channel;
testDiscussion = (
await request
.post(api('rooms.createDiscussion'))
.set(credentials)
.send({ prid: testChannel._id, t_name: `rooms.join.discussion.${Date.now()}` })
).body.discussion;
});

after(() =>
Promise.all([
deleteRoom({ type: 'c', roomId: testChannel._id }),
deleteRoom({ type: 'p', roomId: testGroup._id }),
deleteRoom({ type: 'c', roomId: testChannelWithCode._id }),
Comment thread
ggazzo marked this conversation as resolved.
Resolved
deleteUser(testUser),
updatePermission('join-without-join-code', ['admin', 'bot', 'app']),
]),
);
Comment thread
ggazzo marked this conversation as resolved.
Resolved

it('should fail when the room does not exist', (done) => {
void request
.post(api('rooms.join'))
.set(testUserCredentials)
.send({ roomId: 'invalid-room-id' })
.expect('Content-Type', 'application/json')
.expect(400)
.expect((res) => {
expect(res.body).to.have.property('success', false);
expect(res.body).to.have.property('errorType', 'error-room-not-found');
})
.end(done);
});

it('should join a public channel by roomId', (done) => {
void request
.post(api('rooms.join'))
.set(testUserCredentials)
.send({ roomId: testChannel._id })
.expect('Content-Type', 'application/json')
.expect(200)
.expect((res) => {
expect(res.body).to.have.property('success', true);
expect(res.body).to.have.nested.property('room._id', testChannel._id);
})
.end(done);
});

it('should join a public channel by roomName', (done) => {
void request
.post(api('rooms.join'))
.set(testUserCredentials)
.send({ roomName: testChannel.name })
.expect('Content-Type', 'application/json')
.expect(200)
.expect((res) => {
expect(res.body).to.have.property('success', true);
expect(res.body).to.have.nested.property('room._id', testChannel._id);
})
.end(done);
});

it('should join a discussion (a room with a parent room) by roomId', (done) => {
void request
.post(api('rooms.join'))
.set(testUserCredentials)
.send({ roomId: testDiscussion._id })
.expect('Content-Type', 'application/json')
.expect(200)
.expect((res) => {
expect(res.body).to.have.property('success', true);
expect(res.body).to.have.nested.property('room._id', testDiscussion._id);
expect(res.body).to.have.nested.property('room.prid', testChannel._id);
})
.end(done);
});

it('should fail to join a private group the user cannot access', (done) => {
void request
.post(api('rooms.join'))
.set(testUserCredentials)
.send({ roomId: testGroup._id })
.expect('Content-Type', 'application/json')
.expect(400)
.expect((res) => {
expect(res.body).to.have.property('success', false);
expect(res.body).to.have.property('errorType', 'error-not-allowed');
})
.end(done);
});

describe('with a join code', () => {
before(async () => {
await request.post(api('channels.setJoinCode')).set(credentials).send({ roomId: testChannelWithCode._id, joinCode: '123' });
await updatePermission('join-without-join-code', []);
});

it('should fail to join without a join code', (done) => {
void request
.post(api('rooms.join'))
.set(testUserCredentials)
.send({ roomId: testChannelWithCode._id })
.expect('Content-Type', 'application/json')
.expect(400)
.expect((res) => {
expect(res.body).to.have.property('success', false);
expect(res.body).to.have.property('errorType', 'error-code-required');
})
.end(done);
});

it('should fail to join with an incorrect join code', (done) => {
void request
.post(api('rooms.join'))
.set(testUserCredentials)
.send({ roomId: testChannelWithCode._id, joinCode: 'WRONG' })
.expect('Content-Type', 'application/json')
.expect(400)
.expect((res) => {
expect(res.body).to.have.property('success', false);
expect(res.body).to.have.property('errorType', 'error-code-invalid');
})
.end(done);
});

it('should join with the correct join code', (done) => {
void request
.post(api('rooms.join'))
.set(testUserCredentials)
.send({ roomId: testChannelWithCode._id, joinCode: '123' })
.expect('Content-Type', 'application/json')
.expect(200)
.expect((res) => {
expect(res.body).to.have.property('success', true);
expect(res.body).to.have.nested.property('room._id', testChannelWithCode._id);
})
.end(done);
});
});
});

describe('[/rooms.autocomplete.channelAndPrivate]', () => {
let testChannel: IRoom;

Expand Down
Loading
Loading