Skip to content
Merged
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
5 changes: 5 additions & 0 deletions libraries/grpc-sdk/src/modules/chat/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { ConduitModule } from '../../classes/index.js';
import {
ChatDefinition,
DeleteMessageRequest,
EditMessageRequest,
Room,
SendMessageRequest,
Expand Down Expand Up @@ -28,6 +29,10 @@ export class Chat extends ConduitModule<typeof ChatDefinition> {
return this.client!.editMessage(EditMessageRequest.fromPartial(messageData));
}

deleteMessage(messageData: DeleteMessageRequest): Promise<any> {
return this.client!.deleteMessage(DeleteMessageRequest.fromPartial(messageData));
}

createRoom(name: string, participants: string[]): Promise<Room> {
return this.client!.createRoom({ name, participants });
}
Expand Down
2 changes: 1 addition & 1 deletion modules/chat/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
20 changes: 19 additions & 1 deletion modules/chat/src/Chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,15 @@ 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';
import { status } from '@grpc/grpc-js';
import { runMigrations } from './migrations/index.js';
import {
CreateRoomRequest,
DeleteMessageRequest,
DeleteRoomRequest,
EditMessageRequest,
Room,
Expand All @@ -45,6 +46,7 @@ export default class Chat extends ManagedModule<Config> {
deleteRoom: this.deleteRoom.bind(this),
sendMessage: this.sendMessage.bind(this),
editMessage: this.editMessage.bind(this),
deleteMessage: this.deleteMessage.bind(this),
},
};
protected metricsSchema = metricsSchema;
Expand Down Expand Up @@ -347,6 +349,22 @@ export default class Chat extends ManagedModule<Config> {
}
}

async deleteMessage(
call: GrpcRequest<DeleteMessageRequest>,
callback: GrpcCallback<Record<string, never>>,
) {
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<DeleteRoomRequest>, callback: GrpcCallback<Room>) {
const { _id } = call.request;
let errorMessage: string | null = null;
Expand Down
6 changes: 6 additions & 0 deletions modules/chat/src/chat.proto
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ message EditMessageRequest {
string newMessage = 3;
}

message DeleteMessageRequest {
string messageId = 1;
string userId = 2;
}

message DeleteRoomRequest {
string _id = 1;
}
Expand All @@ -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);
}
41 changes: 7 additions & 34 deletions modules/chat/src/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -421,39 +426,7 @@ export class ChatRoutes {
async deleteMessage(call: ParsedRouterRequest): Promise<UnparsedRouterResponse> {
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';
}

Expand Down
157 changes: 157 additions & 0 deletions modules/chat/src/utils/deleteMessage.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof createSdk>) {
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);
});
});
53 changes: 53 additions & 0 deletions modules/chat/src/utils/deleteMessage.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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>(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>(ChatMessage.name, message._id, {
deleted: true,
})
.catch((e: Error) => {
throw new GrpcError(status.INTERNAL, e.message);
});
} else {
await grpcSdk
.database!.deleteOne<ChatMessage>(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));
}
1 change: 1 addition & 0 deletions modules/chat/src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
4 changes: 3 additions & 1 deletion modules/chat/tsconfig.test.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": []
}
Loading