From 59028225eef9efed258c6a7e44402f94ae31bd64 Mon Sep 17 00:00:00 2001 From: Artem Niehrieiev Date: Fri, 30 Jan 2026 08:40:27 +0000 Subject: [PATCH 1/3] feat: add user AI chat and message entities with repository and use cases - Introduced UserAiChatEntity and AiChatMessageEntity to manage AI chat sessions and messages. - Implemented IUserAiChatRepository and IAiChatMessageRepository interfaces for data access. - Created repository extensions for user AI chat and message functionalities. - Developed use cases for finding, deleting, and retrieving user AI chats and messages. - Added UserAiChatController to handle API requests related to user AI chats. - Updated global database context to include new repositories. - Created migration to set up database tables and relationships for AI chat entities. --- .../global-database-context.interface.ts | 6 + .../application/global-database-context.ts | 22 ++ backend/src/common/data-injection.tokens.ts | 3 + backend/src/decorators/slug-uuid.decorator.ts | 6 +- .../ai-chat-message.entity.ts | 40 +++ .../ai-chat-messages/message-role.enum.ts | 5 + .../ai-chat-message-repository.extension.ts | 19 + .../ai-chat-message-repository.interface.ts | 6 + .../data-structures/user-ai-chat.ds.ts | 13 + .../response-objects/user-ai-chat.ro.ts | 19 + .../utils/build-user-ai-chat-ro.util.ts | 31 ++ .../use-cases/delete-user-ai-chat.use.case.ts | 35 ++ .../find-user-ai-chat-by-id.use.case.ts | 33 ++ .../use-cases/find-user-ai-chats.use.case.ts | 27 ++ .../user-ai-chat-use-cases.interface.ts | 20 ++ .../user-ai-chat.controller.ts | 75 ++++ .../user-ai-chat-repository.extension.ts | 27 ++ .../user-ai-chat-repository.interface.ts | 7 + .../user-ai-chat/user-ai-chat.entity.ts | 44 +++ backend/src/entities/ai/ai.module.ts | 25 +- backend/src/entities/user/user.entity.ts | 328 ++++++++++-------- backend/src/exceptions/text/messages.ts | 1 + ...633-AddChatWithAiAndChatMessageEntities.ts | 29 ++ 23 files changed, 678 insertions(+), 143 deletions(-) create mode 100644 backend/src/entities/ai/ai-conversation-history/ai-chat-messages/ai-chat-message.entity.ts create mode 100644 backend/src/entities/ai/ai-conversation-history/ai-chat-messages/message-role.enum.ts create mode 100644 backend/src/entities/ai/ai-conversation-history/ai-chat-messages/repository/ai-chat-message-repository.extension.ts create mode 100644 backend/src/entities/ai/ai-conversation-history/ai-chat-messages/repository/ai-chat-message-repository.interface.ts create mode 100644 backend/src/entities/ai/ai-conversation-history/application/data-structures/user-ai-chat.ds.ts create mode 100644 backend/src/entities/ai/ai-conversation-history/application/response-objects/user-ai-chat.ro.ts create mode 100644 backend/src/entities/ai/ai-conversation-history/application/utils/build-user-ai-chat-ro.util.ts create mode 100644 backend/src/entities/ai/ai-conversation-history/use-cases/delete-user-ai-chat.use.case.ts create mode 100644 backend/src/entities/ai/ai-conversation-history/use-cases/find-user-ai-chat-by-id.use.case.ts create mode 100644 backend/src/entities/ai/ai-conversation-history/use-cases/find-user-ai-chats.use.case.ts create mode 100644 backend/src/entities/ai/ai-conversation-history/use-cases/user-ai-chat-use-cases.interface.ts create mode 100644 backend/src/entities/ai/ai-conversation-history/user-ai-chat.controller.ts create mode 100644 backend/src/entities/ai/ai-conversation-history/user-ai-chat/repository/user-ai-chat-repository.extension.ts create mode 100644 backend/src/entities/ai/ai-conversation-history/user-ai-chat/repository/user-ai-chat-repository.interface.ts create mode 100644 backend/src/entities/ai/ai-conversation-history/user-ai-chat/user-ai-chat.entity.ts create mode 100644 backend/src/migrations/1769759553633-AddChatWithAiAndChatMessageEntities.ts diff --git a/backend/src/common/application/global-database-context.interface.ts b/backend/src/common/application/global-database-context.interface.ts index 5296f1a2d..0db8b7407 100644 --- a/backend/src/common/application/global-database-context.interface.ts +++ b/backend/src/common/application/global-database-context.interface.ts @@ -62,6 +62,10 @@ import { DashboardEntity } from '../../entities/visualizations/dashboard/dashboa import { DashboardWidgetEntity } from '../../entities/visualizations/dashboard-widget/dashboard-widget.entity.js'; import { IDashboardRepository } from '../../entities/visualizations/dashboard/repository/dashboard.repository.interface.js'; import { IDashboardWidgetRepository } from '../../entities/visualizations/dashboard-widget/repository/dashboard-widget.repository.interface.js'; +import { UserAiChatEntity } from '../../entities/ai/ai-conversation-history/user-ai-chat/user-ai-chat.entity.js'; +import { IUserAiChatRepository } from '../../entities/ai/ai-conversation-history/user-ai-chat/repository/user-ai-chat-repository.interface.js'; +import { AiChatMessageEntity } from '../../entities/ai/ai-conversation-history/ai-chat-messages/ai-chat-message.entity.js'; +import { IAiChatMessageRepository } from '../../entities/ai/ai-conversation-history/ai-chat-messages/repository/ai-chat-message-repository.interface.js'; export interface IGlobalDatabaseContext extends IDatabaseContext { userRepository: Repository & IUserRepository; @@ -104,4 +108,6 @@ export interface IGlobalDatabaseContext extends IDatabaseContext { savedDbQueryRepository: Repository & ISavedDbQueryRepository; dashboardRepository: Repository & IDashboardRepository; dashboardWidgetRepository: Repository & IDashboardWidgetRepository; + userAiChatRepository: Repository & IUserAiChatRepository; + aiChatMessageRepository: Repository & IAiChatMessageRepository; } diff --git a/backend/src/common/application/global-database-context.ts b/backend/src/common/application/global-database-context.ts index f8740b69b..8da92c81e 100644 --- a/backend/src/common/application/global-database-context.ts +++ b/backend/src/common/application/global-database-context.ts @@ -111,6 +111,12 @@ import { IDashboardRepository } from '../../entities/visualizations/dashboard/re import { IDashboardWidgetRepository } from '../../entities/visualizations/dashboard-widget/repository/dashboard-widget.repository.interface.js'; import { dashboardCustomRepositoryExtension } from '../../entities/visualizations/dashboard/repository/dashboard-custom-repository-extension.js'; import { dashboardWidgetCustomRepositoryExtension } from '../../entities/visualizations/dashboard-widget/repository/dashboard-widget-custom-repository-extension.js'; +import { UserAiChatEntity } from '../../entities/ai/ai-conversation-history/user-ai-chat/user-ai-chat.entity.js'; +import { IUserAiChatRepository } from '../../entities/ai/ai-conversation-history/user-ai-chat/repository/user-ai-chat-repository.interface.js'; +import { userAiChatRepositoryExtension } from '../../entities/ai/ai-conversation-history/user-ai-chat/repository/user-ai-chat-repository.extension.js'; +import { AiChatMessageEntity } from '../../entities/ai/ai-conversation-history/ai-chat-messages/ai-chat-message.entity.js'; +import { IAiChatMessageRepository } from '../../entities/ai/ai-conversation-history/ai-chat-messages/repository/ai-chat-message-repository.interface.js'; +import { aiChatMessageRepositoryExtension } from '../../entities/ai/ai-conversation-history/ai-chat-messages/repository/ai-chat-message-repository.extension.js'; @Injectable({ scope: Scope.REQUEST }) export class GlobalDatabaseContext implements IGlobalDatabaseContext { @@ -156,6 +162,8 @@ export class GlobalDatabaseContext implements IGlobalDatabaseContext { private _savedDbQueryRepository: Repository & ISavedDbQueryRepository; private _dashboardRepository: Repository & IDashboardRepository; private _dashboardWidgetRepository: Repository & IDashboardWidgetRepository; + private _userAiChatRepository: Repository & IUserAiChatRepository; + private _aiChatMessageRepository: Repository & IAiChatMessageRepository; public constructor( @Inject(BaseType.DATA_SOURCE) @@ -265,6 +273,12 @@ export class GlobalDatabaseContext implements IGlobalDatabaseContext { this._dashboardWidgetRepository = this.appDataSource .getRepository(DashboardWidgetEntity) .extend(dashboardWidgetCustomRepositoryExtension); + this._userAiChatRepository = this.appDataSource + .getRepository(UserAiChatEntity) + .extend(userAiChatRepositoryExtension); + this._aiChatMessageRepository = this.appDataSource + .getRepository(AiChatMessageEntity) + .extend(aiChatMessageRepositoryExtension); } public get userRepository(): Repository & IUserRepository { @@ -429,6 +443,14 @@ export class GlobalDatabaseContext implements IGlobalDatabaseContext { return this._dashboardWidgetRepository; } + public get userAiChatRepository(): Repository & IUserAiChatRepository { + return this._userAiChatRepository; + } + + public get aiChatMessageRepository(): Repository & IAiChatMessageRepository { + return this._aiChatMessageRepository; + } + public startTransaction(): Promise { this._queryRunner = this.appDataSource.createQueryRunner(); this._queryRunner.startTransaction(); diff --git a/backend/src/common/data-injection.tokens.ts b/backend/src/common/data-injection.tokens.ts index fc7052955..dea9caaf7 100644 --- a/backend/src/common/data-injection.tokens.ts +++ b/backend/src/common/data-injection.tokens.ts @@ -160,6 +160,9 @@ export enum UseCaseType { REQUEST_INFO_FROM_TABLE_WITH_AI_V2 = 'REQUEST_INFO_FROM_TABLE_WITH_AI_V2', REQUEST_INFO_FROM_TABLE_WITH_AI_V3 = 'REQUEST_INFO_FROM_TABLE_WITH_AI_V3', REQUEST_AI_SETTINGS_AND_WIDGETS_CREATION = 'REQUEST_AI_SETTINGS_AND_WIDGETS_CREATION', + FIND_USER_AI_CHATS = 'FIND_USER_AI_CHATS', + FIND_USER_AI_CHAT_BY_ID = 'FIND_USER_AI_CHAT_BY_ID', + DELETE_USER_AI_CHAT = 'DELETE_USER_AI_CHAT', CREATE_TABLE_FILTERS = 'CREATE_TABLE_FILTERS', FIND_TABLE_FILTERS = 'FIND_TABLE_FILTERS', diff --git a/backend/src/decorators/slug-uuid.decorator.ts b/backend/src/decorators/slug-uuid.decorator.ts index e930ed7e3..fd6e2a925 100644 --- a/backend/src/decorators/slug-uuid.decorator.ts +++ b/backend/src/decorators/slug-uuid.decorator.ts @@ -14,7 +14,8 @@ export type SlugUuidParameter = | 'apiKeyId' | 'companyId' | 'threadId' - | 'filterId'; + | 'filterId' + | 'chatId'; export const SlugUuid = createParamDecorator( (parameterName: SlugUuidParameter = 'slug', ctx: ExecutionContext): string => { const request: IRequestWithCognitoInfo = ctx.switchToHttp().getRequest(); @@ -29,7 +30,8 @@ export const SlugUuid = createParamDecorator( 'eventId', 'companyId', 'threadId', - 'filterId' + 'filterId', + 'chatId' ]; if (!availableSlagParameters.includes(parameterName)) { throw new BadRequestException(Messages.UUID_INVALID); diff --git a/backend/src/entities/ai/ai-conversation-history/ai-chat-messages/ai-chat-message.entity.ts b/backend/src/entities/ai/ai-conversation-history/ai-chat-messages/ai-chat-message.entity.ts new file mode 100644 index 000000000..f7a3ace74 --- /dev/null +++ b/backend/src/entities/ai/ai-conversation-history/ai-chat-messages/ai-chat-message.entity.ts @@ -0,0 +1,40 @@ +import { + Column, + CreateDateColumn, + Entity, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, + Relation, + UpdateDateColumn, +} from 'typeorm'; +import { MessageRole } from './message-role.enum.js'; +import { UserAiChatEntity } from '../user-ai-chat/user-ai-chat.entity.js'; + +@Entity('ai_chat_message') +export class AiChatMessageEntity { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ default: null, type: 'text' }) + message: string; + + @Column({ nullable: true, default: null, type: 'enum', enum: MessageRole }) + role: MessageRole; + + @CreateDateColumn({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' }) + created_at: Date; + + @UpdateDateColumn({ type: 'timestamp', nullable: true, default: null }) + updated_at: Date; + + @ManyToOne( + () => UserAiChatEntity, + (ai_chat) => ai_chat.messages, + ) + @JoinColumn({ name: 'ai_chat_id' }) + ai_chat: Relation; + + @Column() + ai_chat_id: string; +} diff --git a/backend/src/entities/ai/ai-conversation-history/ai-chat-messages/message-role.enum.ts b/backend/src/entities/ai/ai-conversation-history/ai-chat-messages/message-role.enum.ts new file mode 100644 index 000000000..d92dea7de --- /dev/null +++ b/backend/src/entities/ai/ai-conversation-history/ai-chat-messages/message-role.enum.ts @@ -0,0 +1,5 @@ +export enum MessageRole { + user = 'user', + ai = 'ai', + system = 'system', +} diff --git a/backend/src/entities/ai/ai-conversation-history/ai-chat-messages/repository/ai-chat-message-repository.extension.ts b/backend/src/entities/ai/ai-conversation-history/ai-chat-messages/repository/ai-chat-message-repository.extension.ts new file mode 100644 index 000000000..4516f0985 --- /dev/null +++ b/backend/src/entities/ai/ai-conversation-history/ai-chat-messages/repository/ai-chat-message-repository.extension.ts @@ -0,0 +1,19 @@ +import { IAiChatMessageRepository } from './ai-chat-message-repository.interface.js'; +import { AiChatMessageEntity } from '../ai-chat-message.entity.js'; + +export const aiChatMessageRepositoryExtension: IAiChatMessageRepository = { + async findMessagesForChat(chatId: string): Promise { + return await this.createQueryBuilder('ai_chat_message') + .where('ai_chat_message.ai_chat_id = :chatId', { chatId }) + .orderBy('ai_chat_message.created_at', 'ASC') + .getMany(); + }, + + async deleteMessagesForChat(chatId: string): Promise { + await this.createQueryBuilder() + .delete() + .from('ai_chat_message') + .where('ai_chat_id = :chatId', { chatId }) + .execute(); + }, +}; diff --git a/backend/src/entities/ai/ai-conversation-history/ai-chat-messages/repository/ai-chat-message-repository.interface.ts b/backend/src/entities/ai/ai-conversation-history/ai-chat-messages/repository/ai-chat-message-repository.interface.ts new file mode 100644 index 000000000..7286425f7 --- /dev/null +++ b/backend/src/entities/ai/ai-conversation-history/ai-chat-messages/repository/ai-chat-message-repository.interface.ts @@ -0,0 +1,6 @@ +import { AiChatMessageEntity } from '../ai-chat-message.entity.js'; + +export interface IAiChatMessageRepository { + findMessagesForChat(chatId: string): Promise; + deleteMessagesForChat(chatId: string): Promise; +} diff --git a/backend/src/entities/ai/ai-conversation-history/application/data-structures/user-ai-chat.ds.ts b/backend/src/entities/ai/ai-conversation-history/application/data-structures/user-ai-chat.ds.ts new file mode 100644 index 000000000..4b907c540 --- /dev/null +++ b/backend/src/entities/ai/ai-conversation-history/application/data-structures/user-ai-chat.ds.ts @@ -0,0 +1,13 @@ +export class FindUserAiChatsDs { + userId: string; +} + +export class FindUserAiChatByIdDs { + userId: string; + chatId: string; +} + +export class DeleteUserAiChatDs { + userId: string; + chatId: string; +} diff --git a/backend/src/entities/ai/ai-conversation-history/application/response-objects/user-ai-chat.ro.ts b/backend/src/entities/ai/ai-conversation-history/application/response-objects/user-ai-chat.ro.ts new file mode 100644 index 000000000..d527c14f9 --- /dev/null +++ b/backend/src/entities/ai/ai-conversation-history/application/response-objects/user-ai-chat.ro.ts @@ -0,0 +1,19 @@ +import { MessageRole } from '../../ai-chat-messages/message-role.enum.js'; + +export class AiChatMessageRO { + id: string; + message: string; + role: MessageRole; + created_at: Date; +} + +export class UserAiChatRO { + id: string; + name: string; + created_at: Date; + updated_at: Date; +} + +export class UserAiChatWithMessagesRO extends UserAiChatRO { + messages: AiChatMessageRO[]; +} diff --git a/backend/src/entities/ai/ai-conversation-history/application/utils/build-user-ai-chat-ro.util.ts b/backend/src/entities/ai/ai-conversation-history/application/utils/build-user-ai-chat-ro.util.ts new file mode 100644 index 000000000..34f0a901f --- /dev/null +++ b/backend/src/entities/ai/ai-conversation-history/application/utils/build-user-ai-chat-ro.util.ts @@ -0,0 +1,31 @@ +import { AiChatMessageEntity } from '../../ai-chat-messages/ai-chat-message.entity.js'; +import { UserAiChatEntity } from '../../user-ai-chat/user-ai-chat.entity.js'; +import { AiChatMessageRO, UserAiChatRO, UserAiChatWithMessagesRO } from '../response-objects/user-ai-chat.ro.js'; + +export function buildUserAiChatRO(chat: UserAiChatEntity): UserAiChatRO { + return { + id: chat.id, + name: chat.name, + created_at: chat.created_at, + updated_at: chat.updated_at, + }; +} + +export function buildAiChatMessageRO(message: AiChatMessageEntity): AiChatMessageRO { + return { + id: message.id, + message: message.message, + role: message.role, + created_at: message.created_at, + }; +} + +export function buildUserAiChatWithMessagesRO(chat: UserAiChatEntity): UserAiChatWithMessagesRO { + return { + id: chat.id, + name: chat.name, + created_at: chat.created_at, + updated_at: chat.updated_at, + messages: chat.messages?.map((message) => buildAiChatMessageRO(message)) ?? [], + }; +} diff --git a/backend/src/entities/ai/ai-conversation-history/use-cases/delete-user-ai-chat.use.case.ts b/backend/src/entities/ai/ai-conversation-history/use-cases/delete-user-ai-chat.use.case.ts new file mode 100644 index 000000000..62a7e4225 --- /dev/null +++ b/backend/src/entities/ai/ai-conversation-history/use-cases/delete-user-ai-chat.use.case.ts @@ -0,0 +1,35 @@ +import { Inject, Injectable, NotFoundException, Scope } from '@nestjs/common'; +import AbstractUseCase from '../../../../common/abstract-use.case.js'; +import { IGlobalDatabaseContext } from '../../../../common/application/global-database-context.interface.js'; +import { BaseType } from '../../../../common/data-injection.tokens.js'; +import { Messages } from '../../../../exceptions/text/messages.js'; +import { SuccessResponse } from '../../../../microservices/saas-microservice/data-structures/common-responce.ds.js'; +import { DeleteUserAiChatDs } from '../application/data-structures/user-ai-chat.ds.js'; +import { IDeleteUserAiChat } from './user-ai-chat-use-cases.interface.js'; + +@Injectable({ scope: Scope.REQUEST }) +export class DeleteUserAiChatUseCase + extends AbstractUseCase + implements IDeleteUserAiChat +{ + constructor( + @Inject(BaseType.GLOBAL_DB_CONTEXT) + protected _dbContext: IGlobalDatabaseContext, + ) { + super(); + } + + protected async implementation(inputData: DeleteUserAiChatDs): Promise { + const { userId, chatId } = inputData; + const foundChat = await this._dbContext.userAiChatRepository.findChatByIdAndUserId(chatId, userId); + + if (!foundChat) { + throw new NotFoundException(Messages.AI_CHAT_NOT_FOUND); + } + + await this._dbContext.aiChatMessageRepository.deleteMessagesForChat(chatId); + await this._dbContext.userAiChatRepository.remove(foundChat); + + return { success: true }; + } +} diff --git a/backend/src/entities/ai/ai-conversation-history/use-cases/find-user-ai-chat-by-id.use.case.ts b/backend/src/entities/ai/ai-conversation-history/use-cases/find-user-ai-chat-by-id.use.case.ts new file mode 100644 index 000000000..93d00442e --- /dev/null +++ b/backend/src/entities/ai/ai-conversation-history/use-cases/find-user-ai-chat-by-id.use.case.ts @@ -0,0 +1,33 @@ +import { Inject, Injectable, NotFoundException } from '@nestjs/common'; +import AbstractUseCase from '../../../../common/abstract-use.case.js'; +import { IGlobalDatabaseContext } from '../../../../common/application/global-database-context.interface.js'; +import { BaseType } from '../../../../common/data-injection.tokens.js'; +import { Messages } from '../../../../exceptions/text/messages.js'; +import { FindUserAiChatByIdDs } from '../application/data-structures/user-ai-chat.ds.js'; +import { UserAiChatWithMessagesRO } from '../application/response-objects/user-ai-chat.ro.js'; +import { buildUserAiChatWithMessagesRO } from '../application/utils/build-user-ai-chat-ro.util.js'; +import { IFindUserAiChatById } from './user-ai-chat-use-cases.interface.js'; + +@Injectable() +export class FindUserAiChatByIdUseCase + extends AbstractUseCase + implements IFindUserAiChatById +{ + constructor( + @Inject(BaseType.GLOBAL_DB_CONTEXT) + protected _dbContext: IGlobalDatabaseContext, + ) { + super(); + } + + protected async implementation(inputData: FindUserAiChatByIdDs): Promise { + const { userId, chatId } = inputData; + const foundChat = await this._dbContext.userAiChatRepository.findChatWithMessagesByIdAndUserId(chatId, userId); + + if (!foundChat) { + throw new NotFoundException(Messages.AI_CHAT_NOT_FOUND); + } + + return buildUserAiChatWithMessagesRO(foundChat); + } +} diff --git a/backend/src/entities/ai/ai-conversation-history/use-cases/find-user-ai-chats.use.case.ts b/backend/src/entities/ai/ai-conversation-history/use-cases/find-user-ai-chats.use.case.ts new file mode 100644 index 000000000..134e10ffd --- /dev/null +++ b/backend/src/entities/ai/ai-conversation-history/use-cases/find-user-ai-chats.use.case.ts @@ -0,0 +1,27 @@ +import { Inject, Injectable } from '@nestjs/common'; +import AbstractUseCase from '../../../../common/abstract-use.case.js'; +import { IGlobalDatabaseContext } from '../../../../common/application/global-database-context.interface.js'; +import { BaseType } from '../../../../common/data-injection.tokens.js'; +import { FindUserAiChatsDs } from '../application/data-structures/user-ai-chat.ds.js'; +import { UserAiChatRO } from '../application/response-objects/user-ai-chat.ro.js'; +import { buildUserAiChatRO } from '../application/utils/build-user-ai-chat-ro.util.js'; +import { IFindUserAiChats } from './user-ai-chat-use-cases.interface.js'; + +@Injectable() +export class FindUserAiChatsUseCase + extends AbstractUseCase + implements IFindUserAiChats +{ + constructor( + @Inject(BaseType.GLOBAL_DB_CONTEXT) + protected _dbContext: IGlobalDatabaseContext, + ) { + super(); + } + + protected async implementation(inputData: FindUserAiChatsDs): Promise { + const { userId } = inputData; + const foundChats = await this._dbContext.userAiChatRepository.findAllChatsForUser(userId); + return foundChats.map((chat) => buildUserAiChatRO(chat)); + } +} diff --git a/backend/src/entities/ai/ai-conversation-history/use-cases/user-ai-chat-use-cases.interface.ts b/backend/src/entities/ai/ai-conversation-history/use-cases/user-ai-chat-use-cases.interface.ts new file mode 100644 index 000000000..234612ae0 --- /dev/null +++ b/backend/src/entities/ai/ai-conversation-history/use-cases/user-ai-chat-use-cases.interface.ts @@ -0,0 +1,20 @@ +import { InTransactionEnum } from '../../../../enums/in-transaction.enum.js'; +import { SuccessResponse } from '../../../../microservices/saas-microservice/data-structures/common-responce.ds.js'; +import { + DeleteUserAiChatDs, + FindUserAiChatByIdDs, + FindUserAiChatsDs, +} from '../application/data-structures/user-ai-chat.ds.js'; +import { UserAiChatRO, UserAiChatWithMessagesRO } from '../application/response-objects/user-ai-chat.ro.js'; + +export interface IFindUserAiChats { + execute(inputData: FindUserAiChatsDs, inTransaction: InTransactionEnum): Promise; +} + +export interface IFindUserAiChatById { + execute(inputData: FindUserAiChatByIdDs, inTransaction: InTransactionEnum): Promise; +} + +export interface IDeleteUserAiChat { + execute(inputData: DeleteUserAiChatDs, inTransaction: InTransactionEnum): Promise; +} diff --git a/backend/src/entities/ai/ai-conversation-history/user-ai-chat.controller.ts b/backend/src/entities/ai/ai-conversation-history/user-ai-chat.controller.ts new file mode 100644 index 000000000..37b78e35c --- /dev/null +++ b/backend/src/entities/ai/ai-conversation-history/user-ai-chat.controller.ts @@ -0,0 +1,75 @@ +import { Controller, Delete, Get, Inject, Injectable, UseInterceptors } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { UseCaseType } from '../../../common/data-injection.tokens.js'; +import { SlugUuid } from '../../../decorators/slug-uuid.decorator.js'; +import { Timeout } from '../../../decorators/timeout.decorator.js'; +import { UserId } from '../../../decorators/user-id.decorator.js'; +import { InTransactionEnum } from '../../../enums/in-transaction.enum.js'; +import { SentryInterceptor } from '../../../interceptors/sentry.interceptor.js'; +import { SuccessResponse } from '../../../microservices/saas-microservice/data-structures/common-responce.ds.js'; +import { + DeleteUserAiChatDs, + FindUserAiChatByIdDs, + FindUserAiChatsDs, +} from './application/data-structures/user-ai-chat.ds.js'; +import { UserAiChatRO, UserAiChatWithMessagesRO } from './application/response-objects/user-ai-chat.ro.js'; +import { + IDeleteUserAiChat, + IFindUserAiChatById, + IFindUserAiChats, +} from './use-cases/user-ai-chat-use-cases.interface.js'; + +@UseInterceptors(SentryInterceptor) +@Timeout() +@Controller('ai/chats') +@ApiBearerAuth() +@ApiTags('AI Chats') +@Injectable() +export class UserAiChatController { + constructor( + @Inject(UseCaseType.FIND_USER_AI_CHATS) + private readonly findUserAiChatsUseCase: IFindUserAiChats, + @Inject(UseCaseType.FIND_USER_AI_CHAT_BY_ID) + private readonly findUserAiChatByIdUseCase: IFindUserAiChatById, + @Inject(UseCaseType.DELETE_USER_AI_CHAT) + private readonly deleteUserAiChatUseCase: IDeleteUserAiChat, + ) {} + + @ApiOperation({ summary: 'Get all AI chats for current user' }) + @ApiResponse({ + status: 200, + description: 'Returns list of AI chats.', + type: [UserAiChatRO], + }) + @Get() + async findAllChats(@UserId() userId: string): Promise { + const inputData: FindUserAiChatsDs = { userId }; + return await this.findUserAiChatsUseCase.execute(inputData, InTransactionEnum.OFF); + } + + @ApiOperation({ summary: 'Get AI chat by ID with all messages' }) + @ApiResponse({ + status: 200, + description: 'Returns AI chat with messages.', + type: UserAiChatWithMessagesRO, + }) + @ApiParam({ name: 'chatId', required: true, type: String }) + @Get(':chatId') + async findChatById(@UserId() userId: string, @SlugUuid('chatId') chatId: string): Promise { + const inputData: FindUserAiChatByIdDs = { userId, chatId }; + return await this.findUserAiChatByIdUseCase.execute(inputData, InTransactionEnum.OFF); + } + + @ApiOperation({ summary: 'Delete AI chat by ID' }) + @ApiResponse({ + status: 200, + description: 'AI chat deleted successfully.', + type: SuccessResponse, + }) + @ApiParam({ name: 'chatId', required: true, type: String }) + @Delete(':chatId') + async deleteChat(@UserId() userId: string, @SlugUuid('chatId') chatId: string): Promise { + const inputData: DeleteUserAiChatDs = { userId, chatId }; + return await this.deleteUserAiChatUseCase.execute(inputData, InTransactionEnum.ON); + } +} diff --git a/backend/src/entities/ai/ai-conversation-history/user-ai-chat/repository/user-ai-chat-repository.extension.ts b/backend/src/entities/ai/ai-conversation-history/user-ai-chat/repository/user-ai-chat-repository.extension.ts new file mode 100644 index 000000000..ca287c764 --- /dev/null +++ b/backend/src/entities/ai/ai-conversation-history/user-ai-chat/repository/user-ai-chat-repository.extension.ts @@ -0,0 +1,27 @@ +import { IUserAiChatRepository } from './user-ai-chat-repository.interface.js'; +import { UserAiChatEntity } from '../user-ai-chat.entity.js'; + +export const userAiChatRepositoryExtension: IUserAiChatRepository = { + async findAllChatsForUser(userId: string): Promise { + return await this.createQueryBuilder('user_ai_chat') + .where('user_ai_chat.user_id = :userId', { userId }) + .orderBy('user_ai_chat.created_at', 'DESC') + .getMany(); + }, + + async findChatByIdAndUserId(chatId: string, userId: string): Promise { + return await this.createQueryBuilder('user_ai_chat') + .where('user_ai_chat.id = :chatId', { chatId }) + .andWhere('user_ai_chat.user_id = :userId', { userId }) + .getOne(); + }, + + async findChatWithMessagesByIdAndUserId(chatId: string, userId: string): Promise { + return await this.createQueryBuilder('user_ai_chat') + .leftJoinAndSelect('user_ai_chat.messages', 'messages') + .where('user_ai_chat.id = :chatId', { chatId }) + .andWhere('user_ai_chat.user_id = :userId', { userId }) + .orderBy('messages.created_at', 'ASC') + .getOne(); + }, +}; diff --git a/backend/src/entities/ai/ai-conversation-history/user-ai-chat/repository/user-ai-chat-repository.interface.ts b/backend/src/entities/ai/ai-conversation-history/user-ai-chat/repository/user-ai-chat-repository.interface.ts new file mode 100644 index 000000000..2cc8d687c --- /dev/null +++ b/backend/src/entities/ai/ai-conversation-history/user-ai-chat/repository/user-ai-chat-repository.interface.ts @@ -0,0 +1,7 @@ +import { UserAiChatEntity } from '../user-ai-chat.entity.js'; + +export interface IUserAiChatRepository { + findAllChatsForUser(userId: string): Promise; + findChatByIdAndUserId(chatId: string, userId: string): Promise; + findChatWithMessagesByIdAndUserId(chatId: string, userId: string): Promise; +} diff --git a/backend/src/entities/ai/ai-conversation-history/user-ai-chat/user-ai-chat.entity.ts b/backend/src/entities/ai/ai-conversation-history/user-ai-chat/user-ai-chat.entity.ts new file mode 100644 index 000000000..abd5213c3 --- /dev/null +++ b/backend/src/entities/ai/ai-conversation-history/user-ai-chat/user-ai-chat.entity.ts @@ -0,0 +1,44 @@ +import { + Column, + CreateDateColumn, + Entity, + JoinColumn, + ManyToOne, + OneToMany, + PrimaryGeneratedColumn, + Relation, + UpdateDateColumn, +} from 'typeorm'; +import { UserEntity } from '../../../user/user.entity.js'; +import { AiChatMessageEntity } from '../ai-chat-messages/ai-chat-message.entity.js'; + +@Entity('user_ai_chat') +export class UserAiChatEntity { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ default: null }) + name: string; + + @CreateDateColumn({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' }) + created_at: Date; + + @UpdateDateColumn({ type: 'timestamp', nullable: true, default: null }) + updated_at: Date; + + @ManyToOne( + () => UserEntity, + (user) => user.ai_chats, + ) + @JoinColumn({ name: 'user_id' }) + user: Relation; + + @OneToMany( + () => AiChatMessageEntity, + (message) => message.ai_chat, + ) + messages: Relation[]; + + @Column() + user_id: string; +} diff --git a/backend/src/entities/ai/ai.module.ts b/backend/src/entities/ai/ai.module.ts index 9938af009..ff7939aa6 100644 --- a/backend/src/entities/ai/ai.module.ts +++ b/backend/src/entities/ai/ai.module.ts @@ -10,10 +10,16 @@ import { RequestAISettingsAndWidgetsCreationUseCase } from './use-cases/request- import { RequestInfoFromTableWithAIUseCaseV5 } from './use-cases/request-info-from-table-with-ai-v5.use.case.js'; import { RequestInfoFromTableWithAIUseCaseV6 } from './use-cases/request-info-from-table-with-ai-v6.use.case.js'; import { UserAIRequestsControllerV2 } from './user-ai-requests-v2.controller.js'; +import { UserAiChatController } from './ai-conversation-history/user-ai-chat.controller.js'; +import { FindUserAiChatsUseCase } from './ai-conversation-history/use-cases/find-user-ai-chats.use.case.js'; +import { FindUserAiChatByIdUseCase } from './ai-conversation-history/use-cases/find-user-ai-chat-by-id.use.case.js'; +import { DeleteUserAiChatUseCase } from './ai-conversation-history/use-cases/delete-user-ai-chat.use.case.js'; +import { UserAiChatEntity } from './ai-conversation-history/user-ai-chat/user-ai-chat.entity.js'; +import { AiChatMessageEntity } from './ai-conversation-history/ai-chat-messages/ai-chat-message.entity.js'; @Global() @Module({ - imports: [TypeOrmModule.forFeature([UserEntity, LogOutEntity])], + imports: [TypeOrmModule.forFeature([UserEntity, LogOutEntity, UserAiChatEntity, AiChatMessageEntity])], providers: [ { provide: BaseType.GLOBAL_DB_CONTEXT, @@ -31,10 +37,22 @@ import { UserAIRequestsControllerV2 } from './user-ai-requests-v2.controller.js' provide: UseCaseType.REQUEST_AI_SETTINGS_AND_WIDGETS_CREATION, useClass: RequestAISettingsAndWidgetsCreationUseCase, }, + { + provide: UseCaseType.FIND_USER_AI_CHATS, + useClass: FindUserAiChatsUseCase, + }, + { + provide: UseCaseType.FIND_USER_AI_CHAT_BY_ID, + useClass: FindUserAiChatByIdUseCase, + }, + { + provide: UseCaseType.DELETE_USER_AI_CHAT, + useClass: DeleteUserAiChatUseCase, + }, AiService, ], exports: [AiService], - controllers: [UserAIRequestsControllerV2], + controllers: [UserAIRequestsControllerV2, UserAiChatController], }) export class AIModule implements NestModule { public configure(consumer: MiddlewareConsumer): any { @@ -44,6 +62,9 @@ export class AIModule implements NestModule { { path: '/ai/v2/request/:connectionId', method: RequestMethod.POST }, { path: '/ai/v3/request/:connectionId', method: RequestMethod.POST }, { path: '/ai/v2/setup/:connectionId', method: RequestMethod.GET }, + { path: '/ai/chats', method: RequestMethod.GET }, + { path: '/ai/chats/:chatId', method: RequestMethod.GET }, + { path: '/ai/chats/:chatId', method: RequestMethod.DELETE }, ); } } diff --git a/backend/src/entities/user/user.entity.ts b/backend/src/entities/user/user.entity.ts index a829b6f70..ccc15bb07 100644 --- a/backend/src/entities/user/user.entity.ts +++ b/backend/src/entities/user/user.entity.ts @@ -1,17 +1,17 @@ import { ConnectionEntity } from '../connection/connection.entity.js'; import { - Entity, - Column, - JoinTable, - ManyToMany, - OneToMany, - OneToOne, - PrimaryGeneratedColumn, - BeforeInsert, - Relation, - BeforeUpdate, - AfterLoad, - ManyToOne, + Entity, + Column, + JoinTable, + ManyToMany, + OneToMany, + OneToOne, + PrimaryGeneratedColumn, + BeforeInsert, + Relation, + BeforeUpdate, + AfterLoad, + ManyToOne, } from 'typeorm'; import { GroupEntity } from '../group/group.entity.js'; import { UserActionEntity } from '../user-actions/user-action.entity.js'; @@ -29,134 +29,184 @@ import { AiResponsesToUserEntity } from '../ai/ai-data-entities/ai-reponses-to-u import { SignInAuditEntity } from '../user-sign-in-audit/sign-in-audit.entity.js'; import { SecretAccessLogEntity } from '../secret-access-log/secret-access-log.entity.js'; import { PersonalTableSettingsEntity } from '../table-settings/personal-table-settings/personal-table-settings.entity.js'; +import { UserAiChatEntity } from '../ai/ai-conversation-history/user-ai-chat/user-ai-chat.entity.js'; @Entity('user') export class UserEntity { - @PrimaryGeneratedColumn('uuid') - id: string; - - @Column({ default: null }) - email: string; - - @Column({ default: null }) - password: string; - - @Column({ default: null }) - name: string; - - @Column({ default: false, type: 'boolean' }) - suspended: boolean; - - @Column({ default: false, type: 'boolean' }) - isDemoAccount: boolean; - - @BeforeInsert() - async hashPassword() { - if (this.password) { - this.password = await Encryptor.hashUserPassword(this.password); - } - this.emailToLowerCase(); - } - - @BeforeUpdate() - encryptOtpSecretKey() { - if (this.isOTPEnabled && this.otpSecretKey) { - this.otpSecretKey = Encryptor.encryptData(this.otpSecretKey); - } - this.emailToLowerCase(); - } - - @AfterLoad() - decryptOtpSecretKey() { - if (this.isOTPEnabled && this.otpSecretKey) { - this.otpSecretKey = Encryptor.decryptData(this.otpSecretKey); - } - this.emailToLowerCase(); - } - - @Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' }) - createdAt: Date; - - @Column({ default: null }) - gclid: string; - - @Column({ default: false, type: 'boolean' }) - isOTPEnabled: boolean; - - @Column({ default: null }) - otpSecretKey: string; - - @OneToMany((_) => ConnectionEntity, (connection) => connection.author) - @JoinTable() - connections: Relation[]; - - @ManyToOne((_) => CompanyInfoEntity, (company) => company.users, { onDelete: 'SET NULL' }) - @JoinTable() - company: Relation; - - @ManyToMany((_) => GroupEntity, (group) => group.users) - @JoinTable() - groups: Relation[]; - - @OneToOne((_) => UserActionEntity, (user_action) => user_action.user) - user_action: Relation; - - @OneToOne((_) => EmailVerificationEntity, (email_verification) => email_verification.user) - email_verification: Relation; - - @OneToOne((_) => PasswordResetEntity, (password_reset) => password_reset.user) - password_reset: Relation; - - @OneToOne((_) => EmailChangeEntity, (email_change) => email_change.user) - email_change: Relation; - - @OneToOne((_) => UserInvitationEntity, (user_invitation) => user_invitation.user) - user_invitation: Relation; - - @OneToOne((_) => GitHubUserIdentifierEntity, (github_user_identifier) => github_user_identifier.user) - github_user_identifier: Relation; - - @OneToMany((_) => UserApiKeyEntity, (api_key) => api_key.user) - api_keys: Relation[]; - - @OneToMany((_) => AiResponsesToUserEntity, (response) => response.user) - ai_responses: Relation[]; - - @OneToMany((_) => SignInAuditEntity, (signInAudit) => signInAudit.user) - signInAudits: Relation[]; - - @OneToMany((_) => SecretAccessLogEntity, (secretAccessLog) => secretAccessLog.user) - secretAccessLogs: Relation[]; - - @OneToMany((_) => PersonalTableSettingsEntity, (personal_table_settings) => personal_table_settings.connection) - personal_table_settings: Relation[]; - - @Column({ default: false, type: 'boolean' }) - isActive: boolean; - - @Column('enum', { - nullable: false, - enum: UserRoleEnum, - default: UserRoleEnum.USER, - }) - role: UserRoleEnum; - - @Column('enum', { - nullable: true, - enum: ExternalRegistrationProviderEnum, - default: null, - }) - externalRegistrationProvider: ExternalRegistrationProviderEnum; - - @Column({ default: null }) - samlNameId: string; - - @Column({ default: true, type: 'boolean' }) - showTestConnections: boolean; - - private emailToLowerCase() { - if (this.email) { - this.email = this.email.toLowerCase(); - } - } + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ default: null }) + email: string; + + @Column({ default: null }) + password: string; + + @Column({ default: null }) + name: string; + + @Column({ default: false, type: 'boolean' }) + suspended: boolean; + + @Column({ default: false, type: 'boolean' }) + isDemoAccount: boolean; + + @BeforeInsert() + async hashPassword() { + if (this.password) { + this.password = await Encryptor.hashUserPassword(this.password); + } + this.emailToLowerCase(); + } + + @BeforeUpdate() + encryptOtpSecretKey() { + if (this.isOTPEnabled && this.otpSecretKey) { + this.otpSecretKey = Encryptor.encryptData(this.otpSecretKey); + } + this.emailToLowerCase(); + } + + @AfterLoad() + decryptOtpSecretKey() { + if (this.isOTPEnabled && this.otpSecretKey) { + this.otpSecretKey = Encryptor.decryptData(this.otpSecretKey); + } + this.emailToLowerCase(); + } + + @Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' }) + createdAt: Date; + + @Column({ default: null }) + gclid: string; + + @Column({ default: false, type: 'boolean' }) + isOTPEnabled: boolean; + + @Column({ default: null }) + otpSecretKey: string; + + @OneToMany( + (_) => ConnectionEntity, + (connection) => connection.author, + ) + @JoinTable() + connections: Relation[]; + + @ManyToOne( + (_) => CompanyInfoEntity, + (company) => company.users, + { onDelete: 'SET NULL' }, + ) + @JoinTable() + company: Relation; + + @ManyToMany( + (_) => GroupEntity, + (group) => group.users, + ) + @JoinTable() + groups: Relation[]; + + @OneToOne( + (_) => UserActionEntity, + (user_action) => user_action.user, + ) + user_action: Relation; + + @OneToOne( + (_) => EmailVerificationEntity, + (email_verification) => email_verification.user, + ) + email_verification: Relation; + + @OneToOne( + (_) => PasswordResetEntity, + (password_reset) => password_reset.user, + ) + password_reset: Relation; + + @OneToOne( + (_) => EmailChangeEntity, + (email_change) => email_change.user, + ) + email_change: Relation; + + @OneToOne( + (_) => UserInvitationEntity, + (user_invitation) => user_invitation.user, + ) + user_invitation: Relation; + + @OneToOne( + (_) => GitHubUserIdentifierEntity, + (github_user_identifier) => github_user_identifier.user, + ) + github_user_identifier: Relation; + + @OneToMany( + (_) => UserApiKeyEntity, + (api_key) => api_key.user, + ) + api_keys: Relation[]; + + @OneToMany( + (_) => AiResponsesToUserEntity, + (response) => response.user, + ) + ai_responses: Relation[]; + + @OneToMany( + (_) => SignInAuditEntity, + (signInAudit) => signInAudit.user, + ) + signInAudits: Relation[]; + + @OneToMany( + (_) => SecretAccessLogEntity, + (secretAccessLog) => secretAccessLog.user, + ) + secretAccessLogs: Relation[]; + + @OneToMany( + (_) => PersonalTableSettingsEntity, + (personal_table_settings) => personal_table_settings.connection, + ) + personal_table_settings: Relation[]; + + @OneToMany( + (_) => UserAiChatEntity, + (ai_chat) => ai_chat.user, + ) + ai_chats: Relation[]; + + @Column({ default: false, type: 'boolean' }) + isActive: boolean; + + @Column('enum', { + nullable: false, + enum: UserRoleEnum, + default: UserRoleEnum.USER, + }) + role: UserRoleEnum; + + @Column('enum', { + nullable: true, + enum: ExternalRegistrationProviderEnum, + default: null, + }) + externalRegistrationProvider: ExternalRegistrationProviderEnum; + + @Column({ default: null }) + samlNameId: string; + + @Column({ default: true, type: 'boolean' }) + showTestConnections: boolean; + + private emailToLowerCase() { + if (this.email) { + this.email = this.email.toLowerCase(); + } + } } diff --git a/backend/src/exceptions/text/messages.ts b/backend/src/exceptions/text/messages.ts index 9184af32c..e5e568447 100644 --- a/backend/src/exceptions/text/messages.ts +++ b/backend/src/exceptions/text/messages.ts @@ -16,6 +16,7 @@ export const Messages = { API_KEY_SUSPENDED: 'API key is suspended', AI_REQUESTS_NOT_ALLOWED: 'AI requests are not allowed for this connection', AI_THREAD_NOT_FOUND: 'Thread with specified parameters not found', + AI_CHAT_NOT_FOUND: 'AI chat with specified parameters not found', ACCOUNT_SUSPENDED: 'Your account has been suspended. Please reach out to your company administrator for assistance or contact our support team for further help', ACCESS_LEVEL_INVALID: 'Access level is invalid', diff --git a/backend/src/migrations/1769759553633-AddChatWithAiAndChatMessageEntities.ts b/backend/src/migrations/1769759553633-AddChatWithAiAndChatMessageEntities.ts new file mode 100644 index 000000000..047c51871 --- /dev/null +++ b/backend/src/migrations/1769759553633-AddChatWithAiAndChatMessageEntities.ts @@ -0,0 +1,29 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddChatWithAiAndChatMessageEntities1769759553633 implements MigrationInterface { + name = 'AddChatWithAiAndChatMessageEntities1769759553633'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TYPE "public"."ai_chat_message_role_enum" AS ENUM('user', 'ai', 'system')`); + await queryRunner.query( + `CREATE TABLE "ai_chat_message" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "message" text, "role" "public"."ai_chat_message_role_enum", "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP DEFAULT now(), "ai_chat_id" uuid NOT NULL, CONSTRAINT "PK_55019f66ea41b836f50c5aaf2b3" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE TABLE "user_ai_chat" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" character varying, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP DEFAULT now(), "user_id" uuid NOT NULL, CONSTRAINT "PK_6943806be8a75f41c5a7dc6a18d" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `ALTER TABLE "ai_chat_message" ADD CONSTRAINT "FK_03bc49058afd5262d6a503bf123" FOREIGN KEY ("ai_chat_id") REFERENCES "user_ai_chat"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "user_ai_chat" ADD CONSTRAINT "FK_0f95dbd767d42e637345636cb5d" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "user_ai_chat" DROP CONSTRAINT "FK_0f95dbd767d42e637345636cb5d"`); + await queryRunner.query(`ALTER TABLE "ai_chat_message" DROP CONSTRAINT "FK_03bc49058afd5262d6a503bf123"`); + await queryRunner.query(`DROP TABLE "user_ai_chat"`); + await queryRunner.query(`DROP TABLE "ai_chat_message"`); + await queryRunner.query(`DROP TYPE "public"."ai_chat_message_role_enum"`); + } +} From ba204a0376b48b9c61f72782ba0d81fe12783119 Mon Sep 17 00:00:00 2001 From: Artem Niehrieiev Date: Fri, 30 Jan 2026 10:14:36 +0000 Subject: [PATCH 2/3] feat: implement version 4 of AI table request and add message saving functionality --- backend/src/common/data-injection.tokens.ts | 1 + .../ai-chat-message-repository.extension.ts | 10 + .../ai-chat-message-repository.interface.ts | 2 + .../user-ai-chat-repository.extension.ts | 8 + .../user-ai-chat-repository.interface.ts | 1 + backend/src/entities/ai/ai.module.ts | 6 + ...est-info-from-table-with-ai-v7.use.case.ts | 341 ++++++++++++++++++ .../ai/user-ai-requests-v2.controller.ts | 44 +++ 8 files changed, 413 insertions(+) create mode 100644 backend/src/entities/ai/use-cases/request-info-from-table-with-ai-v7.use.case.ts diff --git a/backend/src/common/data-injection.tokens.ts b/backend/src/common/data-injection.tokens.ts index dea9caaf7..93f9e9b67 100644 --- a/backend/src/common/data-injection.tokens.ts +++ b/backend/src/common/data-injection.tokens.ts @@ -159,6 +159,7 @@ export enum UseCaseType { REQUEST_INFO_FROM_TABLE_WITH_AI_V2 = 'REQUEST_INFO_FROM_TABLE_WITH_AI_V2', REQUEST_INFO_FROM_TABLE_WITH_AI_V3 = 'REQUEST_INFO_FROM_TABLE_WITH_AI_V3', + REQUEST_INFO_FROM_TABLE_WITH_AI_V4 = 'REQUEST_INFO_FROM_TABLE_WITH_AI_V4', REQUEST_AI_SETTINGS_AND_WIDGETS_CREATION = 'REQUEST_AI_SETTINGS_AND_WIDGETS_CREATION', FIND_USER_AI_CHATS = 'FIND_USER_AI_CHATS', FIND_USER_AI_CHAT_BY_ID = 'FIND_USER_AI_CHAT_BY_ID', diff --git a/backend/src/entities/ai/ai-conversation-history/ai-chat-messages/repository/ai-chat-message-repository.extension.ts b/backend/src/entities/ai/ai-conversation-history/ai-chat-messages/repository/ai-chat-message-repository.extension.ts index 4516f0985..77208d3c0 100644 --- a/backend/src/entities/ai/ai-conversation-history/ai-chat-messages/repository/ai-chat-message-repository.extension.ts +++ b/backend/src/entities/ai/ai-conversation-history/ai-chat-messages/repository/ai-chat-message-repository.extension.ts @@ -1,5 +1,6 @@ import { IAiChatMessageRepository } from './ai-chat-message-repository.interface.js'; import { AiChatMessageEntity } from '../ai-chat-message.entity.js'; +import { MessageRole } from '../message-role.enum.js'; export const aiChatMessageRepositoryExtension: IAiChatMessageRepository = { async findMessagesForChat(chatId: string): Promise { @@ -16,4 +17,13 @@ export const aiChatMessageRepositoryExtension: IAiChatMessageRepository = { .where('ai_chat_id = :chatId', { chatId }) .execute(); }, + + async saveMessage(chatId: string, message: string, role: MessageRole): Promise { + const newMessage = this.create({ + ai_chat_id: chatId, + message, + role, + }); + return await this.save(newMessage); + }, }; diff --git a/backend/src/entities/ai/ai-conversation-history/ai-chat-messages/repository/ai-chat-message-repository.interface.ts b/backend/src/entities/ai/ai-conversation-history/ai-chat-messages/repository/ai-chat-message-repository.interface.ts index 7286425f7..27911caa3 100644 --- a/backend/src/entities/ai/ai-conversation-history/ai-chat-messages/repository/ai-chat-message-repository.interface.ts +++ b/backend/src/entities/ai/ai-conversation-history/ai-chat-messages/repository/ai-chat-message-repository.interface.ts @@ -1,6 +1,8 @@ import { AiChatMessageEntity } from '../ai-chat-message.entity.js'; +import { MessageRole } from '../message-role.enum.js'; export interface IAiChatMessageRepository { findMessagesForChat(chatId: string): Promise; deleteMessagesForChat(chatId: string): Promise; + saveMessage(chatId: string, message: string, role: MessageRole): Promise; } diff --git a/backend/src/entities/ai/ai-conversation-history/user-ai-chat/repository/user-ai-chat-repository.extension.ts b/backend/src/entities/ai/ai-conversation-history/user-ai-chat/repository/user-ai-chat-repository.extension.ts index ca287c764..28271768c 100644 --- a/backend/src/entities/ai/ai-conversation-history/user-ai-chat/repository/user-ai-chat-repository.extension.ts +++ b/backend/src/entities/ai/ai-conversation-history/user-ai-chat/repository/user-ai-chat-repository.extension.ts @@ -24,4 +24,12 @@ export const userAiChatRepositoryExtension: IUserAiChatRepository = { .orderBy('messages.created_at', 'ASC') .getOne(); }, + + async createChatForUser(userId: string, name?: string): Promise { + const newChat = this.create({ + user_id: userId, + name: name || null, + }); + return await this.save(newChat); + }, }; diff --git a/backend/src/entities/ai/ai-conversation-history/user-ai-chat/repository/user-ai-chat-repository.interface.ts b/backend/src/entities/ai/ai-conversation-history/user-ai-chat/repository/user-ai-chat-repository.interface.ts index 2cc8d687c..badc9c92d 100644 --- a/backend/src/entities/ai/ai-conversation-history/user-ai-chat/repository/user-ai-chat-repository.interface.ts +++ b/backend/src/entities/ai/ai-conversation-history/user-ai-chat/repository/user-ai-chat-repository.interface.ts @@ -4,4 +4,5 @@ export interface IUserAiChatRepository { findAllChatsForUser(userId: string): Promise; findChatByIdAndUserId(chatId: string, userId: string): Promise; findChatWithMessagesByIdAndUserId(chatId: string, userId: string): Promise; + createChatForUser(userId: string, name?: string): Promise; } diff --git a/backend/src/entities/ai/ai.module.ts b/backend/src/entities/ai/ai.module.ts index ff7939aa6..97e6b1bd7 100644 --- a/backend/src/entities/ai/ai.module.ts +++ b/backend/src/entities/ai/ai.module.ts @@ -9,6 +9,7 @@ import { AiService } from './ai.service.js'; import { RequestAISettingsAndWidgetsCreationUseCase } from './use-cases/request-ai-settings-and-widgets-creation.use.case.js'; import { RequestInfoFromTableWithAIUseCaseV5 } from './use-cases/request-info-from-table-with-ai-v5.use.case.js'; import { RequestInfoFromTableWithAIUseCaseV6 } from './use-cases/request-info-from-table-with-ai-v6.use.case.js'; +import { RequestInfoFromTableWithAIUseCaseV7 } from './use-cases/request-info-from-table-with-ai-v7.use.case.js'; import { UserAIRequestsControllerV2 } from './user-ai-requests-v2.controller.js'; import { UserAiChatController } from './ai-conversation-history/user-ai-chat.controller.js'; import { FindUserAiChatsUseCase } from './ai-conversation-history/use-cases/find-user-ai-chats.use.case.js'; @@ -33,6 +34,10 @@ import { AiChatMessageEntity } from './ai-conversation-history/ai-chat-messages/ provide: UseCaseType.REQUEST_INFO_FROM_TABLE_WITH_AI_V3, useClass: RequestInfoFromTableWithAIUseCaseV6, }, + { + provide: UseCaseType.REQUEST_INFO_FROM_TABLE_WITH_AI_V4, + useClass: RequestInfoFromTableWithAIUseCaseV7, + }, { provide: UseCaseType.REQUEST_AI_SETTINGS_AND_WIDGETS_CREATION, useClass: RequestAISettingsAndWidgetsCreationUseCase, @@ -61,6 +66,7 @@ export class AIModule implements NestModule { .forRoutes( { path: '/ai/v2/request/:connectionId', method: RequestMethod.POST }, { path: '/ai/v3/request/:connectionId', method: RequestMethod.POST }, + { path: '/ai/v4/request/:connectionId', method: RequestMethod.POST }, { path: '/ai/v2/setup/:connectionId', method: RequestMethod.GET }, { path: '/ai/chats', method: RequestMethod.GET }, { path: '/ai/chats/:chatId', method: RequestMethod.GET }, diff --git a/backend/src/entities/ai/use-cases/request-info-from-table-with-ai-v7.use.case.ts b/backend/src/entities/ai/use-cases/request-info-from-table-with-ai-v7.use.case.ts new file mode 100644 index 000000000..78cf46cc2 --- /dev/null +++ b/backend/src/entities/ai/use-cases/request-info-from-table-with-ai-v7.use.case.ts @@ -0,0 +1,341 @@ +import { BadRequestException, Inject, Injectable, NotFoundException, Scope } from '@nestjs/common'; +import { getDataAccessObject } from '@rocketadmin/shared-code/dist/src/data-access-layer/shared/create-data-access-object.js'; +import { ConnectionTypesEnum } from '@rocketadmin/shared-code/dist/src/shared/enums/connection-types-enum.js'; +import { IDataAccessObject } from '@rocketadmin/shared-code/dist/src/shared/interfaces/data-access-object.interface.js'; +import { IDataAccessObjectAgent } from '@rocketadmin/shared-code/dist/src/shared/interfaces/data-access-object-agent.interface.js'; +import Sentry from '@sentry/minimal'; +import { Response } from 'express'; +import { BaseMessage } from '@langchain/core/messages'; +import AbstractUseCase from '../../../common/abstract-use.case.js'; +import { IGlobalDatabaseContext } from '../../../common/application/global-database-context.interface.js'; +import { BaseType } from '../../../common/data-injection.tokens.js'; +import { Messages } from '../../../exceptions/text/messages.js'; +import { slackPostMessage } from '../../../helpers/index.js'; +import { isConnectionTypeAgent } from '../../../helpers/is-connection-entity-agent.js'; +import { ConnectionEntity } from '../../connection/connection.entity.js'; +import { IRequestInfoFromTableV2 } from '../ai-use-cases.interface.js'; +import { RequestInfoFromTableDSV2 } from '../application/data-structures/request-info-from-table.ds.js'; +import { + AICoreService, + AIToolDefinition, + AIToolCall, + MessageBuilder, + createDatabaseTools, + createDatabaseQuerySystemPrompt, + isValidSQLQuery, + isValidMongoDbCommand, + wrapQueryWithLimit, + AIProviderType, +} from '../../../ai-core/index.js'; +import { UserAiChatEntity } from '../ai-conversation-history/user-ai-chat/user-ai-chat.entity.js'; +import { MessageRole } from '../ai-conversation-history/ai-chat-messages/message-role.enum.js'; + +@Injectable({ scope: Scope.REQUEST }) +export class RequestInfoFromTableWithAIUseCaseV7 + extends AbstractUseCase + implements IRequestInfoFromTableV2 +{ + private readonly maxDepth: number = 10; + private readonly aiProvider: AIProviderType = AIProviderType.BEDROCK; + + constructor( + @Inject(BaseType.GLOBAL_DB_CONTEXT) + protected _dbContext: IGlobalDatabaseContext, + private readonly aiCoreService: AICoreService, + ) { + super(); + } + + public async implementation(inputData: RequestInfoFromTableDSV2): Promise { + const { connectionId, tableName, user_message, master_password, user_id, response, ai_thread_id } = inputData; + + this.setupResponseHeaders(response); + + const { foundConnection, dataAccessObject, isMongoDb, userEmail } = await this.setupConnection( + connectionId, + master_password, + user_id, + ); + + const tools = createDatabaseTools(isMongoDb); + + const systemPrompt = createDatabaseQuerySystemPrompt( + tableName, + foundConnection.type as ConnectionTypesEnum, + foundConnection.schema, + ); + + let chatIdForHeader: string | null = null; + let foundUserAiChat: UserAiChatEntity | null = null; + + if (ai_thread_id) { + foundUserAiChat = await this._dbContext.userAiChatRepository.findChatByIdAndUserId(ai_thread_id, user_id); + if (foundUserAiChat) { + chatIdForHeader = foundUserAiChat.id; + } + } + + if (!foundUserAiChat) { + foundUserAiChat = await this._dbContext.userAiChatRepository.createChatForUser(user_id); + chatIdForHeader = foundUserAiChat.id; + } + + if (chatIdForHeader) { + response.setHeader('X-AI-Thread-ID', chatIdForHeader); + } + + await this._dbContext.aiChatMessageRepository.saveMessage(foundUserAiChat.id, user_message, MessageRole.user); + + const messages = new MessageBuilder().system(systemPrompt).human(user_message).build(); + + try { + const { accumulatedResponse } = await this.processWithToolLoop( + messages, + tools, + response, + dataAccessObject, + tableName, + userEmail, + foundConnection, + ); + + if (accumulatedResponse) { + await this._dbContext.aiChatMessageRepository.saveMessage( + foundUserAiChat.id, + accumulatedResponse, + MessageRole.ai, + ); + } + + response.end(); + } catch (error) { + await slackPostMessage(error?.message); + Sentry.captureException(error); + if (!response.headersSent) { + response.status(500).send({ error: 'An error occurred while processing your request.' }); + } + } + } + + private async processWithToolLoop( + messages: BaseMessage[], + tools: AIToolDefinition[], + response: Response, + dataAccessObject: IDataAccessObject | IDataAccessObjectAgent, + inputTableName: string, + userEmail: string, + foundConnection: ConnectionEntity, + ): Promise<{ lastResponseId: string | null; accumulatedResponse: string }> { + let currentMessages = [...messages]; + let lastResponseId: string | null = null; + let depth = 0; + let totalAccumulatedResponse = ''; + + while (depth < this.maxDepth) { + try { + const stream = await this.aiCoreService.streamChatWithToolsAndProvider(this.aiProvider, currentMessages, tools); + + let pendingToolCalls: AIToolCall[] = []; + let accumulatedContent = ''; + + for await (const chunk of stream) { + if (chunk.type === 'text' && chunk.content) { + response.write(chunk.content); + accumulatedContent += chunk.content; + totalAccumulatedResponse += chunk.content; + } + + if (chunk.type === 'tool_call' && chunk.toolCall) { + pendingToolCalls.push(chunk.toolCall); + } + + if (chunk.responseId) { + lastResponseId = chunk.responseId; + } + } + + if (pendingToolCalls.length === 0) { + break; + } + + const toolResults = await this.executeToolCalls( + pendingToolCalls, + dataAccessObject, + inputTableName, + userEmail, + foundConnection, + ); + + const continuationBuilder = MessageBuilder.fromMessages(currentMessages); + continuationBuilder.ai(accumulatedContent, pendingToolCalls); + for (const result of toolResults) { + continuationBuilder.toolResult(result.toolCallId, result.result); + } + currentMessages = continuationBuilder.build(); + + depth++; + } catch (loopError) { + throw loopError; + } + } + + if (depth >= this.maxDepth) { + const maxDepthMessage = + '\n\nYour question is too complex to process at this time. Please try simplifying it or breaking it down into smaller parts.'; + response.write(maxDepthMessage); + totalAccumulatedResponse += maxDepthMessage; + } + + return { lastResponseId, accumulatedResponse: totalAccumulatedResponse }; + } + + private async executeToolCalls( + toolCalls: AIToolCall[], + dataAccessObject: IDataAccessObject | IDataAccessObjectAgent, + inputTableName: string, + userEmail: string, + foundConnection: ConnectionEntity, + ): Promise> { + const results: Array<{ toolCallId: string; result: string }> = []; + + for (const toolCall of toolCalls) { + let result: string; + + try { + switch (toolCall.name) { + case 'getTableStructure': { + const tableName = (toolCall.arguments.tableName as string) || inputTableName; + const structureInfo = await this.getTableStructureInfo( + dataAccessObject, + tableName, + userEmail, + foundConnection, + ); + result = JSON.stringify(structureInfo); + break; + } + + case 'executeRawSql': { + const query = toolCall.arguments.query as string; + if (!query) { + throw new Error('Missing required function argument "query"'); + } + if (!isValidSQLQuery(query)) { + throw new Error( + 'Invalid SQL query. Please ensure it is a read-only SELECT statement without any forbidden keywords.', + ); + } + const wrappedQuery = wrapQueryWithLimit(query, foundConnection.type as ConnectionTypesEnum); + const queryResult = await dataAccessObject.executeRawQuery(wrappedQuery, inputTableName, userEmail); + result = JSON.stringify(queryResult); + break; + } + + case 'executeAggregationPipeline': { + const pipeline = toolCall.arguments.pipeline as string; + if (!pipeline) { + throw new Error('Missing required function argument "pipeline"'); + } + if (!isValidMongoDbCommand(pipeline)) { + throw new Error( + 'Invalid MongoDB command. Please ensure it is a read-only aggregation pipeline without any forbidden keywords.', + ); + } + const pipelineResult = await dataAccessObject.executeRawQuery(pipeline, inputTableName, userEmail); + result = JSON.stringify(pipelineResult); + break; + } + + default: + result = JSON.stringify({ error: `Unknown tool: ${toolCall.name}` }); + } + } catch (error) { + result = JSON.stringify({ error: error.message }); + } + + results.push({ toolCallId: toolCall.id, result }); + } + + return results; + } + + private async getTableStructureInfo( + dao: IDataAccessObject | IDataAccessObjectAgent, + tableName: string, + userEmail: string, + foundConnection: ConnectionEntity, + ) { + const [tableStructure, tableForeignKeys, referencedTableNamesAndColumns] = await Promise.all([ + dao.getTableStructure(tableName, userEmail), + dao.getTableForeignKeys(tableName, userEmail), + dao.getReferencedTableNamesAndColumns(tableName, userEmail), + ]); + + const referencedTablesStructures = []; + const structurePromises = referencedTableNamesAndColumns.flatMap((referencedTable) => + referencedTable.referenced_by.map((table) => + dao.getTableStructure(table.table_name, userEmail).then((structure) => ({ + tableName: table.table_name, + structure, + })), + ), + ); + referencedTablesStructures.push(...(await Promise.all(structurePromises))); + + const foreignTablesStructures = []; + const foreignTablesStructurePromises = tableForeignKeys.flatMap((foreignKey) => + dao.getTableStructure(foreignKey.referenced_table_name, userEmail).then((structure) => ({ + tableName: foreignKey.referenced_table_name, + structure, + })), + ); + foreignTablesStructures.push(...(await Promise.all(foreignTablesStructurePromises))); + + return { + tableStructure, + tableName, + schema: foundConnection.schema || null, + tableForeignKeys, + referencedTableNamesAndColumns, + referencedTablesStructures, + foreignTablesStructures, + }; + } + + private setupResponseHeaders(response: Response): void { + response.setHeader('Content-Type', 'text/event-stream'); + response.setHeader('Cache-Control', 'no-cache'); + response.setHeader('Connection', 'keep-alive'); + response.setHeader('Access-Control-Expose-Headers', 'X-AI-Thread-ID'); + } + + private async setupConnection(connectionId: string, master_password: string, user_id: string) { + const foundConnection = await this._dbContext.connectionRepository.findAndDecryptConnection( + connectionId, + master_password, + ); + + if (!foundConnection) { + throw new NotFoundException(Messages.CONNECTION_NOT_FOUND); + } + + let userEmail: string; + if (isConnectionTypeAgent(foundConnection.type)) { + userEmail = await this._dbContext.userRepository.getUserEmailOrReturnNull(user_id); + } + + const connectionProperties = + await this._dbContext.connectionPropertiesRepository.findConnectionProperties(connectionId); + + if (connectionProperties && !connectionProperties.allow_ai_requests) { + throw new BadRequestException(Messages.AI_REQUESTS_NOT_ALLOWED); + } + + const dataAccessObject = getDataAccessObject(foundConnection); + const databaseType = foundConnection.type; + const isMongoDb = + databaseType === ConnectionTypesEnum.mongodb || databaseType === ConnectionTypesEnum.agent_mongodb; + + return { foundConnection, dataAccessObject, databaseType, isMongoDb, userEmail }; + } +} diff --git a/backend/src/entities/ai/user-ai-requests-v2.controller.ts b/backend/src/entities/ai/user-ai-requests-v2.controller.ts index f7e5a2859..cb0ac9118 100644 --- a/backend/src/entities/ai/user-ai-requests-v2.controller.ts +++ b/backend/src/entities/ai/user-ai-requests-v2.controller.ts @@ -38,6 +38,8 @@ export class UserAIRequestsControllerV2 { private readonly requestInfoFromTableWithAIUseCase: IRequestInfoFromTableV2, @Inject(UseCaseType.REQUEST_INFO_FROM_TABLE_WITH_AI_V3) private readonly requestInfoFromTableWithAIUseCaseV3: IRequestInfoFromTableV2, + @Inject(UseCaseType.REQUEST_INFO_FROM_TABLE_WITH_AI_V4) + private readonly requestInfoFromTableWithAIUseCaseV4: IRequestInfoFromTableV2, @Inject(UseCaseType.REQUEST_AI_SETTINGS_AND_WIDGETS_CREATION) private readonly requestAISettingsAndWidgetsCreationUseCase: IAISettingsAndWidgetsCreation, ) {} @@ -126,6 +128,48 @@ export class UserAIRequestsControllerV2 { return await this.requestInfoFromTableWithAIUseCaseV3.execute(inputData, InTransactionEnum.OFF); } + @ApiOperation({ + summary: 'Request info from table in connection with AI with conversation history (Version 4)', + }) + @ApiResponse({ + status: 201, + description: 'Returned info with conversation history saved.', + }) + @UseGuards(TableReadGuard) + @ApiBody({ type: RequestInfoFromTableBodyDTO }) + @ApiQuery({ name: 'tableName', required: true, type: String }) + @ApiQuery({ name: 'threadId', required: false, type: String }) + @Timeout(process.env.NODE_ENV !== 'test' ? TimeoutDefaults.AI : TimeoutDefaults.AI_TEST) + @Post('/ai/v4/request/:connectionId') + public async requestInfoFromTableWithAIWithHistory( + @SlugUuid('connectionId') connectionId: string, + @Query('threadId') threadId: string, + @QueryTableName() tableName: string, + @MasterPassword() masterPassword: string, + @UserId() userId: string, + @Body() requestData: RequestInfoFromTableBodyDTO, + @Res({ passthrough: true }) response: Response, + ): Promise { + if (threadId) { + if (!ValidationHelper.isValidUUID(threadId)) { + response.status(400).send({ + error: 'Invalid threadId format. It should be a valid UUID.', + }); + return; + } + } + const inputData: RequestInfoFromTableDSV2 = { + connectionId, + tableName, + user_message: requestData.user_message, + master_password: masterPassword, + user_id: userId, + response, + ai_thread_id: threadId || null, + }; + return await this.requestInfoFromTableWithAIUseCaseV4.execute(inputData, InTransactionEnum.OFF); + } + @ApiOperation({ summary: 'Request AI settings and widgets creation for connection', }) From 34d89724ba3270dd484f7df59bedf77ffcbe4fc7 Mon Sep 17 00:00:00 2001 From: Artem Niehrieiev Date: Fri, 30 Jan 2026 12:36:27 +0000 Subject: [PATCH 3/3] Add end-to-end tests for non-SaaS AI chat functionality --- .../non-saas-ai-chat-e2e.test.ts | 613 ++++++++++++++++++ .../ava-tests/saas-tests/ai-chat-e2e.test.ts | 613 ++++++++++++++++++ 2 files changed, 1226 insertions(+) create mode 100644 backend/test/ava-tests/non-saas-tests/non-saas-ai-chat-e2e.test.ts create mode 100644 backend/test/ava-tests/saas-tests/ai-chat-e2e.test.ts diff --git a/backend/test/ava-tests/non-saas-tests/non-saas-ai-chat-e2e.test.ts b/backend/test/ava-tests/non-saas-tests/non-saas-ai-chat-e2e.test.ts new file mode 100644 index 000000000..944edf34f --- /dev/null +++ b/backend/test/ava-tests/non-saas-tests/non-saas-ai-chat-e2e.test.ts @@ -0,0 +1,613 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { faker } from '@faker-js/faker'; +import { INestApplication, ValidationPipe } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import test from 'ava'; +import cookieParser from 'cookie-parser'; +import request from 'supertest'; +import { ApplicationModule } from '../../../src/app.module.js'; +import { Messages } from '../../../src/exceptions/text/messages.js'; +import { AllExceptionsFilter } from '../../../src/exceptions/all-exceptions.filter.js'; +import { Cacher } from '../../../src/helpers/cache/cacher.js'; +import { DatabaseModule } from '../../../src/shared/database/database.module.js'; +import { DatabaseService } from '../../../src/shared/database/database.service.js'; +import { MockFactory } from '../../mock.factory.js'; +import { registerUserAndReturnUserInfo } from '../../utils/register-user-and-return-user-info.js'; +import { setSaasEnvVariable } from '../../utils/set-saas-env-variable.js'; +import { TestUtils } from '../../utils/test.utils.js'; +import { ValidationException } from '../../../src/exceptions/custom-exceptions/validation-exception.js'; +import { ValidationError } from 'class-validator'; +import { getTestData } from '../../utils/get-test-data.js'; +import { createTestPostgresTableWithSchema } from '../../utils/create-test-table.js'; +import { dropTestTables } from '../../utils/drop-test-tables.js'; +import { AICoreService } from '../../../src/ai-core/index.js'; +import { DataSource } from 'typeorm'; +import { BaseType } from '../../../src/common/data-injection.tokens.js'; +import { UserAiChatEntity } from '../../../src/entities/ai/ai-conversation-history/user-ai-chat/user-ai-chat.entity.js'; +import { AiChatMessageEntity } from '../../../src/entities/ai/ai-conversation-history/ai-chat-messages/ai-chat-message.entity.js'; +import { MessageRole } from '../../../src/entities/ai/ai-conversation-history/ai-chat-messages/message-role.enum.js'; +import { WinstonLogger } from '../../../src/entities/logging/winston-logger.js'; + +const mockFactory = new MockFactory(); +let app: INestApplication; +let _testUtils: TestUtils; +let currentTest: string; +const testTables: Array = []; + +function createMockAIStream(content: string) { + return { + *[Symbol.asyncIterator]() { + yield { type: 'text', content, responseId: faker.string.uuid() }; + }, + }; +} + +const mockAICoreService = { + streamChatWithToolsAndProvider: async () => createMockAIStream('This is a mocked AI response for testing.'), + complete: async () => 'Mocked completion', + chat: async () => ({ content: 'Mocked chat', responseId: faker.string.uuid() }), + streamChat: async () => createMockAIStream('Mocked stream'), + chatWithTools: async () => ({ content: 'Mocked tools', responseId: faker.string.uuid() }), + streamChatWithTools: async () => createMockAIStream('Mocked tools stream'), + getDefaultProvider: () => 'openai', + setDefaultProvider: () => {}, + getAvailableProviders: () => [], +}; + +test.before(async () => { + setSaasEnvVariable(); + const moduleFixture = await Test.createTestingModule({ + imports: [ApplicationModule, DatabaseModule], + providers: [DatabaseService, TestUtils], + }) + .overrideProvider(AICoreService) + .useValue(mockAICoreService) + .compile(); + + _testUtils = moduleFixture.get(TestUtils); + + app = moduleFixture.createNestApplication(); + app.use(cookieParser()); + app.useGlobalFilters(new AllExceptionsFilter(app.get(WinstonLogger))); + app.useGlobalPipes( + new ValidationPipe({ + exceptionFactory(validationErrors: ValidationError[] = []) { + return new ValidationException(validationErrors); + }, + }), + ); + await app.init(); + app.getHttpServer().listen(0); +}); + +test.after(async () => { + try { + const connectionToTestDB = getTestData(mockFactory).connectionToPostgresSchema; + await dropTestTables(testTables, connectionToTestDB); + await Cacher.clearAllCache(); + await app.close(); + } catch (e) { + console.error('After tests error ' + e); + } +}); + +currentTest = 'GET /ai/chats'; + +test.serial(`${currentTest} should return empty list when user has no chats`, async (t) => { + try { + const { token } = await registerUserAndReturnUserInfo(app); + + const getChatsResponse = await request(app.getHttpServer()) + .get('/ai/chats') + .set('Cookie', token) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(getChatsResponse.status, 200); + const chats = JSON.parse(getChatsResponse.text); + t.true(Array.isArray(chats)); + t.is(chats.length, 0); + } catch (e) { + console.error(e); + throw e; + } +}); + +test.serial(`${currentTest} should return list of chats for user`, async (t) => { + try { + const { token } = await registerUserAndReturnUserInfo(app); + + const dataSource = app.get(BaseType.DATA_SOURCE); + const userAiChatRepository = dataSource.getRepository(UserAiChatEntity); + + const userResponse = await request(app.getHttpServer()) + .get('/user') + .set('Cookie', token) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + const user = JSON.parse(userResponse.text); + + const testChat = userAiChatRepository.create({ + id: faker.string.uuid(), + name: 'Test Chat', + user_id: user.id, + }); + await userAiChatRepository.save(testChat); + + const getChatsResponse = await request(app.getHttpServer()) + .get('/ai/chats') + .set('Cookie', token) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(getChatsResponse.status, 200); + const chats = JSON.parse(getChatsResponse.text); + t.true(Array.isArray(chats)); + t.is(chats.length, 1); + t.is(chats[0].name, 'Test Chat'); + t.truthy(chats[0].id); + t.truthy(chats[0].created_at); + } catch (e) { + console.error(e); + throw e; + } +}); + +test.serial(`${currentTest} should not return chats from other users`, async (t) => { + try { + const { token: token1 } = await registerUserAndReturnUserInfo(app); + const { token: token2 } = await registerUserAndReturnUserInfo(app); + + const dataSource = app.get(BaseType.DATA_SOURCE); + const userAiChatRepository = dataSource.getRepository(UserAiChatEntity); + + const user1Response = await request(app.getHttpServer()) + .get('/user') + .set('Cookie', token1) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + const user1 = JSON.parse(user1Response.text); + + const testChat = userAiChatRepository.create({ + id: faker.string.uuid(), + name: 'User1 Chat', + user_id: user1.id, + }); + await userAiChatRepository.save(testChat); + + const getChatsResponse = await request(app.getHttpServer()) + .get('/ai/chats') + .set('Cookie', token2) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(getChatsResponse.status, 200); + const chats = JSON.parse(getChatsResponse.text); + t.true(Array.isArray(chats)); + t.is(chats.length, 0); + } catch (e) { + console.error(e); + throw e; + } +}); + +currentTest = 'GET /ai/chats/:chatId'; + +test.serial(`${currentTest} should return chat with messages`, async (t) => { + try { + const { token } = await registerUserAndReturnUserInfo(app); + + const dataSource = app.get(BaseType.DATA_SOURCE); + const userAiChatRepository = dataSource.getRepository(UserAiChatEntity); + const aiChatMessageRepository = dataSource.getRepository(AiChatMessageEntity); + + const userResponse = await request(app.getHttpServer()) + .get('/user') + .set('Cookie', token) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + const user = JSON.parse(userResponse.text); + + const testChat = userAiChatRepository.create({ + id: faker.string.uuid(), + name: 'Chat with Messages', + user_id: user.id, + }); + const savedChat = await userAiChatRepository.save(testChat); + + const userMessage = aiChatMessageRepository.create({ + id: faker.string.uuid(), + message: 'Hello AI', + role: MessageRole.user, + ai_chat_id: savedChat.id, + }); + await aiChatMessageRepository.save(userMessage); + + const aiMessage = aiChatMessageRepository.create({ + id: faker.string.uuid(), + message: 'Hello Human', + role: MessageRole.ai, + ai_chat_id: savedChat.id, + }); + await aiChatMessageRepository.save(aiMessage); + + const getChatResponse = await request(app.getHttpServer()) + .get(`/ai/chats/${savedChat.id}`) + .set('Cookie', token) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(getChatResponse.status, 200); + const chat = JSON.parse(getChatResponse.text); + t.is(chat.id, savedChat.id); + t.is(chat.name, 'Chat with Messages'); + t.true(Array.isArray(chat.messages)); + t.is(chat.messages.length, 2); + + t.is(chat.messages[0].message, 'Hello AI'); + t.is(chat.messages[0].role, MessageRole.user); + t.is(chat.messages[1].message, 'Hello Human'); + t.is(chat.messages[1].role, MessageRole.ai); + } catch (e) { + console.error(e); + throw e; + } +}); + +test.serial(`${currentTest} should return 404 for non-existent chat`, async (t) => { + try { + const { token } = await registerUserAndReturnUserInfo(app); + const nonExistentId = faker.string.uuid(); + + const getChatResponse = await request(app.getHttpServer()) + .get(`/ai/chats/${nonExistentId}`) + .set('Cookie', token) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(getChatResponse.status, 404); + } catch (e) { + console.error(e); + throw e; + } +}); + +test.serial(`${currentTest} should return 404 when accessing another user's chat`, async (t) => { + try { + const { token: token1 } = await registerUserAndReturnUserInfo(app); + const { token: token2 } = await registerUserAndReturnUserInfo(app); + + const dataSource = app.get(BaseType.DATA_SOURCE); + const userAiChatRepository = dataSource.getRepository(UserAiChatEntity); + + const user1Response = await request(app.getHttpServer()) + .get('/user') + .set('Cookie', token1) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + const user1 = JSON.parse(user1Response.text); + + const testChat = userAiChatRepository.create({ + id: faker.string.uuid(), + name: 'User1 Private Chat', + user_id: user1.id, + }); + const savedChat = await userAiChatRepository.save(testChat); + + const getChatResponse = await request(app.getHttpServer()) + .get(`/ai/chats/${savedChat.id}`) + .set('Cookie', token2) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(getChatResponse.status, 404); + } catch (e) { + console.error(e); + throw e; + } +}); + +currentTest = 'DELETE /ai/chats/:chatId'; + +test.serial(`${currentTest} should delete chat and all its messages`, async (t) => { + try { + const { token } = await registerUserAndReturnUserInfo(app); + + const dataSource = app.get(BaseType.DATA_SOURCE); + const userAiChatRepository = dataSource.getRepository(UserAiChatEntity); + const aiChatMessageRepository = dataSource.getRepository(AiChatMessageEntity); + + const userResponse = await request(app.getHttpServer()) + .get('/user') + .set('Cookie', token) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + const user = JSON.parse(userResponse.text); + + const testChat = userAiChatRepository.create({ + id: faker.string.uuid(), + name: 'Chat to Delete', + user_id: user.id, + }); + const savedChat = await userAiChatRepository.save(testChat); + + const userMessage = aiChatMessageRepository.create({ + id: faker.string.uuid(), + message: 'Test message', + role: MessageRole.user, + ai_chat_id: savedChat.id, + }); + await aiChatMessageRepository.save(userMessage); + + const deleteChatResponse = await request(app.getHttpServer()) + .delete(`/ai/chats/${savedChat.id}`) + .set('Cookie', token) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(deleteChatResponse.status, 200); + const result = JSON.parse(deleteChatResponse.text); + t.is(result.success, true); + + const foundChat = await userAiChatRepository.findOne({ where: { id: savedChat.id } }); + t.is(foundChat, null); + + const foundMessages = await aiChatMessageRepository.find({ where: { ai_chat_id: savedChat.id } }); + t.is(foundMessages.length, 0); + } catch (e) { + console.error(e); + throw e; + } +}); + +test.serial(`${currentTest} should return 404 for non-existent chat`, async (t) => { + try { + const { token } = await registerUserAndReturnUserInfo(app); + const nonExistentId = faker.string.uuid(); + + const deleteChatResponse = await request(app.getHttpServer()) + .delete(`/ai/chats/${nonExistentId}`) + .set('Cookie', token) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(deleteChatResponse.status, 404); + } catch (e) { + console.error(e); + throw e; + } +}); + +test.serial(`${currentTest} should not allow deleting another user's chat`, async (t) => { + try { + const { token: token1 } = await registerUserAndReturnUserInfo(app); + const { token: token2 } = await registerUserAndReturnUserInfo(app); + + const dataSource = app.get(BaseType.DATA_SOURCE); + const userAiChatRepository = dataSource.getRepository(UserAiChatEntity); + + const user1Response = await request(app.getHttpServer()) + .get('/user') + .set('Cookie', token1) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + const user1 = JSON.parse(user1Response.text); + + const testChat = userAiChatRepository.create({ + id: faker.string.uuid(), + name: 'User1 Chat to Protect', + user_id: user1.id, + }); + const savedChat = await userAiChatRepository.save(testChat); + + const deleteChatResponse = await request(app.getHttpServer()) + .delete(`/ai/chats/${savedChat.id}`) + .set('Cookie', token2) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(deleteChatResponse.status, 404); + + const foundChat = await userAiChatRepository.findOne({ where: { id: savedChat.id } }); + t.truthy(foundChat); + } catch (e) { + console.error(e); + throw e; + } +}); + +currentTest = 'POST /ai/v4/request/:connectionId'; + +test.serial(`${currentTest} should create AI request and save conversation history`, async (t) => { + try { + const connectionToTestDB = getTestData(mockFactory).connectionToPostgresSchema; + const { token } = await registerUserAndReturnUserInfo(app); + const { testTableName } = await createTestPostgresTableWithSchema(connectionToTestDB); + testTables.push(testTableName); + + const createConnectionResponse = await request(app.getHttpServer()) + .post('/connection') + .send(connectionToTestDB) + .set('Cookie', token) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(createConnectionResponse.status, 201); + const connectionRO = JSON.parse(createConnectionResponse.text); + + await request(app.getHttpServer()) + .post(`/connection/properties/${connectionRO.id}`) + .send({ allow_ai_requests: true }) + .set('Cookie', token) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + const aiRequestResponse = await request(app.getHttpServer()) + .post(`/ai/v4/request/${connectionRO.id}?tableName=${testTableName}`) + .send({ + user_message: 'Show me all records', + }) + .set('Cookie', token) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(aiRequestResponse.status, 201); + + const threadId = aiRequestResponse.headers['x-ai-thread-id']; + t.truthy(threadId); + + const dataSource = app.get(BaseType.DATA_SOURCE); + const userAiChatRepository = dataSource.getRepository(UserAiChatEntity); + const aiChatMessageRepository = dataSource.getRepository(AiChatMessageEntity); + + const createdChat = await userAiChatRepository.findOne({ where: { id: threadId } }); + t.truthy(createdChat); + + const messages = await aiChatMessageRepository.find({ + where: { ai_chat_id: threadId }, + order: { created_at: 'ASC' }, + }); + + t.is(messages.length, 2); + t.is(messages[0].role, MessageRole.user); + t.is(messages[0].message, 'Show me all records'); + t.is(messages[1].role, MessageRole.ai); + t.truthy(messages[1].message); + } catch (e) { + console.error(e); + throw e; + } +}); + +test.serial(`${currentTest} should continue existing conversation when ai_thread_id is provided`, async (t) => { + try { + const connectionToTestDB = getTestData(mockFactory).connectionToPostgresSchema; + const { token } = await registerUserAndReturnUserInfo(app); + const { testTableName } = await createTestPostgresTableWithSchema(connectionToTestDB); + testTables.push(testTableName); + + const createConnectionResponse = await request(app.getHttpServer()) + .post('/connection') + .send(connectionToTestDB) + .set('Cookie', token) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(createConnectionResponse.status, 201); + const connectionRO = JSON.parse(createConnectionResponse.text); + + await request(app.getHttpServer()) + .post(`/connection/properties/${connectionRO.id}`) + .send({ allow_ai_requests: true }) + .set('Cookie', token) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + const firstResponse = await request(app.getHttpServer()) + .post(`/ai/v4/request/${connectionRO.id}?tableName=${testTableName}`) + .send({ + user_message: 'First message', + }) + .set('Cookie', token) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(firstResponse.status, 201); + const threadId = firstResponse.headers['x-ai-thread-id']; + t.truthy(threadId); + + const secondResponse = await request(app.getHttpServer()) + .post(`/ai/v4/request/${connectionRO.id}?tableName=${testTableName}&threadId=${threadId}`) + .send({ + user_message: 'Second message', + }) + .set('Cookie', token) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(secondResponse.status, 201); + t.is(secondResponse.headers['x-ai-thread-id'], threadId); + + const dataSource = app.get(BaseType.DATA_SOURCE); + const aiChatMessageRepository = dataSource.getRepository(AiChatMessageEntity); + + const messages = await aiChatMessageRepository.find({ + where: { ai_chat_id: threadId }, + order: { created_at: 'ASC' }, + }); + + t.is(messages.length, 4); + t.is(messages[0].message, 'First message'); + t.is(messages[0].role, MessageRole.user); + t.is(messages[1].role, MessageRole.ai); + t.is(messages[2].message, 'Second message'); + t.is(messages[2].role, MessageRole.user); + t.is(messages[3].role, MessageRole.ai); + } catch (e) { + console.error(e); + throw e; + } +}); + +test.serial(`${currentTest} should fail without authentication`, async (t) => { + try { + const connectionId = faker.string.uuid(); + + const aiRequestResponse = await request(app.getHttpServer()) + .post(`/ai/v4/request/${connectionId}`) + .send({ + user_message: 'Test message', + table_name: 'test_table', + }) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(aiRequestResponse.status, 401); + } catch (e) { + console.error(e); + throw e; + } +}); + +test.serial(`${currentTest} should fail when AI requests are not allowed for connection`, async (t) => { + try { + const connectionToTestDB = getTestData(mockFactory).connectionToPostgresSchema; + const { token } = await registerUserAndReturnUserInfo(app); + const { testTableName } = await createTestPostgresTableWithSchema(connectionToTestDB); + testTables.push(testTableName); + + const createConnectionResponse = await request(app.getHttpServer()) + .post('/connection') + .send(connectionToTestDB) + .set('Cookie', token) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(createConnectionResponse.status, 201); + const connectionRO = JSON.parse(createConnectionResponse.text); + + await request(app.getHttpServer()) + .post(`/connection/properties/${connectionRO.id}`) + .send({ allow_ai_requests: false }) + .set('Cookie', token) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + const aiRequestResponse = await request(app.getHttpServer()) + .post(`/ai/v4/request/${connectionRO.id}?tableName=${testTableName}`) + .send({ + user_message: 'Show me all records', + }) + .set('Cookie', token) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(aiRequestResponse.status, 400); + const response = JSON.parse(aiRequestResponse.text); + t.is(response.message, Messages.AI_REQUESTS_NOT_ALLOWED); + } catch (e) { + console.error(e); + throw e; + } +}); diff --git a/backend/test/ava-tests/saas-tests/ai-chat-e2e.test.ts b/backend/test/ava-tests/saas-tests/ai-chat-e2e.test.ts new file mode 100644 index 000000000..99727dc9b --- /dev/null +++ b/backend/test/ava-tests/saas-tests/ai-chat-e2e.test.ts @@ -0,0 +1,613 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { faker } from '@faker-js/faker'; +import { INestApplication, ValidationPipe } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import test from 'ava'; +import cookieParser from 'cookie-parser'; +import request from 'supertest'; +import { ApplicationModule } from '../../../src/app.module.js'; +import { Messages } from '../../../src/exceptions/text/messages.js'; +import { AllExceptionsFilter } from '../../../src/exceptions/all-exceptions.filter.js'; +import { Cacher } from '../../../src/helpers/cache/cacher.js'; +import { DatabaseModule } from '../../../src/shared/database/database.module.js'; +import { DatabaseService } from '../../../src/shared/database/database.service.js'; +import { MockFactory } from '../../mock.factory.js'; +import { registerUserAndReturnUserInfo } from '../../utils/register-user-and-return-user-info.js'; +import { TestUtils } from '../../utils/test.utils.js'; +import { ValidationException } from '../../../src/exceptions/custom-exceptions/validation-exception.js'; +import { ValidationError } from 'class-validator'; +import { getTestData } from '../../utils/get-test-data.js'; +import { createTestPostgresTableWithSchema } from '../../utils/create-test-table.js'; +import { dropTestTables } from '../../utils/drop-test-tables.js'; +import { AICoreService } from '../../../src/ai-core/index.js'; +import { DataSource } from 'typeorm'; +import { BaseType } from '../../../src/common/data-injection.tokens.js'; +import { UserAiChatEntity } from '../../../src/entities/ai/ai-conversation-history/user-ai-chat/user-ai-chat.entity.js'; +import { AiChatMessageEntity } from '../../../src/entities/ai/ai-conversation-history/ai-chat-messages/ai-chat-message.entity.js'; +import { MessageRole } from '../../../src/entities/ai/ai-conversation-history/ai-chat-messages/message-role.enum.js'; +import { WinstonLogger } from '../../../src/entities/logging/winston-logger.js'; + +const mockFactory = new MockFactory(); +let app: INestApplication; +let _testUtils: TestUtils; +let currentTest: string; +const testTables: Array = []; + +function createMockAIStream(content: string) { + return { + *[Symbol.asyncIterator]() { + yield { type: 'text', content, responseId: faker.string.uuid() }; + }, + }; +} + +const mockAICoreService = { + streamChatWithToolsAndProvider: async () => createMockAIStream('This is a mocked AI response for testing.'), + complete: async () => 'Mocked completion', + chat: async () => ({ content: 'Mocked chat', responseId: faker.string.uuid() }), + streamChat: async () => createMockAIStream('Mocked stream'), + chatWithTools: async () => ({ content: 'Mocked tools', responseId: faker.string.uuid() }), + streamChatWithTools: async () => createMockAIStream('Mocked tools stream'), + getDefaultProvider: () => 'openai', + setDefaultProvider: () => {}, + getAvailableProviders: () => [], +}; + +test.before(async () => { + const moduleFixture = await Test.createTestingModule({ + imports: [ApplicationModule, DatabaseModule], + providers: [DatabaseService, TestUtils], + }) + .overrideProvider(AICoreService) + .useValue(mockAICoreService) + .compile(); + + _testUtils = moduleFixture.get(TestUtils); + + app = moduleFixture.createNestApplication(); + app.use(cookieParser()); + app.useGlobalFilters(new AllExceptionsFilter(app.get(WinstonLogger))); + app.useGlobalPipes( + new ValidationPipe({ + exceptionFactory(validationErrors: ValidationError[] = []) { + return new ValidationException(validationErrors); + }, + }), + ); + await app.init(); + app.getHttpServer().listen(0); +}); + +test.after(async () => { + try { + const connectionToTestDB = getTestData(mockFactory).connectionToPostgresSchema; + await dropTestTables(testTables, connectionToTestDB); + await Cacher.clearAllCache(); + await app.close(); + } catch (e) { + console.error('After tests error ' + e); + } +}); + + +currentTest = 'GET /ai/chats'; + +test.serial(`${currentTest} should return empty list when user has no chats`, async (t) => { + try { + const { token } = await registerUserAndReturnUserInfo(app); + + const getChatsResponse = await request(app.getHttpServer()) + .get('/ai/chats') + .set('Cookie', token) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(getChatsResponse.status, 200); + const chats = JSON.parse(getChatsResponse.text); + t.true(Array.isArray(chats)); + t.is(chats.length, 0); + } catch (e) { + console.error(e); + throw e; + } +}); + +test.serial(`${currentTest} should return list of chats for user`, async (t) => { + try { + const { token } = await registerUserAndReturnUserInfo(app); + + const dataSource = app.get(BaseType.DATA_SOURCE); + const userAiChatRepository = dataSource.getRepository(UserAiChatEntity); + + const userResponse = await request(app.getHttpServer()) + .get('/user') + .set('Cookie', token) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + const user = JSON.parse(userResponse.text); + + const testChat = userAiChatRepository.create({ + id: faker.string.uuid(), + name: 'Test Chat', + user_id: user.id, + }); + await userAiChatRepository.save(testChat); + + const getChatsResponse = await request(app.getHttpServer()) + .get('/ai/chats') + .set('Cookie', token) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(getChatsResponse.status, 200); + const chats = JSON.parse(getChatsResponse.text); + t.true(Array.isArray(chats)); + t.is(chats.length, 1); + t.is(chats[0].name, 'Test Chat'); + t.truthy(chats[0].id); + t.truthy(chats[0].created_at); + } catch (e) { + console.error(e); + throw e; + } +}); + +test.serial(`${currentTest} should not return chats from other users`, async (t) => { + try { + const { token: token1 } = await registerUserAndReturnUserInfo(app); + const { token: token2 } = await registerUserAndReturnUserInfo(app); + + const dataSource = app.get(BaseType.DATA_SOURCE); + const userAiChatRepository = dataSource.getRepository(UserAiChatEntity); + + const user1Response = await request(app.getHttpServer()) + .get('/user') + .set('Cookie', token1) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + const user1 = JSON.parse(user1Response.text); + + const testChat = userAiChatRepository.create({ + id: faker.string.uuid(), + name: 'User1 Chat', + user_id: user1.id, + }); + await userAiChatRepository.save(testChat); + + const getChatsResponse = await request(app.getHttpServer()) + .get('/ai/chats') + .set('Cookie', token2) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(getChatsResponse.status, 200); + const chats = JSON.parse(getChatsResponse.text); + t.true(Array.isArray(chats)); + t.is(chats.length, 0); + } catch (e) { + console.error(e); + throw e; + } +}); + +currentTest = 'GET /ai/chats/:chatId'; + +test.serial(`${currentTest} should return chat with messages`, async (t) => { + try { + const { token } = await registerUserAndReturnUserInfo(app); + + const dataSource = app.get(BaseType.DATA_SOURCE); + const userAiChatRepository = dataSource.getRepository(UserAiChatEntity); + const aiChatMessageRepository = dataSource.getRepository(AiChatMessageEntity); + + const userResponse = await request(app.getHttpServer()) + .get('/user') + .set('Cookie', token) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + const user = JSON.parse(userResponse.text); + + const testChat = userAiChatRepository.create({ + id: faker.string.uuid(), + name: 'Chat with Messages', + user_id: user.id, + }); + const savedChat = await userAiChatRepository.save(testChat); + + const userMessage = aiChatMessageRepository.create({ + id: faker.string.uuid(), + message: 'Hello AI', + role: MessageRole.user, + ai_chat_id: savedChat.id, + }); + await aiChatMessageRepository.save(userMessage); + + const aiMessage = aiChatMessageRepository.create({ + id: faker.string.uuid(), + message: 'Hello Human', + role: MessageRole.ai, + ai_chat_id: savedChat.id, + }); + await aiChatMessageRepository.save(aiMessage); + + const getChatResponse = await request(app.getHttpServer()) + .get(`/ai/chats/${savedChat.id}`) + .set('Cookie', token) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(getChatResponse.status, 200); + const chat = JSON.parse(getChatResponse.text); + t.is(chat.id, savedChat.id); + t.is(chat.name, 'Chat with Messages'); + t.true(Array.isArray(chat.messages)); + t.is(chat.messages.length, 2); + + t.is(chat.messages[0].message, 'Hello AI'); + t.is(chat.messages[0].role, MessageRole.user); + t.is(chat.messages[1].message, 'Hello Human'); + t.is(chat.messages[1].role, MessageRole.ai); + } catch (e) { + console.error(e); + throw e; + } +}); + +test.serial(`${currentTest} should return 404 for non-existent chat`, async (t) => { + try { + const { token } = await registerUserAndReturnUserInfo(app); + const nonExistentId = faker.string.uuid(); + + const getChatResponse = await request(app.getHttpServer()) + .get(`/ai/chats/${nonExistentId}`) + .set('Cookie', token) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(getChatResponse.status, 404); + } catch (e) { + console.error(e); + throw e; + } +}); + +test.serial(`${currentTest} should return 404 when accessing another user's chat`, async (t) => { + try { + const { token: token1 } = await registerUserAndReturnUserInfo(app); + const { token: token2 } = await registerUserAndReturnUserInfo(app); + + const dataSource = app.get(BaseType.DATA_SOURCE); + const userAiChatRepository = dataSource.getRepository(UserAiChatEntity); + + const user1Response = await request(app.getHttpServer()) + .get('/user') + .set('Cookie', token1) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + const user1 = JSON.parse(user1Response.text); + + const testChat = userAiChatRepository.create({ + id: faker.string.uuid(), + name: 'User1 Private Chat', + user_id: user1.id, + }); + const savedChat = await userAiChatRepository.save(testChat); + + const getChatResponse = await request(app.getHttpServer()) + .get(`/ai/chats/${savedChat.id}`) + .set('Cookie', token2) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(getChatResponse.status, 404); + } catch (e) { + console.error(e); + throw e; + } +}); + +currentTest = 'DELETE /ai/chats/:chatId'; + +test.serial(`${currentTest} should delete chat and all its messages`, async (t) => { + try { + const { token } = await registerUserAndReturnUserInfo(app); + + const dataSource = app.get(BaseType.DATA_SOURCE); + const userAiChatRepository = dataSource.getRepository(UserAiChatEntity); + const aiChatMessageRepository = dataSource.getRepository(AiChatMessageEntity); + + const userResponse = await request(app.getHttpServer()) + .get('/user') + .set('Cookie', token) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + const user = JSON.parse(userResponse.text); + + const testChat = userAiChatRepository.create({ + id: faker.string.uuid(), + name: 'Chat to Delete', + user_id: user.id, + }); + const savedChat = await userAiChatRepository.save(testChat); + + const userMessage = aiChatMessageRepository.create({ + id: faker.string.uuid(), + message: 'Test message', + role: MessageRole.user, + ai_chat_id: savedChat.id, + }); + await aiChatMessageRepository.save(userMessage); + + const deleteChatResponse = await request(app.getHttpServer()) + .delete(`/ai/chats/${savedChat.id}`) + .set('Cookie', token) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(deleteChatResponse.status, 200); + const result = JSON.parse(deleteChatResponse.text); + t.is(result.success, true); + + const foundChat = await userAiChatRepository.findOne({ where: { id: savedChat.id } }); + t.is(foundChat, null); + + const foundMessages = await aiChatMessageRepository.find({ where: { ai_chat_id: savedChat.id } }); + t.is(foundMessages.length, 0); + } catch (e) { + console.error(e); + throw e; + } +}); + +test.serial(`${currentTest} should return 404 for non-existent chat`, async (t) => { + try { + const { token } = await registerUserAndReturnUserInfo(app); + const nonExistentId = faker.string.uuid(); + + const deleteChatResponse = await request(app.getHttpServer()) + .delete(`/ai/chats/${nonExistentId}`) + .set('Cookie', token) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(deleteChatResponse.status, 404); + } catch (e) { + console.error(e); + throw e; + } +}); + +test.serial(`${currentTest} should not allow deleting another user's chat`, async (t) => { + try { + const { token: token1 } = await registerUserAndReturnUserInfo(app); + const { token: token2 } = await registerUserAndReturnUserInfo(app); + + const dataSource = app.get(BaseType.DATA_SOURCE); + const userAiChatRepository = dataSource.getRepository(UserAiChatEntity); + + const user1Response = await request(app.getHttpServer()) + .get('/user') + .set('Cookie', token1) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + const user1 = JSON.parse(user1Response.text); + + const testChat = userAiChatRepository.create({ + id: faker.string.uuid(), + name: 'User1 Chat to Protect', + user_id: user1.id, + }); + const savedChat = await userAiChatRepository.save(testChat); + + const deleteChatResponse = await request(app.getHttpServer()) + .delete(`/ai/chats/${savedChat.id}`) + .set('Cookie', token2) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(deleteChatResponse.status, 404); + + const foundChat = await userAiChatRepository.findOne({ where: { id: savedChat.id } }); + t.truthy(foundChat); + } catch (e) { + console.error(e); + throw e; + } +}); + + +currentTest = 'POST /ai/v4/request/:connectionId'; + +test.serial(`${currentTest} should create AI request and save conversation history`, async (t) => { + try { + const connectionToTestDB = getTestData(mockFactory).connectionToPostgresSchema; + const { token } = await registerUserAndReturnUserInfo(app); + const { testTableName } = await createTestPostgresTableWithSchema(connectionToTestDB); + testTables.push(testTableName); + + const createConnectionResponse = await request(app.getHttpServer()) + .post('/connection') + .send(connectionToTestDB) + .set('Cookie', token) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(createConnectionResponse.status, 201); + const connectionRO = JSON.parse(createConnectionResponse.text); + + await request(app.getHttpServer()) + .post(`/connection/properties/${connectionRO.id}`) + .send({ allow_ai_requests: true }) + .set('Cookie', token) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + const aiRequestResponse = await request(app.getHttpServer()) + .post(`/ai/v4/request/${connectionRO.id}?tableName=${testTableName}`) + .send({ + user_message: 'Show me all records', + }) + .set('Cookie', token) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(aiRequestResponse.status, 201); + + const threadId = aiRequestResponse.headers['x-ai-thread-id']; + t.truthy(threadId); + + const dataSource = app.get(BaseType.DATA_SOURCE); + const userAiChatRepository = dataSource.getRepository(UserAiChatEntity); + const aiChatMessageRepository = dataSource.getRepository(AiChatMessageEntity); + + const createdChat = await userAiChatRepository.findOne({ where: { id: threadId } }); + t.truthy(createdChat); + + const messages = await aiChatMessageRepository.find({ + where: { ai_chat_id: threadId }, + order: { created_at: 'ASC' }, + }); + + t.is(messages.length, 2); + t.is(messages[0].role, MessageRole.user); + t.is(messages[0].message, 'Show me all records'); + t.is(messages[1].role, MessageRole.ai); + t.truthy(messages[1].message); + } catch (e) { + console.error(e); + throw e; + } +}); + +test.serial(`${currentTest} should continue existing conversation when ai_thread_id is provided`, async (t) => { + try { + const connectionToTestDB = getTestData(mockFactory).connectionToPostgresSchema; + const { token } = await registerUserAndReturnUserInfo(app); + const { testTableName } = await createTestPostgresTableWithSchema(connectionToTestDB); + testTables.push(testTableName); + + const createConnectionResponse = await request(app.getHttpServer()) + .post('/connection') + .send(connectionToTestDB) + .set('Cookie', token) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(createConnectionResponse.status, 201); + const connectionRO = JSON.parse(createConnectionResponse.text); + + await request(app.getHttpServer()) + .post(`/connection/properties/${connectionRO.id}`) + .send({ allow_ai_requests: true }) + .set('Cookie', token) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + const firstResponse = await request(app.getHttpServer()) + .post(`/ai/v4/request/${connectionRO.id}?tableName=${testTableName}`) + .send({ + user_message: 'First message', + }) + .set('Cookie', token) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(firstResponse.status, 201); + const threadId = firstResponse.headers['x-ai-thread-id']; + t.truthy(threadId); + + const secondResponse = await request(app.getHttpServer()) + .post(`/ai/v4/request/${connectionRO.id}?tableName=${testTableName}&threadId=${threadId}`) + .send({ + user_message: 'Second message', + }) + .set('Cookie', token) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(secondResponse.status, 201); + t.is(secondResponse.headers['x-ai-thread-id'], threadId); + + const dataSource = app.get(BaseType.DATA_SOURCE); + const aiChatMessageRepository = dataSource.getRepository(AiChatMessageEntity); + + const messages = await aiChatMessageRepository.find({ + where: { ai_chat_id: threadId }, + order: { created_at: 'ASC' }, + }); + + t.is(messages.length, 4); + t.is(messages[0].message, 'First message'); + t.is(messages[0].role, MessageRole.user); + t.is(messages[1].role, MessageRole.ai); + t.is(messages[2].message, 'Second message'); + t.is(messages[2].role, MessageRole.user); + t.is(messages[3].role, MessageRole.ai); + } catch (e) { + console.error(e); + throw e; + } +}); + +test.serial(`${currentTest} should fail without authentication`, async (t) => { + try { + const connectionId = faker.string.uuid(); + + const aiRequestResponse = await request(app.getHttpServer()) + .post(`/ai/v4/request/${connectionId}`) + .send({ + user_message: 'Test message', + table_name: 'test_table', + }) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(aiRequestResponse.status, 401); + } catch (e) { + console.error(e); + throw e; + } +}); + +test.serial(`${currentTest} should fail when AI requests are not allowed for connection`, async (t) => { + try { + const connectionToTestDB = getTestData(mockFactory).connectionToPostgresSchema; + const { token } = await registerUserAndReturnUserInfo(app); + const { testTableName } = await createTestPostgresTableWithSchema(connectionToTestDB); + testTables.push(testTableName); + + const createConnectionResponse = await request(app.getHttpServer()) + .post('/connection') + .send(connectionToTestDB) + .set('Cookie', token) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(createConnectionResponse.status, 201); + const connectionRO = JSON.parse(createConnectionResponse.text); + + await request(app.getHttpServer()) + .post(`/connection/properties/${connectionRO.id}`) + .send({ allow_ai_requests: false }) + .set('Cookie', token) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + const aiRequestResponse = await request(app.getHttpServer()) + .post(`/ai/v4/request/${connectionRO.id}?tableName=${testTableName}`) + .send({ + user_message: 'Show me all records', + }) + .set('Cookie', token) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(aiRequestResponse.status, 400); + const response = JSON.parse(aiRequestResponse.text); + t.is(response.message, Messages.AI_REQUESTS_NOT_ALLOWED); + } catch (e) { + console.error(e); + throw e; + } +});