Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/witty-beds-bow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@rocket.chat/rest-typings': minor

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

since its a minor the PR should be a feat

'@rocket.chat/meteor': minor
---

Adds `POST /v1/rooms.getOrCreate`, an idempotent endpoint that resolves a room by type and name, creating the direct message when it does not exist yet.

Uses it to fix direct messages addressed by username, such as the ones opened with **Reply in direct message**, not being found on the first lookup — which made opening a conversation cost an extra request and log an avoidable `Invalid Room` error.
Comment thread
ricardogarim marked this conversation as resolved.
19 changes: 19 additions & 0 deletions apps/meteor/client/lib/utils/mapRoomFromApi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import type { IOmnichannelRoom, IRoom, Serialized } from '@rocket.chat/core-typings';

import { mapMessageFromApi } from './mapMessageFromApi';

// Livechat rooms are opened through the same flow, so their own date fields have to be covered too.
type SerializedRoom = Serialized<IRoom> & Partial<Serialized<Pick<IOmnichannelRoom, 'queuedAt' | 'closedAt'>>>;

// REST serializes Date fields to strings; the Rooms store is Date-typed. Every date-bearing key
// published by `roomFields` has to be revived, or it lands in the store as a string.
export const mapRoomFromApi = ({ _updatedAt, ts, lm, queuedAt, closedAt, lastMessage, ...room }: SerializedRoom): IRoom =>
({
...room,
...(_updatedAt && { _updatedAt: new Date(_updatedAt) }),
...(ts && { ts: new Date(ts) }),
...(lm && { lm: new Date(lm) }),
...(queuedAt && { queuedAt: new Date(queuedAt) }),
...(closedAt && { closedAt: new Date(closedAt) }),
...(lastMessage && { lastMessage: mapMessageFromApi(lastMessage) }),
}) as unknown as IRoom;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
28 changes: 14 additions & 14 deletions apps/meteor/client/views/room/hooks/useOpenRoom.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ describe('useOpenRoom', () => {
wrapper: mockAppRoot()
.withJohnDoe()
.withPermission('preview-c-room')
.withMethod('getRoomByTypeAndName', () => channelRoom as any)
.withEndpoint('POST', '/v1/rooms.getOrCreate', () => ({ room: channelRoom }) as any)
.build(),
});

