diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index e1389d4db..6660a4b40 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -38,6 +38,7 @@ import { DatabaseModule } from './shared/database/database.module.js'; import { GetHelloUseCase } from './use-cases-app/get-hello.use.case.js'; import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler'; import { SharedJobsModule } from './entities/shared-jobs/shared-jobs.module.js'; +import { TableCategoriesModule } from './entities/table-categories/table-categories.module.js'; @Module({ imports: [ @@ -79,6 +80,7 @@ import { SharedJobsModule } from './entities/shared-jobs/shared-jobs.module.js'; DemoDataModule, LoggingModule, SharedJobsModule, + TableCategoriesModule, ], controllers: [AppController], providers: [ diff --git a/backend/src/common/application/global-database-context.interface.ts b/backend/src/common/application/global-database-context.interface.ts index c09872186..08245f31a 100644 --- a/backend/src/common/application/global-database-context.interface.ts +++ b/backend/src/common/application/global-database-context.interface.ts @@ -46,6 +46,8 @@ import { ITableWidgetsRepository } from '../../entities/widget/repository/table- import { TableWidgetEntity } from '../../entities/widget/table-widget.entity.js'; import { IDatabaseContext } from '../database-context.interface.js'; import { TableCategoriesEntity } from '../../entities/table-categories/table-categories.entity.js'; +import { ITableCategoriesCustomRepository } from '../../entities/table-categories/repository/table-categories-repository.interface.js'; +import { ConnectionPropertiesEntity } from '../../entities/connection-properties/connection-properties.entity.js'; export interface IGlobalDatabaseContext extends IDatabaseContext { userRepository: Repository & IUserRepository; @@ -59,7 +61,7 @@ export interface IGlobalDatabaseContext extends IDatabaseContext { passwordResetRepository: IPasswordResetRepository; emailChangeRepository: IEmailChangeRepository; userInvitationRepository: IUserInvitationRepository; - connectionPropertiesRepository: IConnectionPropertiesRepository; + connectionPropertiesRepository: Repository & IConnectionPropertiesRepository; customFieldsRepository: ICustomFieldsRepository; tableLogsRepository: ITableLogsRepository; userActionRepository: IUserActionRepository; @@ -80,5 +82,5 @@ export interface IGlobalDatabaseContext extends IDatabaseContext { companyTabTitleRepository: Repository; tableFiltersRepository: Repository & ITableFiltersCustomRepository; aiResponsesToUserRepository: Repository & IAiResponsesToUserRepository; - tableCategoriesRepository: Repository; + tableCategoriesRepository: Repository & ITableCategoriesCustomRepository; } diff --git a/backend/src/common/application/global-database-context.ts b/backend/src/common/application/global-database-context.ts index a21b8a2fd..d8e229214 100644 --- a/backend/src/common/application/global-database-context.ts +++ b/backend/src/common/application/global-database-context.ts @@ -88,6 +88,8 @@ import { IAiResponsesToUserRepository } from '../../entities/ai/ai-data-entities import { AiResponsesToUserEntity } from '../../entities/ai/ai-data-entities/ai-reponses-to-user/ai-responses-to-user.entity.js'; import { aiResponsesToUserRepositoryExtension } from '../../entities/ai/ai-data-entities/ai-reponses-to-user/ai-reponses-to-user-repository.extension.js'; import { TableCategoriesEntity } from '../../entities/table-categories/table-categories.entity.js'; +import { ITableCategoriesCustomRepository } from '../../entities/table-categories/repository/table-categories-repository.interface.js'; +import { tableCategoriesCustomRepositoryExtension } from '../../entities/table-categories/repository/table-categories-repository.extension.js'; @Injectable({ scope: Scope.REQUEST }) export class GlobalDatabaseContext implements IGlobalDatabaseContext { @@ -104,7 +106,7 @@ export class GlobalDatabaseContext implements IGlobalDatabaseContext { private _passwordResetRepository: IPasswordResetRepository; private _emailChangeRepository: IEmailChangeRepository; private _userInvitationRepository: IUserInvitationRepository; - private _connectionPropertiesRepository: IConnectionPropertiesRepository; + private _connectionPropertiesRepository: Repository & IConnectionPropertiesRepository; private _customFieldsRepository: ICustomFieldsRepository; private _tableLogsRepository: ITableLogsRepository; private _userActionRepository: IUserActionRepository; @@ -125,7 +127,7 @@ export class GlobalDatabaseContext implements IGlobalDatabaseContext { private _companyTabTitleRepository: Repository; private _tableFiltersRepository: Repository & ITableFiltersCustomRepository; private _aiResponsesToUserRepository: Repository & IAiResponsesToUserRepository; - private _tableCategoriesRepository: Repository; + private _tableCategoriesRepository: Repository & ITableCategoriesCustomRepository; public constructor( @Inject(BaseType.DATA_SOURCE) @@ -211,7 +213,9 @@ export class GlobalDatabaseContext implements IGlobalDatabaseContext { this._aiResponsesToUserRepository = this.appDataSource .getRepository(AiResponsesToUserEntity) .extend(aiResponsesToUserRepositoryExtension); - this._tableCategoriesRepository = this.appDataSource.getRepository(TableCategoriesEntity); + this._tableCategoriesRepository = this.appDataSource + .getRepository(TableCategoriesEntity) + .extend(tableCategoriesCustomRepositoryExtension); } public get userRepository(): Repository & IUserRepository { @@ -258,7 +262,7 @@ export class GlobalDatabaseContext implements IGlobalDatabaseContext { return this._userInvitationRepository; } - public get connectionPropertiesRepository(): IConnectionPropertiesRepository { + public get connectionPropertiesRepository(): Repository & IConnectionPropertiesRepository { return this._connectionPropertiesRepository; } @@ -342,7 +346,7 @@ export class GlobalDatabaseContext implements IGlobalDatabaseContext { return this._aiResponsesToUserRepository; } - public get tableCategoriesRepository(): Repository { + public get tableCategoriesRepository(): Repository & ITableCategoriesCustomRepository { return this._tableCategoriesRepository; } diff --git a/backend/src/common/data-injection.tokens.ts b/backend/src/common/data-injection.tokens.ts index 81bc44e7f..c497e8cf6 100644 --- a/backend/src/common/data-injection.tokens.ts +++ b/backend/src/common/data-injection.tokens.ts @@ -167,4 +167,7 @@ export enum UseCaseType { FIND_TABLE_FILTER_BY_ID = 'FIND_TABLE_FILTER_BY_ID', DELETE_TABLE_FILTER_BY_ID = 'DELETE_TABLE_FILTER_BY_ID', UPDATE_TABLE_FILTER_BY_ID = 'UPDATE_TABLE_FILTER_BY_ID', + + CREATE_UPDATE_TABLE_CATEGORIES = 'CREATE_UPDATE_TABLE_CATEGORIES', + FIND_TABLE_CATEGORIES = 'FIND_TABLE_CATEGORIES', } diff --git a/backend/src/entities/connection-properties/application/data-structures/create-connection-properties.ds.ts b/backend/src/entities/connection-properties/application/data-structures/create-connection-properties.ds.ts index abd281a82..0bdd6a5a4 100644 --- a/backend/src/entities/connection-properties/application/data-structures/create-connection-properties.ds.ts +++ b/backend/src/entities/connection-properties/application/data-structures/create-connection-properties.ds.ts @@ -15,5 +15,7 @@ export class CreateConnectionPropertiesDs { table_categories: Array<{ category_name: string; tables: Array; + category_color: string; + category_id: string; }>; } diff --git a/backend/src/entities/connection-properties/use-cases/create-connection-properties.use.case.ts b/backend/src/entities/connection-properties/use-cases/create-connection-properties.use.case.ts index ae7ec3f91..e4f4c5078 100644 --- a/backend/src/entities/connection-properties/use-cases/create-connection-properties.use.case.ts +++ b/backend/src/entities/connection-properties/use-cases/create-connection-properties.use.case.ts @@ -38,6 +38,8 @@ export class CreateConnectionPropertiesUseCase const newCategory = this._dbContext.tableCategoriesRepository.create({ category_name: category.category_name, tables: category.tables, + category_color: category.category_color, + category_id: category.category_id, }); newCategory.connection_properties = createdConnectionProperties; return newCategory; diff --git a/backend/src/entities/connection-properties/use-cases/update-connection-properties.use.case.ts b/backend/src/entities/connection-properties/use-cases/update-connection-properties.use.case.ts index 1eb64a7f3..730d2f86d 100644 --- a/backend/src/entities/connection-properties/use-cases/update-connection-properties.use.case.ts +++ b/backend/src/entities/connection-properties/use-cases/update-connection-properties.use.case.ts @@ -61,6 +61,8 @@ export class UpdateConnectionPropertiesUseCase const newCategory = this._dbContext.tableCategoriesRepository.create({ category_name: category.category_name, tables: category.tables, + category_color: category.category_color, + category_id: category.category_id, }); newCategory.connection_properties = updatedProperties; return newCategory; diff --git a/backend/src/entities/connection-properties/utils/build-found-connection-properties-ds.ts b/backend/src/entities/connection-properties/utils/build-found-connection-properties-ds.ts index 5256b0a64..9e4e8568d 100644 --- a/backend/src/entities/connection-properties/utils/build-found-connection-properties-ds.ts +++ b/backend/src/entities/connection-properties/utils/build-found-connection-properties-ds.ts @@ -20,9 +20,10 @@ export function buildFoundConnectionPropertiesDs( table_categories: connectionProperties.table_categories ? connectionProperties.table_categories.map((c) => { return { - id: c.id, + category_id: c.category_id, category_name: c.category_name, tables: c.tables, + category_color: c.category_color, }; }) : [], diff --git a/backend/src/entities/connection-properties/utils/validate-create-connection-properties-ds.ts b/backend/src/entities/connection-properties/utils/validate-create-connection-properties-ds.ts index 492f3798f..59c3f6248 100644 --- a/backend/src/entities/connection-properties/utils/validate-create-connection-properties-ds.ts +++ b/backend/src/entities/connection-properties/utils/validate-create-connection-properties-ds.ts @@ -5,6 +5,7 @@ import { Messages } from '../../../exceptions/text/messages.js'; import { HttpStatus } from '@nestjs/common'; import { ConnectionEntity } from '../../connection/connection.entity.js'; import { getDataAccessObject } from '@rocketadmin/shared-code/dist/src/data-access-layer/shared/create-data-access-object.js'; +import { validateTableCategories } from '../../table-categories/utils/validate-table-categories.util.js'; export async function validateCreateConnectionPropertiesDs( createConnectionProperties: CreateConnectionPropertiesDs, @@ -43,6 +44,8 @@ export async function validateCreateConnectionPropertiesDs( errors.push(Messages.CANT_CATEGORIZE_HIDDEN_TABLE(table)); } } + + await validateTableCategories(table_categories, connection); } if (errors.length > 0) { diff --git a/backend/src/entities/table-categories/data-sctructures/create-or-update-table-categories.ds.ts b/backend/src/entities/table-categories/data-sctructures/create-or-update-table-categories.ds.ts new file mode 100644 index 000000000..6c0ac7a1e --- /dev/null +++ b/backend/src/entities/table-categories/data-sctructures/create-or-update-table-categories.ds.ts @@ -0,0 +1,12 @@ +export class TableCategoryDS { + category_name: string; + category_color: string; + category_id: string; + tables: Array; +} + +export class CreateOrUpdateTableCategoriesDS { + connectionId: string; + master_password: string; + table_categories: Array; +} diff --git a/backend/src/entities/table-categories/dto/create-table-category.dto.ts b/backend/src/entities/table-categories/dto/create-table-category.dto.ts index c3e6808fa..00aaa6e3f 100644 --- a/backend/src/entities/table-categories/dto/create-table-category.dto.ts +++ b/backend/src/entities/table-categories/dto/create-table-category.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsArray, IsNotEmpty, IsString, MaxLength, MinLength } from 'class-validator'; +import { IsArray, IsNotEmpty, IsOptional, IsString, MaxLength, MinLength } from 'class-validator'; export class CreateTableCategoryDto { @ApiProperty({ type: String, maxLength: 255, example: 'Category 1' }) @@ -9,9 +9,24 @@ export class CreateTableCategoryDto { @MaxLength(255) category_name: string; + @ApiProperty({ type: String, maxLength: 255, example: '#FF5733' }) + @IsOptional() + @IsString() + @IsNotEmpty() + @MinLength(1) + @MaxLength(255) + category_color: string; + @ApiProperty({ type: String, example: ['table1', 'table2'], isArray: true }) @IsNotEmpty() @IsArray() @IsString({ each: true }) tables: string[]; + + @ApiProperty({ type: String, maxLength: 255, example: 'cat-001' }) + @IsString() + @IsNotEmpty() + @MinLength(1) + @MaxLength(255) + category_id: string; } diff --git a/backend/src/entities/table-categories/dto/found-table-category.ro.ts b/backend/src/entities/table-categories/dto/found-table-category.ro.ts index 3556172f8..4ede716a2 100644 --- a/backend/src/entities/table-categories/dto/found-table-category.ro.ts +++ b/backend/src/entities/table-categories/dto/found-table-category.ro.ts @@ -2,11 +2,14 @@ import { ApiProperty } from '@nestjs/swagger'; export class FoundTableCategoryRo { @ApiProperty({ type: String }) - id: string; + category_id: string; @ApiProperty({ type: String }) category_name: string; + @ApiProperty({ type: String }) + category_color: string; + @ApiProperty({ isArray: true, type: String }) tables: Array; } diff --git a/backend/src/entities/table-categories/repository/table-categories-repository.extension.ts b/backend/src/entities/table-categories/repository/table-categories-repository.extension.ts new file mode 100644 index 000000000..716b5d25c --- /dev/null +++ b/backend/src/entities/table-categories/repository/table-categories-repository.extension.ts @@ -0,0 +1,12 @@ +import { TableCategoriesEntity } from '../table-categories.entity.js'; +import { ITableCategoriesCustomRepository } from './table-categories-repository.interface.js'; + +export const tableCategoriesCustomRepositoryExtension: ITableCategoriesCustomRepository = { + async findTableCategoriesForConnection(connectionId: string): Promise> { + return this.createQueryBuilder('table_categories') + .leftJoin('table_categories.connection_properties', 'connection_properties') + .leftJoin('connection_properties.connection', 'connection') + .where('connection.id = :connectionId', { connectionId: connectionId }) + .getMany(); + }, +}; diff --git a/backend/src/entities/table-categories/repository/table-categories-repository.interface.ts b/backend/src/entities/table-categories/repository/table-categories-repository.interface.ts new file mode 100644 index 000000000..24f35ce9f --- /dev/null +++ b/backend/src/entities/table-categories/repository/table-categories-repository.interface.ts @@ -0,0 +1,5 @@ +import { TableCategoriesEntity } from '../table-categories.entity.js'; + +export interface ITableCategoriesCustomRepository { + findTableCategoriesForConnection(connectionId: string): Promise>; +} diff --git a/backend/src/entities/table-categories/table-categories.controller.ts b/backend/src/entities/table-categories/table-categories.controller.ts new file mode 100644 index 000000000..83e562693 --- /dev/null +++ b/backend/src/entities/table-categories/table-categories.controller.ts @@ -0,0 +1,64 @@ +import { Body, Controller, Get, Inject, Injectable, Put, UseGuards, UseInterceptors } from '@nestjs/common'; +import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { UseCaseType } from '../../common/data-injection.tokens.js'; +import { MasterPassword } from '../../decorators/master-password.decorator.js'; +import { SlugUuid } from '../../decorators/slug-uuid.decorator.js'; +import { ConnectionEditGuard } from '../../guards/connection-edit.guard.js'; +import { ConnectionReadGuard } from '../../guards/connection-read.guard.js'; +import { SentryInterceptor } from '../../interceptors/sentry.interceptor.js'; +import { CreateOrUpdateTableCategoriesDS } from './data-sctructures/create-or-update-table-categories.ds.js'; +import { CreateTableCategoryDto } from './dto/create-table-category.dto.js'; +import { FoundTableCategoryRo } from './dto/found-table-category.ro.js'; +import { ICreateTableCategories, IFindTableCategories } from './use-cases/table-categories-use-cases.interface.js'; + +@UseInterceptors(SentryInterceptor) +@Controller('table-categories') +@ApiBearerAuth() +@ApiTags('Table categories') +@Injectable() +export class TableCategoriesController { + constructor( + @Inject(UseCaseType.CREATE_UPDATE_TABLE_CATEGORIES) + private readonly createTableCategoriesUseCase: ICreateTableCategories, + @Inject(UseCaseType.FIND_TABLE_CATEGORIES) + private readonly findTableCategoriesUseCase: IFindTableCategories, + ) {} + + @ApiOperation({ summary: 'Add new table categories' }) + @ApiBody({ type: CreateTableCategoryDto, isArray: true }) + @ApiResponse({ + status: 200, + description: 'Table categories created.', + type: FoundTableCategoryRo, + isArray: true, + }) + @ApiParam({ name: 'connectionId', required: true }) + @UseGuards(ConnectionEditGuard) + @Put('/:connectionId') + async createTableCategories( + @SlugUuid('connectionId') connectionId: string, + @MasterPassword() masterPwd: string, + @Body() requestBody: CreateTableCategoryDto[], + ): Promise> { + const inputData: CreateOrUpdateTableCategoriesDS = { + connectionId, + master_password: masterPwd, + table_categories: requestBody, + }; + return await this.createTableCategoriesUseCase.execute(inputData); + } + + @ApiOperation({ summary: 'Find table categories' }) + @ApiResponse({ + status: 200, + description: 'Table categories found.', + type: FoundTableCategoryRo, + isArray: true, + }) + @ApiParam({ name: 'connectionId', required: true }) + @UseGuards(ConnectionReadGuard) + @Get('/:connectionId') + async findTableCategories(@SlugUuid('connectionId') connectionId: string): Promise> { + return await this.findTableCategoriesUseCase.execute(connectionId); + } +} diff --git a/backend/src/entities/table-categories/table-categories.entity.ts b/backend/src/entities/table-categories/table-categories.entity.ts index 5da333589..22f4ef083 100644 --- a/backend/src/entities/table-categories/table-categories.entity.ts +++ b/backend/src/entities/table-categories/table-categories.entity.ts @@ -9,6 +9,12 @@ export class TableCategoriesEntity { @Column({ type: 'varchar', length: 255 }) category_name: string; + @Column({ type: 'varchar', length: 255, nullable: false }) + category_id: string; + + @Column({ type: 'varchar', length: 255, default: null, nullable: true }) + category_color: string; + @Column('varchar', { array: true, default: null }) tables: string[]; diff --git a/backend/src/entities/table-categories/table-categories.module.ts b/backend/src/entities/table-categories/table-categories.module.ts new file mode 100644 index 000000000..d979a927d --- /dev/null +++ b/backend/src/entities/table-categories/table-categories.module.ts @@ -0,0 +1,39 @@ +import { TypeOrmModule } from '@nestjs/typeorm'; +import { GlobalDatabaseContext } from '../../common/application/global-database-context.js'; +import { BaseType, UseCaseType } from '../../common/data-injection.tokens.js'; +import { MiddlewareConsumer, Module, RequestMethod } from '@nestjs/common'; +import { AuthMiddleware } from '../../authorization/auth.middleware.js'; +import { TableCategoriesController } from './table-categories.controller.js'; +import { FindTableCategoriesUseCase } from './use-cases/find-table-categories.use.case.js'; +import { CreateOrUpdateTableCategoriesUseCase } from './use-cases/create-or-update-table-categories.use.case.js'; +import { UserEntity } from '../user/user.entity.js'; +import { LogOutEntity } from '../log-out/log-out.entity.js'; + +@Module({ + imports: [TypeOrmModule.forFeature([UserEntity, LogOutEntity])], + providers: [ + { + provide: BaseType.GLOBAL_DB_CONTEXT, + useClass: GlobalDatabaseContext, + }, + { + provide: UseCaseType.FIND_TABLE_CATEGORIES, + useClass: FindTableCategoriesUseCase, + }, + { + provide: UseCaseType.CREATE_UPDATE_TABLE_CATEGORIES, + useClass: CreateOrUpdateTableCategoriesUseCase, + }, + ], + controllers: [TableCategoriesController], +}) +export class TableCategoriesModule { + public configure(consumer: MiddlewareConsumer): any { + consumer + .apply(AuthMiddleware) + .forRoutes( + { path: '/table-categories/:connectionId/', method: RequestMethod.GET }, + { path: '/table-categories/:connectionId/', method: RequestMethod.PUT }, + ); + } +} diff --git a/backend/src/entities/table-categories/use-cases/create-or-update-table-categories.use.case.ts b/backend/src/entities/table-categories/use-cases/create-or-update-table-categories.use.case.ts new file mode 100644 index 000000000..e4eaad44a --- /dev/null +++ b/backend/src/entities/table-categories/use-cases/create-or-update-table-categories.use.case.ts @@ -0,0 +1,61 @@ +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 { ICreateTableCategories } from './table-categories-use-cases.interface.js'; +import { FoundTableCategoryRo } from '../dto/found-table-category.ro.js'; +import { CreateOrUpdateTableCategoriesDS } from '../data-sctructures/create-or-update-table-categories.ds.js'; +import { validateTableCategories } from '../utils/validate-table-categories.util.js'; +import { buildFoundTableCategoryRo } from '../utils/build-found-table-category.ro.js'; + +@Injectable() +export class CreateOrUpdateTableCategoriesUseCase + extends AbstractUseCase> + implements ICreateTableCategories +{ + constructor( + @Inject(BaseType.GLOBAL_DB_CONTEXT) + protected _dbContext: IGlobalDatabaseContext, + ) { + super(); + } + + protected async implementation(inputData: CreateOrUpdateTableCategoriesDS): Promise> { + const { connectionId, master_password, table_categories } = inputData; + const foundConnection = await this._dbContext.connectionRepository.findAndDecryptConnection( + connectionId, + master_password, + ); + + await validateTableCategories(table_categories, foundConnection); + + const foundTableCategories = + await this._dbContext.tableCategoriesRepository.findTableCategoriesForConnection(connectionId); + + if (foundTableCategories.length) { + await this._dbContext.tableCategoriesRepository.remove(foundTableCategories); + } + + let connectionProperties = + await this._dbContext.connectionPropertiesRepository.findConnectionProperties(connectionId); + + if (!connectionProperties) { + const newConnectionProperties = this._dbContext.connectionPropertiesRepository.create({}); + newConnectionProperties.connection = foundConnection; + connectionProperties = await this._dbContext.connectionPropertiesRepository.save(newConnectionProperties); + } + + const createdCategories = table_categories.map((category) => { + const newCategory = this._dbContext.tableCategoriesRepository.create({ + category_name: category.category_name, + tables: category.tables, + category_color: category.category_color, + category_id: category.category_id, + }); + newCategory.connection_properties = connectionProperties; + return newCategory; + }); + const savedCategories = await this._dbContext.tableCategoriesRepository.save(createdCategories); + return savedCategories.map((category) => buildFoundTableCategoryRo(category)); + } +} diff --git a/backend/src/entities/table-categories/use-cases/find-table-categories.use.case.ts b/backend/src/entities/table-categories/use-cases/find-table-categories.use.case.ts new file mode 100644 index 000000000..2f9a2a27f --- /dev/null +++ b/backend/src/entities/table-categories/use-cases/find-table-categories.use.case.ts @@ -0,0 +1,27 @@ +import { Inject, Injectable } from '@nestjs/common'; +import AbstractUseCase from '../../../common/abstract-use.case.js'; +import { IFindTableCategories } from './table-categories-use-cases.interface.js'; +import { BaseType } from '../../../common/data-injection.tokens.js'; +import { IGlobalDatabaseContext } from '../../../common/application/global-database-context.interface.js'; +import { FoundTableCategoryRo } from '../dto/found-table-category.ro.js'; +import { buildFoundTableCategoryRo } from '../utils/build-found-table-category.ro.js'; + +@Injectable() +export class FindTableCategoriesUseCase + extends AbstractUseCase> + implements IFindTableCategories +{ + constructor( + @Inject(BaseType.GLOBAL_DB_CONTEXT) + protected _dbContext: IGlobalDatabaseContext, + ) { + super(); + } + + protected async implementation(connectionId: string): Promise> { + const foundTableCategories = + await this._dbContext.tableCategoriesRepository.findTableCategoriesForConnection(connectionId); + + return foundTableCategories.map((category) => buildFoundTableCategoryRo(category)); + } +} diff --git a/backend/src/entities/table-categories/use-cases/table-categories-use-cases.interface.ts b/backend/src/entities/table-categories/use-cases/table-categories-use-cases.interface.ts new file mode 100644 index 000000000..4823a7cc6 --- /dev/null +++ b/backend/src/entities/table-categories/use-cases/table-categories-use-cases.interface.ts @@ -0,0 +1,10 @@ +import { CreateOrUpdateTableCategoriesDS } from '../data-sctructures/create-or-update-table-categories.ds.js'; +import { FoundTableCategoryRo } from '../dto/found-table-category.ro.js'; + +export interface ICreateTableCategories { + execute(inputData: CreateOrUpdateTableCategoriesDS): Promise>; +} + +export interface IFindTableCategories { + execute(connectionId: string): Promise>; +} diff --git a/backend/src/entities/table-categories/utils/build-found-table-category.ro.ts b/backend/src/entities/table-categories/utils/build-found-table-category.ro.ts new file mode 100644 index 000000000..2d916fb96 --- /dev/null +++ b/backend/src/entities/table-categories/utils/build-found-table-category.ro.ts @@ -0,0 +1,11 @@ +import { FoundTableCategoryRo } from '../dto/found-table-category.ro.js'; +import { TableCategoriesEntity } from '../table-categories.entity.js'; + +export function buildFoundTableCategoryRo(tableCategory: TableCategoriesEntity): FoundTableCategoryRo { + return { + category_id: tableCategory.category_id, + category_name: tableCategory.category_name, + category_color: tableCategory.category_color, + tables: tableCategory.tables, + }; +} diff --git a/backend/src/entities/table-categories/utils/validate-table-categories.util.ts b/backend/src/entities/table-categories/utils/validate-table-categories.util.ts new file mode 100644 index 000000000..7201ae2db --- /dev/null +++ b/backend/src/entities/table-categories/utils/validate-table-categories.util.ts @@ -0,0 +1,35 @@ +import { getDataAccessObject } from '@rocketadmin/shared-code/dist/src/data-access-layer/shared/create-data-access-object.js'; +import { ConnectionEntity } from '../../connection/connection.entity.js'; +import { TableCategoryDS } from '../data-sctructures/create-or-update-table-categories.ds.js'; +import { BadRequestException } from '@nestjs/common'; + +export async function validateTableCategories( + tableCategoriesData: Array, + connection: ConnectionEntity, +): Promise { + tableCategoriesData.forEach((category) => { + if (!category.category_name || category.category_name.trim() === '') { + throw new BadRequestException('Table category name cannot be empty.'); + } + if (!Array.isArray(category.tables)) { + throw new BadRequestException('Tables must be an array of table names.'); + } + if (category.category_id === undefined || category.category_id.trim() === '') { + throw new BadRequestException('Table category ID cannot be empty.'); + } + }); + const dao = getDataAccessObject(connection); + const tablesInConnection = (await dao.getTablesFromDB()).map((table) => table.tableName); + const tables = tableCategoriesData.map((category) => category.tables).flat(); + const uniqueTables = Array.from(new Set(tables)); + const errors = []; + for (const table of uniqueTables) { + if (!tablesInConnection.includes(table)) { + errors.push(`Table with name ${table} does not exist in the connection.`); + } + } + if (errors.length > 0) { + throw new BadRequestException('Table categories validation failed: ' + errors.join(', ')); + } + return true; +} diff --git a/backend/src/entities/table/use-cases/find-tables-in-connection-v2.use.case.ts b/backend/src/entities/table/use-cases/find-tables-in-connection-v2.use.case.ts index a6b0f3523..3184bc34e 100644 --- a/backend/src/entities/table/use-cases/find-tables-in-connection-v2.use.case.ts +++ b/backend/src/entities/table/use-cases/find-tables-in-connection-v2.use.case.ts @@ -145,10 +145,11 @@ export class FindTablesInConnectionV2UseCase const responseObject: FoundTablesWithCategoriesDS = { tables: sortedTables, - table_categories: tableCategories.map(({ category_name, id, tables }) => ({ + table_categories: tableCategories.map(({ category_name, category_id, tables, category_color }) => ({ category_name, - id, + category_id, tables, + category_color, })), }; return responseObject; diff --git a/backend/src/migrations/1761142678373-AddCategoryColorPropertyAndCategoryIdToTableCategoriesEntity.ts b/backend/src/migrations/1761142678373-AddCategoryColorPropertyAndCategoryIdToTableCategoriesEntity.ts new file mode 100644 index 000000000..3dd6e2a73 --- /dev/null +++ b/backend/src/migrations/1761142678373-AddCategoryColorPropertyAndCategoryIdToTableCategoriesEntity.ts @@ -0,0 +1,15 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddCategoryColorPropertyAndCategoryIdToTableCategoriesEntity1761142678373 implements MigrationInterface { + name = 'AddCategoryColorPropertyAndCategoryIdToTableCategoriesEntity1761142678373'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "table_categories" ADD "category_id" character varying(255) NOT NULL`); + await queryRunner.query(`ALTER TABLE "table_categories" ADD "category_color" character varying(255)`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "table_categories" DROP COLUMN "category_color"`); + await queryRunner.query(`ALTER TABLE "table_categories" DROP COLUMN "category_id"`); + } +} diff --git a/backend/test/ava-tests/saas-tests/connection-properties-e2e.test.ts b/backend/test/ava-tests/saas-tests/connection-properties-e2e.test.ts index dd93e3465..29bad3828 100644 --- a/backend/test/ava-tests/saas-tests/connection-properties-e2e.test.ts +++ b/backend/test/ava-tests/saas-tests/connection-properties-e2e.test.ts @@ -162,7 +162,14 @@ test.serial(`${currentTest} should return created connection properties with tab human_readable_table_names: faker.datatype.boolean(), allow_ai_requests: faker.datatype.boolean(), default_showing_table: null, - table_categories: [{ category_name: 'Category 1', tables: [testTableName] }], + table_categories: [ + { + category_name: 'Category 1', + category_id: 'cat-001', + category_color: '#FF5733', + tables: [testTableName], + }, + ], }; const createConnectionResponse = await request(app.getHttpServer()) @@ -202,7 +209,14 @@ test.serial(`${currentTest} should return created connection properties with tab human_readable_table_names: faker.datatype.boolean(), allow_ai_requests: faker.datatype.boolean(), default_showing_table: null, - table_categories: [{ category_name: 'Updated Category', tables: [testTableName] }], + table_categories: [ + { + category_name: 'Updated Category', + tables: [testTableName], + category_color: '#33FF57', + category_id: 'cat-002', + }, + ], }; const updateConnectionPropertiesResponse = await request(app.getHttpServer()) @@ -283,7 +297,9 @@ test.serial( human_readable_table_names: faker.datatype.boolean(), allow_ai_requests: faker.datatype.boolean(), default_showing_table: null, - table_categories: [{ category_name: 'Category 1', tables: [testTableName] }], + table_categories: [ + { category_name: 'Category 1', tables: [testTableName], category_color: '#FF5733', category_id: 'cat-001' }, + ], }; const createConnectionResponse = await request(app.getHttpServer()) diff --git a/backend/test/ava-tests/saas-tests/table-categories-e2e.test.ts b/backend/test/ava-tests/saas-tests/table-categories-e2e.test.ts new file mode 100644 index 000000000..2bdf2201d --- /dev/null +++ b/backend/test/ava-tests/saas-tests/table-categories-e2e.test.ts @@ -0,0 +1,274 @@ +/* eslint-disable security/detect-non-literal-fs-filename */ +/* eslint-disable @typescript-eslint/no-unused-vars */ +/* eslint-disable security/detect-object-injection */ +import { INestApplication, ValidationPipe } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import test from 'ava'; +import { ValidationError } from 'class-validator'; +import cookieParser from 'cookie-parser'; +import path from 'path'; +import request from 'supertest'; +import { fileURLToPath } from 'url'; +import { ApplicationModule } from '../../../src/app.module.js'; +import { QueryOrderingEnum } from '../../../src/enums/index.js'; +import { AllExceptionsFilter } from '../../../src/exceptions/all-exceptions.filter.js'; +import { ValidationException } from '../../../src/exceptions/custom-exceptions/validation-exception.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 { createTestTable } from '../../utils/create-test-table.js'; +import { getTestData } from '../../utils/get-test-data.js'; +import { registerUserAndReturnUserInfo } from '../../utils/register-user-and-return-user-info.js'; +import { TestUtils } from '../../utils/test.utils.js'; +import { WinstonLogger } from '../../../src/entities/logging/winston-logger.js'; +import { CreateTableCategoryDto } from '../../../src/entities/table-categories/dto/create-table-category.dto.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const mockFactory = new MockFactory(); +let app: INestApplication; +let testUtils: TestUtils; +const testSearchedUserName = 'Vasia'; +const testTables: Array = []; +let currentTest; + +test.before(async () => { + const moduleFixture = await Test.createTestingModule({ + imports: [ApplicationModule, DatabaseModule], + providers: [DatabaseService, TestUtils], + }).compile(); + app = moduleFixture.createNestApplication() as any; + testUtils = moduleFixture.get(TestUtils); + + 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 { + await Cacher.clearAllCache(); + await app.close(); + } catch (e) { + console.error('After tests error ' + e); + } +}); + +currentTest = `PUT /table-categories/:connectionId`; +test.serial(`${currentTest} create table categories`, async (t) => { + try { + const connectionToTestDB = getTestData(mockFactory).connectionToPostgres; + const firstUserToken = (await registerUserAndReturnUserInfo(app)).token; + const { testTableName: firstTesTableName } = await createTestTable(connectionToTestDB); + const { testTableName: secondTesTableName } = await createTestTable(connectionToTestDB); + + const createConnectionResponse = await request(app.getHttpServer()) + .post('/connection') + .send(connectionToTestDB) + .set('Cookie', firstUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + const createConnectionRO = JSON.parse(createConnectionResponse.text); + t.is(createConnectionResponse.status, 201); + + const categoriesDTO: Array = [ + { + category_name: 'Category 1', + category_color: '#FF5733', + tables: [firstTesTableName], + category_id: 'cat-001', + }, + { + category_name: 'Category 2', + category_color: '#33FF57', + tables: [secondTesTableName], + category_id: 'cat-002', + }, + { + category_name: 'Category 3', + category_color: '#3357FF', + tables: [firstTesTableName, secondTesTableName], + category_id: 'cat-003', + }, + ]; + + const createTableCategoriesResponse = await request(app.getHttpServer()) + .put(`/table-categories/${createConnectionRO.id}`) + .send(categoriesDTO) + .set('Cookie', firstUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + const createTableCategoriesRO = JSON.parse(createTableCategoriesResponse.text); + + t.is(createTableCategoriesResponse.status, 200); + + t.is(createTableCategoriesRO.length, 3); + t.is(createTableCategoriesRO[0].category_name, 'Category 1'); + t.is(createTableCategoriesRO[0].category_color, '#FF5733'); + t.is(createTableCategoriesRO[0].category_id, 'cat-001'); + t.deepEqual(createTableCategoriesRO[0].tables, [firstTesTableName]); + t.is(createTableCategoriesRO[1].category_name, 'Category 2'); + t.is(createTableCategoriesRO[1].category_color, '#33FF57'); + t.is(createTableCategoriesRO[1].category_id, 'cat-002'); + t.deepEqual(createTableCategoriesRO[1].tables, [secondTesTableName]); + t.is(createTableCategoriesRO[2].category_name, 'Category 3'); + t.is(createTableCategoriesRO[2].category_color, '#3357FF'); + t.is(createTableCategoriesRO[2].category_id, 'cat-003'); + t.deepEqual(createTableCategoriesRO[2].tables, [firstTesTableName, secondTesTableName]); + + // should recreate categories on the new request + const recreateTableCategoriesResponse = await request(app.getHttpServer()) + .put(`/table-categories/${createConnectionRO.id}`) + .send([categoriesDTO[0]]) + .set('Cookie', firstUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + const recreateTableCategoriesRO = JSON.parse(recreateTableCategoriesResponse.text); + + t.is(recreateTableCategoriesResponse.status, 200); + + t.is(recreateTableCategoriesRO.length, 1); + t.is(recreateTableCategoriesRO[0].category_name, 'Category 1'); + t.is(recreateTableCategoriesRO[0].category_color, '#FF5733'); + t.is(recreateTableCategoriesRO[0].category_id, 'cat-001'); + t.deepEqual(recreateTableCategoriesRO[0].tables, [firstTesTableName]); + } catch (e) { + console.error(e); + t.fail(); + } +}); + +test.serial(`${currentTest} should throw validation exceptions, when table categories dto is invalid`, async (t) => { + try { + const connectionToTestDB = getTestData(mockFactory).connectionToPostgres; + const firstUserToken = (await registerUserAndReturnUserInfo(app)).token; + const { testTableName: firstTesTableName } = await createTestTable(connectionToTestDB); + const { testTableName: secondTesTableName } = await createTestTable(connectionToTestDB); + + const createConnectionResponse = await request(app.getHttpServer()) + .post('/connection') + .send(connectionToTestDB) + .set('Cookie', firstUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + const createConnectionRO = JSON.parse(createConnectionResponse.text); + t.is(createConnectionResponse.status, 201); + + const categoriesDTO = [ + { + category_name: 'Category 1', + tables: '[firstTesTableName]', + }, + { + category_name: 'Category 2', + tables: ['non-real-table'], + }, + { + category_name: null, + tables: [firstTesTableName, secondTesTableName], + }, + ]; + + const createTableCategoriesResponse = await request(app.getHttpServer()) + .put(`/table-categories/${createConnectionRO.id}`) + .send(categoriesDTO) + .set('Cookie', firstUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + const createTableCategoriesRO = JSON.parse(createTableCategoriesResponse.text); + // console.log('🚀 ~ createTableCategoriesRO:', createTableCategoriesRO); + + t.is(createTableCategoriesResponse.status, 400); + } catch (e) { + console.error(e); + t.fail(); + } +}); + +currentTest = `GET /table-categories/:connectionId`; +test.serial(`${currentTest} find table categories`, async (t) => { + try { + const connectionToTestDB = getTestData(mockFactory).connectionToPostgres; + const firstUserToken = (await registerUserAndReturnUserInfo(app)).token; + const { testTableName: firstTesTableName } = await createTestTable(connectionToTestDB); + const { testTableName: secondTesTableName } = await createTestTable(connectionToTestDB); + + const createConnectionResponse = await request(app.getHttpServer()) + .post('/connection') + .send(connectionToTestDB) + .set('Cookie', firstUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + const createConnectionRO = JSON.parse(createConnectionResponse.text); + t.is(createConnectionResponse.status, 201); + + const categoriesDTO: Array = [ + { + category_name: 'Category 1', + category_color: '#FF5733', + tables: [firstTesTableName], + category_id: 'cat-001', + }, + { + category_name: 'Category 2', + category_color: '#33FF57', + tables: [secondTesTableName], + category_id: 'cat-002', + }, + { + category_name: 'Category 3', + category_color: '#3357FF', + tables: [firstTesTableName, secondTesTableName], + category_id: 'cat-003', + }, + ]; + + const createTableCategoriesResponse = await request(app.getHttpServer()) + .put(`/table-categories/${createConnectionRO.id}`) + .send(categoriesDTO) + .set('Cookie', firstUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + t.is(createTableCategoriesResponse.status, 200); + + const findTableCategoriesResponse = await request(app.getHttpServer()) + .get(`/table-categories/${createConnectionRO.id}`) + .set('Cookie', firstUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + const findTableCategoriesRO = JSON.parse(findTableCategoriesResponse.text); + + t.is(findTableCategoriesResponse.status, 200); + + t.is(findTableCategoriesRO.length, 3); + t.is(findTableCategoriesRO[0].category_name, 'Category 1'); + t.is(findTableCategoriesRO[0].category_color, '#FF5733'); + t.is(findTableCategoriesRO[0].category_id, 'cat-001'); + t.deepEqual(findTableCategoriesRO[0].tables, [firstTesTableName]); + t.is(findTableCategoriesRO[1].category_name, 'Category 2'); + t.is(findTableCategoriesRO[1].category_color, '#33FF57'); + t.is(findTableCategoriesRO[1].category_id, 'cat-002'); + t.deepEqual(findTableCategoriesRO[1].tables, [secondTesTableName]); + t.is(findTableCategoriesRO[2].category_name, 'Category 3'); + t.is(findTableCategoriesRO[2].category_color, '#3357FF'); + t.is(findTableCategoriesRO[2].category_id, 'cat-003'); + t.deepEqual(findTableCategoriesRO[2].tables, [firstTesTableName, secondTesTableName]); + } catch (e) { + console.error(e); + t.fail(); + } +});