diff --git a/libraries/grpc-sdk/src/modules/chat/index.ts b/libraries/grpc-sdk/src/modules/chat/index.ts index 58fcf12ea..0ad196db3 100644 --- a/libraries/grpc-sdk/src/modules/chat/index.ts +++ b/libraries/grpc-sdk/src/modules/chat/index.ts @@ -1,6 +1,7 @@ import { ConduitModule } from '../../classes/index.js'; import { ChatDefinition, + DeleteMessageRequest, EditMessageRequest, Room, SendMessageRequest, @@ -28,6 +29,10 @@ export class Chat extends ConduitModule { return this.client!.editMessage(EditMessageRequest.fromPartial(messageData)); } + deleteMessage(messageData: DeleteMessageRequest): Promise { + return this.client!.deleteMessage(DeleteMessageRequest.fromPartial(messageData)); + } + createRoom(name: string, participants: string[]): Promise { return this.client!.createRoom({ name, participants }); } diff --git a/modules/chat/package.json b/modules/chat/package.json index e7c96d28c..255301d8b 100644 --- a/modules/chat/package.json +++ b/modules/chat/package.json @@ -29,7 +29,7 @@ "prepare": "npm run build", "build:docker": "docker build -t ghcr.io/conduitplatform/chat:latest -f ./Dockerfile ../../ && docker push ghcr.io/conduitplatform/chat:latest", "generateTypes": "sh build.sh", - "test": "npx tsc -p tsconfig.test.json && node --test dist-test/utils/invitationHelpers.test.js dist-test/utils/editMessage.test.js" + "test": "npx tsc -p tsconfig.test.json && node --test dist-test/utils/invitationHelpers.test.js dist-test/utils/editMessage.test.js dist-test/utils/deleteMessage.test.js" }, "directories": { "lib": "src" diff --git a/modules/chat/src/Chat.ts b/modules/chat/src/Chat.ts index e324407bb..10e934256 100644 --- a/modules/chat/src/Chat.ts +++ b/modules/chat/src/Chat.ts @@ -11,7 +11,7 @@ import AppConfigSchema, { Config } from './config/index.js'; import { AdminHandlers } from './admin/index.js'; import { ChatRoutes } from './routes/index.js'; import * as models from './models/index.js'; -import { editChatMessage, validateUsersInput } from './utils/index.js'; +import { deleteChatMessage, editChatMessage, validateUsersInput } from './utils/index.js'; import { MessageType } from './enums/messageType.enum.js'; import path from 'path'; import { isArray, isNil } from 'lodash-es'; @@ -19,6 +19,7 @@ import { status } from '@grpc/grpc-js'; import { runMigrations } from './migrations/index.js'; import { CreateRoomRequest, + DeleteMessageRequest, DeleteRoomRequest, EditMessageRequest, Room, @@ -45,6 +46,7 @@ export default class Chat extends ManagedModule { deleteRoom: this.deleteRoom.bind(this), sendMessage: this.sendMessage.bind(this), editMessage: this.editMessage.bind(this), + deleteMessage: this.deleteMessage.bind(this), }, }; protected metricsSchema = metricsSchema; @@ -347,6 +349,22 @@ export default class Chat extends ManagedModule { } } + async deleteMessage( + call: GrpcRequest, + callback: GrpcCallback>, + ) { + const { messageId, userId } = call.request; + try { + await deleteChatMessage(this.grpcSdk, { messageId, userId }); + callback(null, {}); + } catch (e) { + return callback({ + code: (e as GrpcError).code ?? status.INTERNAL, + message: (e as GrpcError).message, + }); + } + } + async deleteRoom(call: GrpcRequest, callback: GrpcCallback) { const { _id } = call.request; let errorMessage: string | null = null; diff --git a/modules/chat/src/chat.proto b/modules/chat/src/chat.proto index 3f76c1fdf..c83270f0d 100644 --- a/modules/chat/src/chat.proto +++ b/modules/chat/src/chat.proto @@ -22,6 +22,11 @@ message EditMessageRequest { string newMessage = 3; } +message DeleteMessageRequest { + string messageId = 1; + string userId = 2; +} + message DeleteRoomRequest { string _id = 1; } @@ -41,4 +46,5 @@ service Chat { rpc DeleteRoom(DeleteRoomRequest) returns (Room); rpc SendMessage(SendMessageRequest) returns (google.protobuf.Empty); rpc EditMessage(EditMessageRequest) returns (google.protobuf.Empty); + rpc DeleteMessage(DeleteMessageRequest) returns (google.protobuf.Empty); } diff --git a/modules/chat/src/routes/index.ts b/modules/chat/src/routes/index.ts index 4da7d407d..e8d97596e 100644 --- a/modules/chat/src/routes/index.ts +++ b/modules/chat/src/routes/index.ts @@ -21,7 +21,12 @@ import { import { ChatMessage, ChatParticipantsLog, ChatRoom, User } from '../models/index.js'; import { isArray, isNil } from 'lodash-es'; import { status } from '@grpc/grpc-js'; -import { editChatMessage, sendInvitations, validateUsersInput } from '../utils/index.js'; +import { + deleteChatMessage, + editChatMessage, + sendInvitations, + validateUsersInput, +} from '../utils/index.js'; import { getMembershipCacheKey, MEMBERSHIP_CACHE_TTL_MS, @@ -421,39 +426,7 @@ export class ChatRoutes { async deleteMessage(call: ParsedRouterRequest): Promise { const { messageId } = call.request.params; const { user } = call.request.context; - const message = await ChatMessage.getInstance() - .findOne({ _id: messageId, deleted: false }) - .catch((e: Error) => { - throw new GrpcError(status.INTERNAL, e.message); - }); - if (isNil(message) || message.senderUser !== user._id) { - throw new GrpcError( - status.NOT_FOUND, - "Message does not exist or you don't have access", - ); - } - if (ConfigController.getInstance().config.auditMode) { - await ChatMessage.getInstance() - .findByIdAndUpdate(messageId, { deleted: true }) - .catch((e: Error) => { - throw new GrpcError(status.INTERNAL, e.message); - }); - } else { - await ChatMessage.getInstance() - .deleteOne({ _id: messageId }) - .catch((e: Error) => { - throw new GrpcError(status.INTERNAL, e.message); - }); - } - - this.grpcSdk.router?.socketPush({ - event: 'message-deleted', - receivers: [], - rooms: [message.room as string], - data: JSON.stringify({ messageId, room: message.room }), - }); - - this.grpcSdk.bus?.publish('chat:delete:ChatMessage', JSON.stringify(messageId)); + await deleteChatMessage(this.grpcSdk, { messageId, userId: user._id }); return 'Message deleted successfully'; } diff --git a/modules/chat/src/utils/deleteMessage.test.ts b/modules/chat/src/utils/deleteMessage.test.ts new file mode 100644 index 000000000..3c1001528 --- /dev/null +++ b/modules/chat/src/utils/deleteMessage.test.ts @@ -0,0 +1,157 @@ +import { describe, it, beforeEach, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { ConduitGrpcSdk } from '@conduitplatform/grpc-sdk'; +import { ConfigController } from '@conduitplatform/module-tools'; +import { status } from '@grpc/grpc-js'; +import { deleteChatMessage } from './deleteMessage.js'; + +const EXISTING_MESSAGE = { + _id: 'msg-1', + senderUser: 'user-1', + room: 'room-1', +}; + +const NOT_FOUND_OR_NO_ACCESS = "Message does not exist or you don't have access"; + +function createSdk(message: typeof EXISTING_MESSAGE | null) { + const finds: unknown[] = []; + const updates: unknown[] = []; + const deletes: unknown[] = []; + const socketPushes: unknown[] = []; + const busEvents: unknown[] = []; + return { + finds, + updates, + deletes, + socketPushes, + busEvents, + grpcSdk: { + database: { + async findOne(schemaName: string, query: unknown) { + finds.push({ schemaName, query }); + return message; + }, + async findByIdAndUpdate(schemaName: string, id: string, document: unknown) { + updates.push({ schemaName, id, document }); + return { ...message, ...(document as object) }; + }, + async deleteOne(schemaName: string, query: unknown) { + deletes.push({ schemaName, query }); + return { deletedCount: 1 }; + }, + }, + router: { + socketPush(payload: unknown) { + socketPushes.push(payload); + }, + }, + bus: { + publish(event: string, payload: string) { + busEvents.push({ event, payload }); + }, + }, + } as unknown as ConduitGrpcSdk, + }; +} + +function assertBroadcast(sdk: ReturnType) { + assert.deepEqual(sdk.socketPushes, [ + { + event: 'message-deleted', + receivers: [], + rooms: ['room-1'], + data: JSON.stringify({ messageId: 'msg-1', room: 'room-1' }), + }, + ]); + assert.deepEqual(sdk.busEvents, [ + { event: 'chat:delete:ChatMessage', payload: JSON.stringify('msg-1') }, + ]); +} + +describe('deleteChatMessage', () => { + let previousConfig: unknown; + + beforeEach(() => { + previousConfig = ConfigController.getInstance().config; + ConfigController.getInstance().config = { + allowMessageDelete: true, + auditMode: false, + }; + }); + + afterEach(() => { + ConfigController.getInstance().config = previousConfig; + }); + + it('hard-deletes and broadcasts when auditMode is off', async () => { + const sdk = createSdk(EXISTING_MESSAGE); + + await deleteChatMessage(sdk.grpcSdk, { messageId: 'msg-1', userId: 'user-1' }); + + assert.deepEqual(sdk.finds, [ + { schemaName: 'ChatMessage', query: { _id: 'msg-1', deleted: false } }, + ]); + assert.deepEqual(sdk.deletes, [ + { schemaName: 'ChatMessage', query: { _id: 'msg-1' } }, + ]); + assert.equal(sdk.updates.length, 0); + assertBroadcast(sdk); + }); + + it('marks the message deleted and broadcasts when auditMode is on', async () => { + ConfigController.getInstance().config = { allowMessageDelete: true, auditMode: true }; + const sdk = createSdk(EXISTING_MESSAGE); + + await deleteChatMessage(sdk.grpcSdk, { messageId: 'msg-1', userId: 'user-1' }); + + assert.deepEqual(sdk.updates, [ + { schemaName: 'ChatMessage', id: 'msg-1', document: { deleted: true } }, + ]); + assert.equal(sdk.deletes.length, 0); + assertBroadcast(sdk); + }); + + it('throws NOT_FOUND when the message is missing', async () => { + const sdk = createSdk(null); + + await assert.rejects( + () => deleteChatMessage(sdk.grpcSdk, { messageId: 'missing', userId: 'user-1' }), + { code: status.NOT_FOUND, message: NOT_FOUND_OR_NO_ACCESS }, + ); + assert.equal(sdk.updates.length, 0); + assert.equal(sdk.deletes.length, 0); + assert.equal(sdk.socketPushes.length, 0); + assert.equal(sdk.busEvents.length, 0); + }); + + it('throws NOT_FOUND when the acting user is not the sender', async () => { + const sdk = createSdk(EXISTING_MESSAGE); + + await assert.rejects( + () => deleteChatMessage(sdk.grpcSdk, { messageId: 'msg-1', userId: 'other-user' }), + { code: status.NOT_FOUND, message: NOT_FOUND_OR_NO_ACCESS }, + ); + assert.equal(sdk.updates.length, 0); + assert.equal(sdk.deletes.length, 0); + assert.equal(sdk.socketPushes.length, 0); + assert.equal(sdk.busEvents.length, 0); + }); + + it('throws FAILED_PRECONDITION and does not write when allowMessageDelete is false', async () => { + ConfigController.getInstance().config = { + allowMessageDelete: false, + auditMode: false, + }; + const sdk = createSdk(EXISTING_MESSAGE); + + await assert.rejects( + () => deleteChatMessage(sdk.grpcSdk, { messageId: 'msg-1', userId: 'user-1' }), + { code: status.FAILED_PRECONDITION, message: 'Message deletion is disabled' }, + ); + assert.equal(sdk.finds.length, 0); + assert.equal(sdk.updates.length, 0); + assert.equal(sdk.deletes.length, 0); + assert.equal(sdk.socketPushes.length, 0); + assert.equal(sdk.busEvents.length, 0); + }); +}); diff --git a/modules/chat/src/utils/deleteMessage.ts b/modules/chat/src/utils/deleteMessage.ts new file mode 100644 index 000000000..492c5f615 --- /dev/null +++ b/modules/chat/src/utils/deleteMessage.ts @@ -0,0 +1,53 @@ +import { ConduitGrpcSdk, GrpcError } from '@conduitplatform/grpc-sdk'; +import { ConfigController } from '@conduitplatform/module-tools'; +import { status } from '@grpc/grpc-js'; +import { isNil } from 'lodash-es'; +import { ChatMessage } from '../models/index.js'; + +export async function deleteChatMessage( + grpcSdk: ConduitGrpcSdk, + params: { messageId: string; userId: string }, +): Promise { + if (!ConfigController.getInstance().config.allowMessageDelete) { + throw new GrpcError(status.FAILED_PRECONDITION, 'Message deletion is disabled'); + } + + const { messageId, userId } = params; + + const message = await grpcSdk + .database!.findOne(ChatMessage.name, { _id: messageId, deleted: false }) + .catch((e: Error) => { + throw new GrpcError(status.INTERNAL, e.message); + }); + if (isNil(message) || message.senderUser !== userId) { + throw new GrpcError( + status.NOT_FOUND, + "Message does not exist or you don't have access", + ); + } + + if (ConfigController.getInstance().config.auditMode) { + await grpcSdk + .database!.findByIdAndUpdate(ChatMessage.name, message._id, { + deleted: true, + }) + .catch((e: Error) => { + throw new GrpcError(status.INTERNAL, e.message); + }); + } else { + await grpcSdk + .database!.deleteOne(ChatMessage.name, { _id: messageId }) + .catch((e: Error) => { + throw new GrpcError(status.INTERNAL, e.message); + }); + } + + grpcSdk.router?.socketPush({ + event: 'message-deleted', + receivers: [], + rooms: [message.room as string], + data: JSON.stringify({ messageId, room: message.room }), + }); + + grpcSdk.bus?.publish('chat:delete:ChatMessage', JSON.stringify(messageId)); +} diff --git a/modules/chat/src/utils/index.ts b/modules/chat/src/utils/index.ts index 28a007832..4f25d6ddc 100644 --- a/modules/chat/src/utils/index.ts +++ b/modules/chat/src/utils/index.ts @@ -7,6 +7,7 @@ import { buildInvitationHookUrl } from './invitationHelpers.js'; export { buildInvitationHookUrl }; export { editChatMessage } from './editMessage.js'; +export { deleteChatMessage } from './deleteMessage.js'; export async function validateUsersInput(grpcSdk: ConduitGrpcSdk, users: UntypedArray) { const uniqueUsers = Array.from(new Set(users)); diff --git a/modules/chat/tsconfig.test.json b/modules/chat/tsconfig.test.json index 41fa53bca..5fca5375a 100644 --- a/modules/chat/tsconfig.test.json +++ b/modules/chat/tsconfig.test.json @@ -12,7 +12,9 @@ "src/utils/invitationHelpers.test.ts", "src/utils/membershipCache.ts", "src/utils/editMessage.ts", - "src/utils/editMessage.test.ts" + "src/utils/editMessage.test.ts", + "src/utils/deleteMessage.ts", + "src/utils/deleteMessage.test.ts" ], "exclude": [] }