Expand Down Expand Up @@ -80,57 +80,57 @@ describe('useOpenRoom', () => {

describe('error classification', () => {
it('maps error-no-permission to RoomNotFoundError without retrying (regression for #40991)', async () => {
const getRoomByTypeAndName = jest.fn().mockImplementation(() => {
throw Object.assign(new Error('No permission'), { error: 'error-no-permission' });
const getOrCreateRoom = jest.fn().mockImplementation(() => {
throw Object.assign(new Error('No permission'), { errorType: 'error-no-permission' });
});

const { result } = renderHook(() => useOpenRoom({ type: 'p', reference: 'private-channel' }), {
wrapper: mockAppRoot().withJohnDoe().withMethod('getRoomByTypeAndName', getRoomByTypeAndName).build(),
wrapper: mockAppRoot().withJohnDoe().withEndpoint('POST', '/v1/rooms.getOrCreate', getOrCreateRoom).build(),
});

await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error).toBeInstanceOf(RoomNotFoundError);
expect(getRoomByTypeAndName).toHaveBeenCalledTimes(1);
expect(getOrCreateRoom).toHaveBeenCalledTimes(1);
});

it('maps error-invalid-room to RoomNotFoundError without retrying for non-DM rooms', async () => {
const getRoomByTypeAndName = jest.fn().mockImplementation(() => {
throw Object.assign(new Error('Invalid room'), { error: 'error-invalid-room' });
it('maps error-invalid-room to RoomNotFoundError without retrying', async () => {
const getOrCreateRoom = jest.fn().mockImplementation(() => {
throw Object.assign(new Error('Invalid room'), { errorType: 'error-invalid-room' });
});

const { result } = renderHook(() => useOpenRoom({ type: 'c', reference: 'missing-channel' }), {
wrapper: mockAppRoot().withJohnDoe().withMethod('getRoomByTypeAndName', getRoomByTypeAndName).build(),
wrapper: mockAppRoot().withJohnDoe().withEndpoint('POST', '/v1/rooms.getOrCreate', getOrCreateRoom).build(),
});

await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error).toBeInstanceOf(RoomNotFoundError);
expect(getRoomByTypeAndName).toHaveBeenCalledTimes(1);
expect(getOrCreateRoom).toHaveBeenCalledTimes(1);
});

it('retries unclassified transient errors and recovers on a later attempt', async () => {
const channelRid = 'channel-rid-transient';
const channelName = 'flaky-channel';
const channelRoom = createFakeRoom({ _id: channelRid, t: 'c', name: channelName });

const getRoomByTypeAndName = jest
const getOrCreateRoom = jest
.fn()
.mockImplementationOnce(() => {
throw new Error('network down');
})
.mockImplementation(() => channelRoom);
.mockImplementation(() => ({ room: channelRoom }));

const { result } = renderHook(() => useOpenRoom({ type: 'c', reference: channelName }), {
wrapper: mockAppRoot()
.withJohnDoe()
.withPermission('preview-c-room')
.withMethod('getRoomByTypeAndName', getRoomByTypeAndName)
.withEndpoint('POST', '/v1/rooms.getOrCreate', getOrCreateRoom)
.build(),
});

// Backoff is Math.min(1000 * 2 ** attempt, 5000); first retry fires after ~1s.
await waitFor(() => expect(result.current.isSuccess).toBe(true), { timeout: 3000 });
expect(result.current.data?.rid).toBe(channelRid);
expect(getRoomByTypeAndName).toHaveBeenCalledTimes(2);
expect(getOrCreateRoom).toHaveBeenCalledTimes(2);
});
});
});
36 changes: 11 additions & 25 deletions apps/meteor/client/views/room/hooks/useOpenRoom.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { isPublicRoom, type IRoom, type RoomType } from '@rocket.chat/core-typings';
import { getObjectKeys } from '@rocket.chat/tools';
import { useEndpoint, useMethod, usePermission, useRoute, useSetting, useUser } from '@rocket.chat/ui-contexts';
import { useEndpoint, usePermission, useRoute, useSetting, useUser } from '@rocket.chat/ui-contexts';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useCallback, useEffect } from 'react';

Expand All @@ -13,14 +13,14 @@ import { NotSubscribedToRoomError } from '../../../lib/errors/NotSubscribedToRoo
import { OldUrlRoomError } from '../../../lib/errors/OldUrlRoomError';
import { RoomNotFoundError } from '../../../lib/errors/RoomNotFoundError';
import { roomsQueryKeys } from '../../../lib/queryKeys';
import { mapRoomFromApi } from '../../../lib/utils/mapRoomFromApi';
import { Rooms, Subscriptions } from '../../../stores';

export function useOpenRoom({ type, reference }: { type: RoomType; reference: string }) {
const user = useUser();
const hasPreviewPermission = usePermission('preview-c-room');
const allowAnonymousRead = useSetting('Accounts_AllowAnonymousRead', true);
const getRoomByTypeAndName = useMethod('getRoomByTypeAndName');
const createDirectMessage = useEndpoint('POST', '/v1/im.create');
const getOrCreateRoom = useEndpoint('POST', '/v1/rooms.getOrCreate');
const directRoute = useRoute('direct');
const openRoom = useOpenRoomMutation();

Expand Down Expand Up @@ -76,33 +76,19 @@ export function useOpenRoom({ type, reference }: { type: RoomType; reference: st

let roomData: IRoom;
try {
roomData = await getRoomByTypeAndName(type, reference);
const { room } = await getOrCreateRoom({ type, name: reference });
roomData = mapRoomFromApi(room);
} catch (error) {
const errorCode = error && typeof error === 'object' && 'error' in error ? error.error : undefined;
const errorCode =
error && typeof error === 'object'
? ('errorType' in error && error.errorType) || ('error' in error && error.error) || undefined
: undefined;

// "No permission" means the room exists but the user can't see it — surface the
// not-found/no-access screen rather than retrying it as a transient failure.
if (errorCode === 'error-no-permission') {
if (errorCode === 'error-no-permission' || errorCode === 'error-invalid-room') {
throw new RoomNotFoundError(undefined, { type, reference });
}

if (errorCode !== 'error-invalid-room') {
throw error;
}

if (type !== 'd') {
throw new RoomNotFoundError(undefined, { type, reference });
}

try {
const { room } = await createDirectMessage({ usernames: reference });

directRoute.push({ rid: room._id }, (prev) => prev);
} catch (error) {
throw new RoomNotFoundError(undefined, { type, reference });
}

throw new OldUrlRoomError(undefined, { type, reference });
throw error;
Comment thread
ricardogarim marked this conversation as resolved.
}

if (!roomData._id) {
Expand Down
64 changes: 62 additions & 2 deletions apps/meteor/server/api/v1/rooms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
isPrivateRoom,
isPublicRoom,
type IUser,
type RoomType,
} from '@rocket.chat/core-typings';
import { Messages, Rooms, Users, Uploads, Subscriptions } from '@rocket.chat/models';
import type { Notifications } from '@rocket.chat/rest-typings';
Expand Down Expand Up @@ -60,8 +61,10 @@ import { FileUpload } from '../../lib/media/file-upload';
import { notifyOnSubscriptionChanged } from '../../lib/notifyListener';
import { openRoom } from '../../lib/openRoom';
import type { RoomRoles } from '../../lib/roles/getRoomRoles';
import { resolveDirectRoomTargets } from '../../lib/rooms/findDirectRoomByIdentifier';
import { syncRolePrioritiesForRoomIfRequired } from '../../lib/rooms/syncRolePrioritiesForRoomIfRequired';
import { unbanUserFromRoom } from '../../lib/unbanUserFromRoom';
import { createDirectMessage } from '../../meteor-methods/messages/createDirectMessage';
import { createDiscussion } from '../../meteor-methods/messages/createDiscussion';
import { sendFileMessage } from '../../meteor-methods/messages/sendFileMessage';
import { executeArchiveRoom } from '../../meteor-methods/rooms/archiveRoom';
Expand All @@ -76,7 +79,7 @@ import { executeUnarchiveRoom } from '../../meteor-methods/rooms/unarchiveRoom';
import { unmuteUserInRoom } from '../../meteor-methods/rooms/unmuteUserInRoom';
import { saveNotificationSettingsMethod } from '../../meteor-methods/users/saveNotificationSettings';
import type { NotificationFieldType } from '../../meteor-methods/users/saveNotificationSettings';
import { roomsGetMethod } from '../../publications/room';
import { findRoomByTypeAndName, roomsGetMethod } from '../../publications/room';
import { settings } from '../../settings';
import type { ExtractRoutesFromAPI } from '../ApiClass';
import { API } from '../api';
Expand Down Expand Up @@ -109,7 +112,7 @@ export async function findRoomByIdOrName({
}): Promise<IRoom> {
if (
(!('roomId' in params) && !('roomName' in params)) ||
('roomId' in params && !(params as { roomId?: string }).roomId && 'roomName' in params && !(params as { roomName?: string }).roomName)
('roomId' in params && !params.roomId && 'roomName' in params && !(params as { roomName?: string }).roomName)
) {
throw new Meteor.Error('error-roomid-param-not-provided', 'The parameter "roomId" or "roomName" is required');
}
Expand Down Expand Up @@ -516,6 +519,63 @@ API.v1.post(
},
);

API.v1.post(
'rooms.getOrCreate',
{
authRequired: false,
body: ajv.compile<{ type: RoomType; name: string }>({
type: 'object',
properties: {
type: { type: 'string', enum: ['c', 'd', 'p', 'l'] },

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

it doesn't make much sense to accept the 4 room types and then throwing on !d.

name: { type: 'string', minLength: 1 },
},
required: ['type', 'name'],
additionalProperties: false,
}),
Comment thread
hacktron-app[bot] marked this conversation as resolved.
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 { type, name } = this.bodyParams;

const room = await findRoomByTypeAndName(this.userId ?? null, type, name);
Comment thread
ricardogarim marked this conversation as resolved.
if (room) {
return API.v1.success({ room });
}

// Only direct messages can be created on demand; every other type must already exist. The
// caller may be anonymous here, since the route stays readable without a session.
if (type !== 'd' || !this.userId) {
return API.v1.failure('Invalid room [error-invalid-room]', 'error-invalid-room');
}

const targets = await resolveDirectRoomTargets(name);
if (!targets) {
return API.v1.failure('Invalid room [error-invalid-room]', 'error-invalid-room');
}

const { rid } = await createDirectMessage(targets, this.userId);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
ricardogarim marked this conversation as resolved.

const created = await findRoomByTypeAndName(this.userId ?? null, type, rid);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

why we can't return the result of createDirectMessage directly? 🤔 that would save a find and we know that if the function ends the room was created.

if (!created) {
return API.v1.failure('Invalid room [error-invalid-room]', 'error-invalid-room');
}

return API.v1.success({ room: created });
Comment thread
coderabbitai[bot] marked this conversation as resolved.
},
);

API.v1.get(
'rooms.info',
{
Expand Down
42 changes: 42 additions & 0 deletions apps/meteor/server/lib/rooms/findDirectRoomByIdentifier.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import type { IRoom, IUser } from '@rocket.chat/core-typings';
import { Rooms, Users } from '@rocket.chat/models';

export const parseDirectRoomTargets = (identifier: string): string[] => identifier.split(',').map((username) => username.trim());

const resolveUsernames = async (usernames: string[]): Promise<Pick<IUser, '_id' | 'username'>[] | null> => {
const users = await Users.findUsersByUsernames<Pick<IUser, '_id' | 'username'>>(usernames, {
Comment thread
ricardogarim marked this conversation as resolved.
projection: { _id: 1, username: 1 },
}).toArray();

return users.length === usernames.length ? users : null;
};

export const resolveDirectRoomTargets = async (identifier: string): Promise<string[] | null> => {
const targets = [...new Set(parseDirectRoomTargets(identifier))];

return (await resolveUsernames(targets)) ? targets : null;
};

export const findDirectRoomByIdentifier = async (identifier: string, user: Pick<IUser, '_id' | 'username'>): Promise<IRoom | null> => {
const targets = parseDirectRoomTargets(identifier);

if (targets.length === 1) {
const byId = await Rooms.findByTypeAndNameOrId('d', targets[0]);
if (byId) {
return byId;
Comment thread
ricardogarim marked this conversation as resolved.
}
}

if (!user.username) {
return null;
}

const members = await resolveUsernames([...new Set([user.username, ...targets])]);
if (!members) {
return null;
}

const uids = members.map(({ _id }) => _id).sort();

return Rooms.findOneDirectRoomContainingAllUserIDs(uids);
};
Loading
Loading