From 4d30aa389d8da62160557198dab857a83a6022ff Mon Sep 17 00:00:00 2001 From: Mohamed Marrouchi Date: Mon, 11 Nov 2024 12:57:27 +0000 Subject: [PATCH] feat: add thread entity (Partial) --- api/src/channel/lib/EventWrapper.ts | 17 +++-- api/src/chat/chat.module.ts | 7 ++ api/src/chat/dto/message.dto.ts | 7 +- .../chat/repositories/thread.repository.ts | 35 +++++++++ api/src/chat/schemas/message.schema.ts | 14 ++++ api/src/chat/schemas/thread.schema.ts | 76 +++++++++++++++++++ api/src/chat/services/chat.service.ts | 2 + api/src/chat/services/message.service.ts | 18 +++++ api/src/chat/services/thread.service.ts | 43 +++++++++++ .../channels/web/base-web-channel.ts | 3 +- api/src/extensions/channels/web/types.ts | 2 + 11 files changed, 217 insertions(+), 7 deletions(-) create mode 100644 api/src/chat/repositories/thread.repository.ts create mode 100644 api/src/chat/schemas/thread.schema.ts create mode 100644 api/src/chat/services/thread.service.ts diff --git a/api/src/channel/lib/EventWrapper.ts b/api/src/channel/lib/EventWrapper.ts index 9456e2007..666a2473f 100644 --- a/api/src/channel/lib/EventWrapper.ts +++ b/api/src/channel/lib/EventWrapper.ts @@ -23,11 +23,8 @@ import ChannelHandler from './Handler'; export interface ChannelEvent {} -export default abstract class EventWrapper< - A, - E, - C extends ChannelHandler = ChannelHandler, -> { +// eslint-disable-next-line prettier/prettier +export default abstract class EventWrapper { _adapter: A = {} as A; _handler: C; @@ -203,6 +200,16 @@ export default abstract class EventWrapper< */ abstract getPayload(): Payload | string | undefined; + /** + * Retrieves the thread id to which the message belongs to + * + * @returns Thread id + */ + getThreadId(): string { + // To be implemented in child class when needed + return undefined; + } + /** * Returns the message in a standardized format * diff --git a/api/src/chat/chat.module.ts b/api/src/chat/chat.module.ts index 16c59877c..9a71d7446 100644 --- a/api/src/chat/chat.module.ts +++ b/api/src/chat/chat.module.ts @@ -28,6 +28,7 @@ import { ConversationRepository } from './repositories/conversation.repository'; import { LabelRepository } from './repositories/label.repository'; import { MessageRepository } from './repositories/message.repository'; import { SubscriberRepository } from './repositories/subscriber.repository'; +import { ThreadRepository } from './repositories/thread.repository'; import { BlockModel } from './schemas/block.schema'; import { CategoryModel } from './schemas/category.schema'; import { ContextVarModel } from './schemas/context-var.schema'; @@ -35,6 +36,7 @@ import { ConversationModel } from './schemas/conversation.schema'; import { LabelModel } from './schemas/label.schema'; import { MessageModel } from './schemas/message.schema'; import { SubscriberModel } from './schemas/subscriber.schema'; +import { ThreadModel } from './schemas/thread.schema'; import { CategorySeeder } from './seeds/category.seed'; import { ContextVarSeeder } from './seeds/context-var.seed'; import { BlockService } from './services/block.service'; @@ -46,6 +48,7 @@ import { ConversationService } from './services/conversation.service'; import { LabelService } from './services/label.service'; import { MessageService } from './services/message.service'; import { SubscriberService } from './services/subscriber.service'; +import { ThreadService } from './services/thread.service'; @Module({ imports: [ @@ -58,6 +61,7 @@ import { SubscriberService } from './services/subscriber.service'; SubscriberModel, ConversationModel, SubscriberModel, + ThreadModel, ]), forwardRef(() => ChannelModule), CmsModule, @@ -81,6 +85,7 @@ import { SubscriberService } from './services/subscriber.service'; MessageRepository, SubscriberRepository, ConversationRepository, + ThreadRepository, CategoryService, ContextVarService, LabelService, @@ -92,6 +97,7 @@ import { SubscriberService } from './services/subscriber.service'; ConversationService, ChatService, BotService, + ThreadService, ], exports: [ SubscriberService, @@ -99,6 +105,7 @@ import { SubscriberService } from './services/subscriber.service'; LabelService, BlockService, ConversationService, + ThreadService, ], }) export class ChatModule {} diff --git a/api/src/chat/dto/message.dto.ts b/api/src/chat/dto/message.dto.ts index a5855bde4..84bfb6284 100644 --- a/api/src/chat/dto/message.dto.ts +++ b/api/src/chat/dto/message.dto.ts @@ -11,8 +11,8 @@ import { IsBoolean, IsNotEmpty, IsObject, - IsString, IsOptional, + IsString, } from 'class-validator'; import { IsObjectId } from '@/utils/validation-rules/is-object-id'; @@ -58,6 +58,11 @@ export class MessageCreateDto { @IsValidMessageText({ message: 'Message should have text property' }) message: StdOutgoingMessage | StdIncomingMessage; + @ApiProperty({ description: 'Thread', type: String }) + @IsString() + @IsOptional() + thread?: string; + @ApiPropertyOptional({ description: 'Message is read', type: Boolean }) @IsBoolean() @IsNotEmpty() diff --git a/api/src/chat/repositories/thread.repository.ts b/api/src/chat/repositories/thread.repository.ts new file mode 100644 index 000000000..f9ee19cd9 --- /dev/null +++ b/api/src/chat/repositories/thread.repository.ts @@ -0,0 +1,35 @@ +/* + * Copyright © 2024 Hexastack. All rights reserved. + * + * Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms: + * 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission. + * 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file). + */ + +import { Injectable } from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { InjectModel } from '@nestjs/mongoose'; +import { Model } from 'mongoose'; + +import { BaseRepository } from '@/utils/generics/base-repository'; + +import { + Thread, + THREAD_POPULATE, + ThreadFull, + ThreadPopulate, +} from '../schemas/thread.schema'; + +@Injectable() +export class ThreadRepository extends BaseRepository< + Thread, + ThreadPopulate, + ThreadFull +> { + constructor( + readonly eventEmitter: EventEmitter2, + @InjectModel(Thread.name) readonly model: Model, + ) { + super(eventEmitter, model, Thread, THREAD_POPULATE, ThreadFull); + } +} diff --git a/api/src/chat/schemas/message.schema.ts b/api/src/chat/schemas/message.schema.ts index 302db5080..21b882ae7 100644 --- a/api/src/chat/schemas/message.schema.ts +++ b/api/src/chat/schemas/message.schema.ts @@ -15,6 +15,7 @@ import { LifecycleHookManager } from '@/utils/generics/lifecycle-hook-manager'; import { TFilterPopulateFields } from '@/utils/types/filter.types'; import { Subscriber } from './subscriber.schema'; +import { Thread } from './thread.schema'; import { StdIncomingMessage, StdOutgoingMessage } from './types/message'; @Schema({ timestamps: true }) @@ -47,6 +48,13 @@ export class MessageStub extends BaseSchema { }) sentBy?: unknown; + @Prop({ + type: MongooseSchema.Types.ObjectId, + required: false, + ref: 'Thread', + }) + thread?: unknown; + @Prop({ type: Object, required: true, @@ -82,6 +90,9 @@ export class Message extends MessageStub { @Transform(({ obj }) => obj.sentBy?.toString()) sentBy?: string; + + @Transform(({ obj }) => obj.thread?.toString()) + thread?: string; } @Schema({ timestamps: true }) @@ -94,6 +105,9 @@ export class MessageFull extends MessageStub { @Transform(({ obj }) => obj.sentBy?.toString()) sentBy?: string; // sendBy is never populate + + @Transform(() => Thread) + thread?: Thread; } export const MessageModel: ModelDefinition = LifecycleHookManager.attach({ diff --git a/api/src/chat/schemas/thread.schema.ts b/api/src/chat/schemas/thread.schema.ts new file mode 100644 index 000000000..60753b7e8 --- /dev/null +++ b/api/src/chat/schemas/thread.schema.ts @@ -0,0 +1,76 @@ +/* + * Copyright © 2024 Hexastack. All rights reserved. + * + * Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms: + * 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission. + * 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file). + */ + +import { ModelDefinition, Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { Exclude, Transform, Type } from 'class-transformer'; +import { Schema as MongooseSchema } from 'mongoose'; + +import { BaseSchema } from '@/utils/generics/base-schema'; +import { LifecycleHookManager } from '@/utils/generics/lifecycle-hook-manager'; +import { + TFilterPopulateFields, + THydratedDocument, +} from '@/utils/types/filter.types'; + +import { Message } from './message.schema'; +import { Subscriber } from './subscriber.schema'; + +@Schema({ timestamps: true }) +export class ThreadStub extends BaseSchema { + @Prop({ + type: String, + unique: true, + required: true, + }) + title: string; + + @Prop({ + type: MongooseSchema.Types.ObjectId, + required: false, + ref: 'Subscriber', + }) + subscriber?: unknown; +} + +@Schema({ timestamps: true }) +export class Thread extends ThreadStub { + @Transform(({ obj }) => obj.subscriber?.toString()) + subscriber: string; + + @Exclude() + messages?: never; +} + +@Schema({ timestamps: true }) +export class ThreadFull extends ThreadStub { + @Type(() => Subscriber) + subscriber: Subscriber; + + @Type(() => Message) + messages?: Message[]; +} + +export type ThreadDocument = THydratedDocument; + +export const ThreadModel: ModelDefinition = LifecycleHookManager.attach({ + name: Thread.name, + schema: SchemaFactory.createForClass(ThreadStub), +}); + +ThreadModel.schema.virtual('messages', { + ref: 'Message', + localField: '_id', + foreignField: 'thread', + justOne: false, +}); + +export default ThreadModel.schema; + +export type ThreadPopulate = keyof TFilterPopulateFields; + +export const THREAD_POPULATE: ThreadPopulate[] = ['messages', 'subscriber']; diff --git a/api/src/chat/services/chat.service.ts b/api/src/chat/services/chat.service.ts index 6342127b7..24571db70 100644 --- a/api/src/chat/services/chat.service.ts +++ b/api/src/chat/services/chat.service.ts @@ -106,8 +106,10 @@ export class ChatService { this.logger.warn('Failed to get the event id', messageId); } const subscriber = event.getSender(); + const received: MessageCreateDto = { mid: messageId, + thread: event.getThreadId(), sender: subscriber.id, message: event.getMessage(), delivery: true, diff --git a/api/src/chat/services/message.service.ts b/api/src/chat/services/message.service.ts index c66d09038..017b51c1e 100644 --- a/api/src/chat/services/message.service.ts +++ b/api/src/chat/services/message.service.ts @@ -28,6 +28,7 @@ import { WebsocketGateway } from '@/websocket/websocket.gateway'; import { MessageRepository } from '../repositories/message.repository'; import { MessageFull, MessagePopulate } from '../schemas/message.schema'; import { Subscriber } from '../schemas/subscriber.schema'; +import { ThreadStub } from '../schemas/thread.schema'; import { AnyMessage } from '../schemas/types/message'; @Injectable() @@ -135,4 +136,21 @@ export class MessageService extends BaseService< return lastMessages.reverse(); } + + /** + * Retrieves all the messages of a given thread + * + * @param subscriber The subscriber whose messages is being retrieved. + * @param threadId Thread ID + * + * @returns All messages. + */ + async findByThread(thread: T) { + return await this.find( + { + thread: thread.id, + }, + ['createdAt', 'asc'], + ); + } } diff --git a/api/src/chat/services/thread.service.ts b/api/src/chat/services/thread.service.ts new file mode 100644 index 000000000..0a5cff9ae --- /dev/null +++ b/api/src/chat/services/thread.service.ts @@ -0,0 +1,43 @@ +/* + * Copyright © 2024 Hexastack. All rights reserved. + * + * Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms: + * 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission. + * 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file). + */ + +import { Injectable } from '@nestjs/common'; + +import { BaseService } from '@/utils/generics/base-service'; + +import { ThreadRepository } from '../repositories/thread.repository'; +import { SubscriberStub } from '../schemas/subscriber.schema'; +import { Thread, ThreadFull, ThreadPopulate } from '../schemas/thread.schema'; + +@Injectable() +export class ThreadService extends BaseService< + Thread, + ThreadPopulate, + ThreadFull +> { + constructor(private readonly threadRepository: ThreadRepository) { + super(threadRepository); + } + + /** + * Retrieves the latest thread for a given subscriber + * + * @param subscriber The subscriber whose thread is being retrieved. + * + * @returns Last thread + */ + async findLast(subscriber: S) { + const [thread] = await this.findPage( + { + subscriber: subscriber.id, + }, + { skip: 0, limit: 1, sort: ['createdAt', 'desc'] }, + ); + return thread; + } +} diff --git a/api/src/extensions/channels/web/base-web-channel.ts b/api/src/extensions/channels/web/base-web-channel.ts index c8d6ab445..2d7c737da 100644 --- a/api/src/extensions/channels/web/base-web-channel.ts +++ b/api/src/extensions/channels/web/base-web-channel.ts @@ -762,11 +762,12 @@ export default abstract class BaseWebChannelHandler< channelData, ); if (event.getEventType() === 'message') { - // Handler sync message sent by chabbot + // Handle sync message sent by the bot if (data.sync && data.author === 'chatbot') { const sentMessage: MessageCreateDto = { mid: event.getId(), message: event.getMessage() as StdOutgoingMessage, + thread: event.getThreadId(), recipient: profile.id, read: true, delivery: true, diff --git a/api/src/extensions/channels/web/types.ts b/api/src/extensions/channels/web/types.ts index a7da407cc..21c666831 100644 --- a/api/src/extensions/channels/web/types.ts +++ b/api/src/extensions/channels/web/types.ts @@ -151,6 +151,7 @@ export namespace Web { | IncomingAttachmentMessage, > = T & { mid?: string; + thread?: string; author?: string; read?: boolean; delivery?: boolean; @@ -231,6 +232,7 @@ export namespace Web { export type OutgoingMessage = OutgoingMessageBase & { mid: string; + thread?: string; author: string; read?: boolean; createdAt: Date;