Skip to content

Commit

Permalink
feat: implement room reader method to get messages in a room (#770)
Browse files Browse the repository at this point in the history
Co-authored-by: Douglas Gubert <[email protected]>
  • Loading branch information
Dnouv and d-gubert authored Jul 19, 2024
1 parent b5dcb6d commit aec2401
Show file tree
Hide file tree
Showing 9 changed files with 183 additions and 47 deletions.
15 changes: 10 additions & 5 deletions src/definition/accessors/IRoomRead.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { IMessage } from '../messages/index';
import type { GetMessagesOptions } from '../../server/bridges/RoomBridge';
import type { IMessageRaw } from '../messages/index';
import type { IRoom } from '../rooms/index';
import type { IUser } from '../users/index';

Expand Down Expand Up @@ -40,12 +41,16 @@ export interface IRoomRead {
getCreatorUserByName(name: string): Promise<IUser | undefined>;

/**
* Gets an iterator for all of the messages in the provided room.
* Retrieves an array of messages from the specified room.
*
* @param roomId the room's id
* @returns an iterator for messages
* @param roomId The unique identifier of the room from which to retrieve messages.
* @param options Optional parameters for retrieving messages:
* - limit: The maximum number of messages to retrieve. Maximum 100
* - skip: The number of messages to skip (for pagination).
* - sort: An object defining the sorting order of the messages. Each key is a field to sort by, and the value is either "asc" for ascending order or "desc" for descending order.
* @returns A Promise that resolves to an array of IMessage objects representing the messages in the room.
*/
getMessages(roomId: string): Promise<IterableIterator<IMessage>>;
getMessages(roomId: string, options?: Partial<GetMessagesOptions>): Promise<Array<IMessageRaw>>;

/**
* Gets an iterator for all of the users in the provided room.
Expand Down
40 changes: 40 additions & 0 deletions src/definition/messages/IMessageRaw.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import type { IBlock, Block } from '@rocket.chat/ui-kit';

import type { IRoom } from '../rooms';
import type { IUserLookup } from '../users';
import type { IMessageAttachment } from './IMessageAttachment';
import type { IMessageFile } from './IMessageFile';
import type { IMessageReactions } from './IMessageReaction';

/**
* The raw version of a message, without resolved information for relationship fields, i.e.
* `room`, `sender` and `editor` are not the complete entity like they are in `IMessage`
*
* This is used in methods that fetch multiple messages at the same time, as resolving the relationship
* fields require additional queries to the database and would hit the system's performance significantly.
*/
export interface IMessageRaw {
id: string;
roomId: IRoom['id'];
sender: IUserLookup;
createdAt: Date;
threadId?: string;
text?: string;
updatedAt?: Date;
editor?: IUserLookup;
editedAt?: Date;
emoji?: string;
avatarUrl?: string;
alias?: string;
file?: IMessageFile;
attachments?: Array<IMessageAttachment>;
reactions?: IMessageReactions;
groupable?: boolean;
parseUrls?: boolean;
customFields?: { [key: string]: any };
blocks?: Array<IBlock | Block>;
starred?: Array<{ _id: string }>;
pinned?: boolean;
pinnedAt?: Date;
pinnedBy?: IUserLookup;
}
2 changes: 2 additions & 0 deletions src/definition/messages/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { IMessageDeleteContext } from './IMessageDeleteContext';
import { IMessageFile } from './IMessageFile';
import { IMessageFollowContext } from './IMessageFollowContext';
import { IMessagePinContext } from './IMessagePinContext';
import { IMessageRaw } from './IMessageRaw';
import { IMessageReaction, IMessageReactions } from './IMessageReaction';
import { IMessageReactionContext } from './IMessageReactionContext';
import { IMessageReportContext } from './IMessageReportContext';
Expand Down Expand Up @@ -39,6 +40,7 @@ export {
IMessageAttachmentField,
IMessageAction,
IMessageFile,
IMessageRaw,
IMessageReactions,
IMessageReaction,
IPostMessageDeleted,
Expand Down
1 change: 1 addition & 0 deletions src/definition/users/IUserLookup.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export interface IUserLookup {
_id: string;
username: string;
name?: string;
}
30 changes: 27 additions & 3 deletions src/server/accessors/RoomRead.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import type { IRoomRead } from '../../definition/accessors';
import type { IMessage } from '../../definition/messages';
import type { IMessageRaw } from '../../definition/messages';
import type { IRoom } from '../../definition/rooms';
import type { IUser } from '../../definition/users';
import type { RoomBridge } from '../bridges';
import { type GetMessagesOptions, GetMessagesSortableFields } from '../bridges/RoomBridge';

export class RoomRead implements IRoomRead {
constructor(private roomBridge: RoomBridge, private appId: string) {}
Expand All @@ -23,8 +24,18 @@ export class RoomRead implements IRoomRead {
return this.roomBridge.doGetCreatorByName(name, this.appId);
}

public getMessages(roomId: string): Promise<IterableIterator<IMessage>> {
throw new Error('Method not implemented.');
public getMessages(roomId: string, options: Partial<GetMessagesOptions> = {}): Promise<IMessageRaw[]> {
if (typeof options.limit !== 'undefined' && (!Number.isFinite(options.limit) || options.limit > 100)) {
throw new Error(`Invalid limit provided. Expected number <= 100, got ${options.limit}`);
}

options.limit ??= 100;

if (options.sort) {
this.validateSort(options.sort);
}

return this.roomBridge.doGetMessages(roomId, options as GetMessagesOptions, this.appId);
}

public getMembers(roomId: string): Promise<Array<IUser>> {
Expand All @@ -46,4 +57,17 @@ export class RoomRead implements IRoomRead {
public getLeaders(roomId: string): Promise<Array<IUser>> {
return this.roomBridge.doGetLeaders(roomId, this.appId);
}

// If there are any invalid fields or values, throw
private validateSort(sort: Record<string, unknown>) {
Object.entries(sort).forEach(([key, value]) => {
if (!GetMessagesSortableFields.includes(key as typeof GetMessagesSortableFields[number])) {
throw new Error(`Invalid key "${key}" used in sort. Available keys for sorting are ${GetMessagesSortableFields.join(', ')}`);
}

if (value !== 'asc' && value !== 'desc') {
throw new Error(`Invalid sort direction for field "${key}". Expected "asc" or "desc", got ${value}`);
}
});
}
}
18 changes: 17 additions & 1 deletion src/server/bridges/RoomBridge.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
import type { IMessage } from '../../definition/messages';
import type { IMessage, IMessageRaw } from '../../definition/messages';
import type { IRoom } from '../../definition/rooms';
import type { IUser } from '../../definition/users';
import { PermissionDeniedError } from '../errors/PermissionDeniedError';
import { AppPermissionManager } from '../managers/AppPermissionManager';
import { AppPermissions } from '../permissions/AppPermissions';
import { BaseBridge } from './BaseBridge';

export const GetMessagesSortableFields = ['createdAt'] as const;

export type GetMessagesOptions = {
limit: number;
skip: number;
sort: Record<typeof GetMessagesSortableFields[number], 'asc' | 'desc'>;
};

export abstract class RoomBridge extends BaseBridge {
public async doCreate(room: IRoom, members: Array<string>, appId: string): Promise<string> {
if (this.hasWritePermission(appId)) {
Expand Down Expand Up @@ -91,6 +99,12 @@ export abstract class RoomBridge extends BaseBridge {
}
}

public async doGetMessages(roomId: string, options: GetMessagesOptions, appId: string): Promise<IMessageRaw[]> {
if (this.hasReadPermission(appId)) {
return this.getMessages(roomId, options, appId);
}
}

public async doRemoveUsers(roomId: string, usernames: Array<string>, appId: string): Promise<void> {
if (this.hasWritePermission(appId)) {
return this.removeUsers(roomId, usernames, appId);
Expand Down Expand Up @@ -129,6 +143,8 @@ export abstract class RoomBridge extends BaseBridge {

protected abstract getLeaders(roomId: string, appId: string): Promise<Array<IUser>>;

protected abstract getMessages(roomId: string, options: GetMessagesOptions, appId: string): Promise<IMessageRaw[]>;

protected abstract removeUsers(roomId: string, usernames: Array<string>, appId: string): Promise<void>;

private hasWritePermission(appId: string): boolean {
Expand Down
11 changes: 10 additions & 1 deletion tests/server/accessors/RoomRead.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,26 @@ import type { IUser } from '../../../src/definition/users';
import { RoomRead } from '../../../src/server/accessors';
import type { RoomBridge } from '../../../src/server/bridges';
import { TestData } from '../../test-data/utilities';
import type { IMessageRaw } from '../../../src/definition/messages';

export class RoomReadAccessorTestFixture {
private room: IRoom;

private user: IUser;

private messages: IMessageRaw[];

private mockRoomBridgeWithRoom: RoomBridge;

@SetupFixture
public setupFixture() {
this.room = TestData.getRoom();
this.user = TestData.getUser();
this.messages = ['507f1f77bcf86cd799439011', '507f191e810c19729de860ea'].map((id) => TestData.getMessageRaw(id));

const theRoom = this.room;
const theUser = this.user;
const theMessages = this.messages;
this.mockRoomBridgeWithRoom = {
doGetById(id, appId): Promise<IRoom> {
return Promise.resolve(theRoom);
Expand All @@ -39,6 +44,9 @@ export class RoomReadAccessorTestFixture {
doGetMembers(name, appId): Promise<Array<IUser>> {
return Promise.resolve([theUser]);
},
doGetMessages(roomId, options, appId): Promise<IMessageRaw[]> {
return Promise.resolve(theMessages);
},
} as RoomBridge;
}

Expand All @@ -58,14 +66,15 @@ export class RoomReadAccessorTestFixture {
Expect(await rr.getCreatorUserByName('testing')).toBe(this.user);
Expect(await rr.getDirectByUsernames([this.user.username])).toBeDefined();
Expect(await rr.getDirectByUsernames([this.user.username])).toBe(this.room);
Expect(await rr.getMessages('testing')).toBeDefined();
Expect(await rr.getMessages('testing')).toBe(this.messages);
}

@AsyncTest()
public async useTheIterators() {
Expect(() => new RoomRead(this.mockRoomBridgeWithRoom, 'testing-app')).not.toThrow();

const rr = new RoomRead(this.mockRoomBridgeWithRoom, 'testing-app');
await Expect(() => rr.getMessages('faker')).toThrowErrorAsync(Error, 'Method not implemented.');

Expect(await rr.getMembers('testing')).toBeDefined();
Expect((await rr.getMembers('testing')) as Array<IUser>).not.toBeEmpty();
Expand Down
7 changes: 6 additions & 1 deletion tests/test-data/bridges/roomBridge.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import type { IMessage } from '../../../src/definition/messages';
import type { IMessage, IMessageRaw } from '../../../src/definition/messages';
import type { IRoom } from '../../../src/definition/rooms';
import type { IUser } from '../../../src/definition/users';
import { RoomBridge } from '../../../src/server/bridges';
import type { GetMessagesOptions } from '../../../src/server/bridges/RoomBridge';

export class TestsRoomBridge extends RoomBridge {
public create(room: IRoom, members: Array<string>, appId: string): Promise<string> {
Expand Down Expand Up @@ -32,6 +33,10 @@ export class TestsRoomBridge extends RoomBridge {
throw new Error('Method not implemented.');
}

public getMessages(roomId: string, options: GetMessagesOptions, appId: string): Promise<IMessageRaw[]> {
throw new Error('Method not implemented.');
}

public update(room: IRoom, members: Array<string>, appId: string): Promise<void> {
throw new Error('Method not implemented.');
}
Expand Down
106 changes: 70 additions & 36 deletions tests/test-data/utilities.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { IHttp, IModify, IPersistence, IRead } from '../../src/definition/accessors';
import { HttpStatusCode } from '../../src/definition/accessors';
import type { IMessage } from '../../src/definition/messages';
import type { IMessage, IMessageAttachment, IMessageRaw } from '../../src/definition/messages';
import type { IRoom } from '../../src/definition/rooms';
import { RoomType } from '../../src/definition/rooms';
import type { ISetting } from '../../src/definition/settings';
Expand Down Expand Up @@ -128,6 +128,39 @@ export class TestInfastructureSetup {
}

const date = new Date();

const DEFAULT_ATTACHMENT = {
color: '#00b2b2',
collapsed: false,
text: 'Just an attachment that is used for testing',
timestampLink: 'https://google.com/',
thumbnailUrl: 'https://avatars0.githubusercontent.com/u/850391?s=88&v=4',
author: {
name: 'Author Name',
link: 'https://github.com/graywolf336',
icon: 'https://avatars0.githubusercontent.com/u/850391?s=88&v=4',
},
title: {
value: 'Attachment Title',
link: 'https://github.com/RocketChat',
displayDownloadLink: false,
},
imageUrl: 'https://rocket.chat/images/default/logo.svg',
audioUrl: 'http://www.w3schools.com/tags/horse.mp3',
videoUrl: 'http://www.w3schools.com/tags/movie.mp4',
fields: [
{
short: true,
title: 'Test',
value: 'Testing out something or other',
},
{
short: true,
title: 'Another Test',
value: '[Link](https://google.com/) something and this and that.',
},
],
};
export class TestData {
public static getDate(): Date {
return date;
Expand Down Expand Up @@ -193,41 +226,42 @@ export class TestData {
emoji: ':see_no_evil:',
avatarUrl: 'https://avatars0.githubusercontent.com/u/850391?s=88&v=4',
alias: 'Testing Bot',
attachments: [
{
collapsed: false,
color: '#00b2b2',
text: 'Just an attachment that is used for testing',
timestamp: new Date(),
timestampLink: 'https://google.com/',
thumbnailUrl: 'https://avatars0.githubusercontent.com/u/850391?s=88&v=4',
author: {
name: 'Author Name',
link: 'https://github.com/graywolf336',
icon: 'https://avatars0.githubusercontent.com/u/850391?s=88&v=4',
},
title: {
value: 'Attachment Title',
link: 'https://github.com/RocketChat',
displayDownloadLink: false,
},
imageUrl: 'https://rocket.chat/images/default/logo.svg',
audioUrl: 'http://www.w3schools.com/tags/horse.mp3',
videoUrl: 'http://www.w3schools.com/tags/movie.mp4',
fields: [
{
short: true,
title: 'Test',
value: 'Testing out something or other',
},
{
short: true,
title: 'Another Test',
value: '[Link](https://google.com/) something and this and that.',
},
],
},
],
attachments: [this.createAttachment()],
};
}

public static getMessageRaw(id?: string, text?: string): IMessageRaw {
const editorUser = TestData.getUser();
const senderUser = TestData.getUser();

return {
id: id || '4bShvoOXqB',
roomId: TestData.getRoom().id,
sender: {
_id: senderUser.id,
username: senderUser.username,
name: senderUser?.name,
},
text: text || 'This is just a test, do not be alarmed',
createdAt: date,
updatedAt: new Date(),
editor: {
_id: editorUser.id,
username: editorUser.username,
},
editedAt: new Date(),
emoji: ':see_no_evil:',
avatarUrl: 'https://avatars0.githubusercontent.com/u/850391?s=88&v=4',
alias: 'Testing Bot',
attachments: [this.createAttachment()],
};
}

private static createAttachment(attachment?: IMessageAttachment): IMessageAttachment {
attachment = attachment || DEFAULT_ATTACHMENT;
return {
timestamp: new Date(),
...attachment,
};
}

Expand Down

0 comments on commit aec2401

Please sign in to comment.