diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 614de3e01..52f5676c5 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -33,6 +33,7 @@ import { EmailModule } from './entities/email/email/email.module.js'; import { CompanyLogoModule } from './entities/company-logo/company-logo.module.js'; import { CompanyFaviconModule } from './entities/company-favicon/company-favicon.module.js'; import { CompanyTabTitleModule } from './entities/company-tab-title/company-tab-title.module.js'; +import { TableFiltersModule } from './entities/table-filters/table-filters.module.js'; @Module({ imports: [ @@ -62,6 +63,7 @@ import { CompanyTabTitleModule } from './entities/company-tab-title/company-tab- CompanyLogoModule, CompanyFaviconModule, CompanyTabTitleModule, + TableFiltersModule, ], 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 5af3701ea..8d684b891 100644 --- a/backend/src/common/application/global-database-context.interface.ts +++ b/backend/src/common/application/global-database-context.interface.ts @@ -43,6 +43,8 @@ import { AiUserFileEntity } from '../../entities/ai/ai-data-entities/ai-user-fil import { CompanyLogoEntity } from '../../entities/company-logo/company-logo.entity.js'; import { CompanyFaviconEntity } from '../../entities/company-favicon/company-favicon.entity.js'; import { CompanyTabTitleEntity } from '../../entities/company-tab-title/company-tab-title.entity.js'; +import { TableFiltersEntity } from '../../entities/table-filters/table-filters.entity.js'; +import { ITableFiltersCustomRepository } from '../../entities/table-filters/repository/table-filters-custom-repository.interface.js'; export interface IGlobalDatabaseContext extends IDatabaseContext { userRepository: Repository & IUserRepository; @@ -77,4 +79,5 @@ export interface IGlobalDatabaseContext extends IDatabaseContext { companyLogoRepository: Repository; companyFaviconRepository: Repository; companyTabTitleRepository: Repository; + tableFiltersRepository: Repository & ITableFiltersCustomRepository; } diff --git a/backend/src/common/application/global-database-context.ts b/backend/src/common/application/global-database-context.ts index ae98a44c6..b685f9486 100644 --- a/backend/src/common/application/global-database-context.ts +++ b/backend/src/common/application/global-database-context.ts @@ -87,6 +87,9 @@ import { aiUserFileRepositoryExtension } from '../../entities/ai/ai-data-entitie import { CompanyLogoEntity } from '../../entities/company-logo/company-logo.entity.js'; import { CompanyFaviconEntity } from '../../entities/company-favicon/company-favicon.entity.js'; import { CompanyTabTitleEntity } from '../../entities/company-tab-title/company-tab-title.entity.js'; +import { TableFiltersEntity } from '../../entities/table-filters/table-filters.entity.js'; +import { ITableFiltersCustomRepository } from '../../entities/table-filters/repository/table-filters-custom-repository.interface.js'; +import { tableFiltersCustomRepositoryExtension } from '../../entities/table-filters/repository/table-filters-custom-repository-extension.js'; @Injectable({ scope: Scope.REQUEST }) export class GlobalDatabaseContext implements IGlobalDatabaseContext { @@ -124,6 +127,7 @@ export class GlobalDatabaseContext implements IGlobalDatabaseContext { private _companyLogoRepository: Repository; private _companyFaviconRepository: Repository; private _companyTabTitleRepository: Repository; + private _tableFiltersRepository: Repository & ITableFiltersCustomRepository; public constructor( @Inject(BaseType.DATA_SOURCE) @@ -209,6 +213,9 @@ export class GlobalDatabaseContext implements IGlobalDatabaseContext { this._companyLogoRepository = this.appDataSource.getRepository(CompanyLogoEntity); this._companyFaviconRepository = this.appDataSource.getRepository(CompanyFaviconEntity); this._companyTabTitleRepository = this.appDataSource.getRepository(CompanyTabTitleEntity); + this._tableFiltersRepository = this.appDataSource + .getRepository(TableFiltersEntity) + .extend(tableFiltersCustomRepositoryExtension); } public get userRepository(): Repository & IUserRepository { @@ -339,6 +346,10 @@ export class GlobalDatabaseContext implements IGlobalDatabaseContext { return this._companyTabTitleRepository; } + public get tableFiltersRepository(): Repository & ITableFiltersCustomRepository { + return this._tableFiltersRepository; + } + 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 fd115eef0..643fdfd59 100644 --- a/backend/src/common/data-injection.tokens.ts +++ b/backend/src/common/data-injection.tokens.ts @@ -185,4 +185,8 @@ export enum UseCaseType { GET_ALL_USER_THREADS_WITH_AI_ASSISTANT = 'GET_ALL_USER_THREADS_WITH_AI_ASSISTANT', GET_ALL_THREAD_MESSAGES = 'GET_ALL_THREAD_MESSAGES', DELETE_THREAD_WITH_AI_ASSISTANT = 'DELETE_THREAD_WITH_AI_ASSISTANT', + + CREATE_TABLE_FILTERS = 'CREATE_TABLE_FILTERS', + FIND_TABLE_FILTERS = 'FIND_TABLE_FILTERS', + DELETE_TABLE_FILTERS = 'DELETE_TABLE_FILTERS', } diff --git a/backend/src/entities/connection/connection.entity.ts b/backend/src/entities/connection/connection.entity.ts index 362e86c38..a235b1f1c 100644 --- a/backend/src/entities/connection/connection.entity.ts +++ b/backend/src/entities/connection/connection.entity.ts @@ -25,6 +25,7 @@ import { CompanyInfoEntity } from '../company-info/company-info.entity.js'; import { ActionRulesEntity } from '../table-actions/table-action-rules-module/action-rules.entity.js'; import { nanoid } from 'nanoid'; import { Constants } from '../../helpers/constants/constants.js'; +import { TableFiltersEntity } from '../table-filters/table-filters.entity.js'; @Entity('connection') export class ConnectionEntity { @@ -234,4 +235,7 @@ export class ConnectionEntity { @ManyToOne((_) => CompanyInfoEntity, (company) => company.connections) @JoinTable() company: Relation; + + @OneToMany((_) => TableFiltersEntity, (table_filters) => table_filters.connection) + table_filters: Relation[]; } diff --git a/backend/src/entities/table-filters/application/data-structures/create-table-filters.ds.ts b/backend/src/entities/table-filters/application/data-structures/create-table-filters.ds.ts new file mode 100644 index 000000000..6d4ecdc1a --- /dev/null +++ b/backend/src/entities/table-filters/application/data-structures/create-table-filters.ds.ts @@ -0,0 +1,6 @@ +export class CreateTableFiltersDto { + table_name: string; + connection_id: string; + filters: Record; + masterPwd: string; +} diff --git a/backend/src/entities/table-filters/application/data-structures/find-table-filters.ds.ts b/backend/src/entities/table-filters/application/data-structures/find-table-filters.ds.ts new file mode 100644 index 000000000..ab7ba3135 --- /dev/null +++ b/backend/src/entities/table-filters/application/data-structures/find-table-filters.ds.ts @@ -0,0 +1,4 @@ +export class FindTableFiltersDs { + table_name: string; + connection_id: string; +} \ No newline at end of file diff --git a/backend/src/entities/table-filters/application/response-objects/created-table-filters.ro.ts b/backend/src/entities/table-filters/application/response-objects/created-table-filters.ro.ts new file mode 100644 index 000000000..0e8057dc2 --- /dev/null +++ b/backend/src/entities/table-filters/application/response-objects/created-table-filters.ro.ts @@ -0,0 +1,15 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class CreatedTableFiltersRO { + @ApiProperty() + id: string; + + @ApiProperty() + tableName: string; + + @ApiProperty() + connectionId: string; + + @ApiProperty({ type: Object }) + filters: Record; +} diff --git a/backend/src/entities/table-filters/repository/table-filters-custom-repository-extension.ts b/backend/src/entities/table-filters/repository/table-filters-custom-repository-extension.ts new file mode 100644 index 000000000..f4f2f0f3d --- /dev/null +++ b/backend/src/entities/table-filters/repository/table-filters-custom-repository-extension.ts @@ -0,0 +1,12 @@ +import { TableFiltersEntity } from '../table-filters.entity.js'; +import { ITableFiltersCustomRepository } from './table-filters-custom-repository.interface.js'; + +export const tableFiltersCustomRepositoryExtension: ITableFiltersCustomRepository = { + async findTableFiltersForTableInConnection(tableName: string, connectionId: string): Promise { + const qb = this.createQueryBuilder('table_filters') + .leftJoin('table_filters.connection', 'connection') + .where('table_filters.table_name = :tableName', { tableName: tableName }) + .andWhere('connection.id = :connectionId', { connectionId: connectionId }); + return await qb.getOne(); + }, +}; diff --git a/backend/src/entities/table-filters/repository/table-filters-custom-repository.interface.ts b/backend/src/entities/table-filters/repository/table-filters-custom-repository.interface.ts new file mode 100644 index 000000000..a33c91b81 --- /dev/null +++ b/backend/src/entities/table-filters/repository/table-filters-custom-repository.interface.ts @@ -0,0 +1,5 @@ +import { TableFiltersEntity } from '../table-filters.entity.js'; + +export interface ITableFiltersCustomRepository { + findTableFiltersForTableInConnection(tableName: string, connectionId: string): Promise; +} diff --git a/backend/src/entities/table-filters/table-filters.controller.ts b/backend/src/entities/table-filters/table-filters.controller.ts new file mode 100644 index 000000000..4f57c32c0 --- /dev/null +++ b/backend/src/entities/table-filters/table-filters.controller.ts @@ -0,0 +1,126 @@ +import { + BadRequestException, + Body, + Controller, + Delete, + Get, + Inject, + Injectable, + Post, + UseGuards, + UseInterceptors, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { UseCaseType } from '../../common/data-injection.tokens.js'; +import { MasterPassword } from '../../decorators/master-password.decorator.js'; +import { QueryTableName } from '../../decorators/query-table-name.decorator.js'; +import { SlugUuid } from '../../decorators/slug-uuid.decorator.js'; +import { InTransactionEnum } from '../../enums/in-transaction.enum.js'; +import { Messages } from '../../exceptions/text/messages.js'; +import { ConnectionEditGuard } from '../../guards/connection-edit.guard.js'; +import { ConnectionReadGuard } from '../../guards/connection-read.guard.js'; +import { SentryInterceptor } from '../../interceptors/index.js'; +import { FindAllRowsWithBodyFiltersDto } from '../table/dto/find-rows-with-body-filters.dto.js'; +import { CreateTableFiltersDto } from './application/data-structures/create-table-filters.ds.js'; +import { FindTableFiltersDs } from './application/data-structures/find-table-filters.ds.js'; +import { CreatedTableFiltersRO } from './application/response-objects/created-table-filters.ro.js'; +import { + ICreateTableFilters, + IDeleteTableFilters, + IFindTableFilters, +} from './use-cases/table-filters-use-cases.interface.js'; + +@UseInterceptors(SentryInterceptor) +@Controller('table-filters') +@ApiBearerAuth() +@ApiTags('Table filters') +@Injectable() +export class TableFiltersController { + constructor( + @Inject(UseCaseType.CREATE_TABLE_FILTERS) + private readonly createTableFiltersUseCase: ICreateTableFilters, + @Inject(UseCaseType.FIND_TABLE_FILTERS) + private readonly findTableFiltersUseCase: IFindTableFilters, + @Inject(UseCaseType.DELETE_TABLE_FILTERS) + private readonly deleteTableFiltersUseCase: IDeleteTableFilters, + ) {} + + @ApiOperation({ summary: 'Add new table filters' }) + @ApiBody({ type: FindAllRowsWithBodyFiltersDto }) + @ApiResponse({ + status: 201, + description: 'Table filters created.', + type: CreatedTableFiltersRO, + }) + @ApiQuery({ name: 'tableName', required: true }) + @ApiParam({ name: 'connectionId', required: true }) + @UseGuards(ConnectionEditGuard) + @Post('/:connectionId') + async addTableFilters( + @QueryTableName() tableName: string, + @Body() body: FindAllRowsWithBodyFiltersDto, + @SlugUuid('connectionId') connectionId: string, + @MasterPassword() masterPwd: string, + ): Promise { + if (!tableName) { + throw new BadRequestException(Messages.TABLE_NAME_MISSING); + } + const inputData: CreateTableFiltersDto = { + table_name: tableName, + connection_id: connectionId, + filters: body.filters, + masterPwd: masterPwd, + }; + return await this.createTableFiltersUseCase.execute(inputData, InTransactionEnum.ON); + } + + @ApiOperation({ summary: 'Find table filters' }) + @ApiBody({ type: FindAllRowsWithBodyFiltersDto }) + @ApiResponse({ + status: 200, + description: 'Table filters found.', + type: CreatedTableFiltersRO, + }) + @ApiQuery({ name: 'tableName', required: true }) + @ApiParam({ name: 'connectionId', required: true }) + @UseGuards(ConnectionReadGuard) + @Get('/:connectionId') + async findTableFilters( + @QueryTableName() tableName: string, + @SlugUuid('connectionId') connectionId: string, + ): Promise { + if (!tableName) { + throw new BadRequestException(Messages.TABLE_NAME_MISSING); + } + const inputData: FindTableFiltersDs = { + table_name: tableName, + connection_id: connectionId, + }; + return await this.findTableFiltersUseCase.execute(inputData, InTransactionEnum.OFF); + } + + @ApiOperation({ summary: 'Delete table filters' }) + @ApiBody({ type: FindAllRowsWithBodyFiltersDto }) + @ApiResponse({ + status: 200, + description: 'Table filters Deleted.', + type: CreatedTableFiltersRO, + }) + @ApiQuery({ name: 'tableName', required: true }) + @ApiParam({ name: 'connectionId', required: true }) + @UseGuards(ConnectionEditGuard) + @Delete('/:connectionId') + async deleteTableFilters( + @QueryTableName() tableName: string, + @SlugUuid('connectionId') connectionId: string, + ): Promise { + if (!tableName) { + throw new BadRequestException(Messages.TABLE_NAME_MISSING); + } + const inputData: FindTableFiltersDs = { + table_name: tableName, + connection_id: connectionId, + }; + return await this.deleteTableFiltersUseCase.execute(inputData, InTransactionEnum.ON); + } +} diff --git a/backend/src/entities/table-filters/table-filters.entity.ts b/backend/src/entities/table-filters/table-filters.entity.ts new file mode 100644 index 000000000..2a8c46615 --- /dev/null +++ b/backend/src/entities/table-filters/table-filters.entity.ts @@ -0,0 +1,24 @@ +import { Column, Entity, JoinColumn, ManyToOne, PrimaryGeneratedColumn, Relation, Unique } from 'typeorm'; +import { ConnectionEntity } from '../connection/connection.entity.js'; + +@Entity('table_filters') +@Unique(['connectionId', 'table_name']) +export class TableFiltersEntity { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'jsonb', nullable: true }) + filters: Record; + + @Column({ default: null }) + table_name: string; + + @ManyToOne((_) => ConnectionEntity, (connection) => connection.table_filters, { + onDelete: 'CASCADE', + }) + @JoinColumn({ name: 'connectionId' }) + connection: Relation; + + @Column() + connectionId: string; +} diff --git a/backend/src/entities/table-filters/table-filters.module.ts b/backend/src/entities/table-filters/table-filters.module.ts new file mode 100644 index 000000000..54e50900f --- /dev/null +++ b/backend/src/entities/table-filters/table-filters.module.ts @@ -0,0 +1,46 @@ +import { MiddlewareConsumer, Module, RequestMethod } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { AuthMiddleware } from '../../authorization/auth.middleware.js'; +import { GlobalDatabaseContext } from '../../common/application/global-database-context.js'; +import { BaseType, UseCaseType } from '../../common/data-injection.tokens.js'; +import { TableFiltersController } from './table-filters.controller.js'; +import { TableFiltersEntity } from './table-filters.entity.js'; +import { CreateTableFiltersUseCase } from './use-cases/create-table-filters.use.case.js'; +import { UserEntity } from '../user/user.entity.js'; +import { LogOutEntity } from '../log-out/log-out.entity.js'; +import { FindTableFiltersUseCase } from './use-cases/find-table-filters.use.case.js'; +import { DeleteTableFiltersUseCase } from './use-cases/delete-table-filters.use.case.js'; + +@Module({ + imports: [TypeOrmModule.forFeature([TableFiltersEntity, UserEntity, LogOutEntity])], + providers: [ + { + provide: BaseType.GLOBAL_DB_CONTEXT, + useClass: GlobalDatabaseContext, + }, + { + provide: UseCaseType.CREATE_TABLE_FILTERS, + useClass: CreateTableFiltersUseCase, + }, + { + provide: UseCaseType.FIND_TABLE_FILTERS, + useClass: FindTableFiltersUseCase, + }, + { + provide: UseCaseType.DELETE_TABLE_FILTERS, + useClass: DeleteTableFiltersUseCase, + }, + ], + controllers: [TableFiltersController], +}) +export class TableFiltersModule { + public configure(consumer: MiddlewareConsumer): any { + consumer + .apply(AuthMiddleware) + .forRoutes( + { path: '/table-filters/:connectionId', method: RequestMethod.POST }, + { path: '/table-filters/:connectionId', method: RequestMethod.GET }, + { path: '/table-filters/:connectionId', method: RequestMethod.DELETE }, + ); + } +} diff --git a/backend/src/entities/table-filters/use-cases/create-table-filters.use.case.ts b/backend/src/entities/table-filters/use-cases/create-table-filters.use.case.ts new file mode 100644 index 000000000..4b6b710cf --- /dev/null +++ b/backend/src/entities/table-filters/use-cases/create-table-filters.use.case.ts @@ -0,0 +1,99 @@ +import { BadRequestException, Inject, Injectable, Scope } from '@nestjs/common'; +import { getDataAccessObject } from '@rocketadmin/shared-code/dist/src/data-access-layer/shared/create-data-access-object.js'; +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 { FilterCriteriaEnum } from '../../../enums/filter-criteria.enum.js'; +import { NonAvailableInFreePlanException } from '../../../exceptions/custom-exceptions/non-available-in-free-plan-exception.js'; +import { Messages } from '../../../exceptions/text/messages.js'; +import { validateStringWithEnum } from '../../../helpers/validators/validate-string-with-enum.js'; +import { ConnectionEntity } from '../../connection/connection.entity.js'; +import { CreateTableFiltersDto } from '../application/data-structures/create-table-filters.ds.js'; +import { CreatedTableFiltersRO } from '../application/response-objects/created-table-filters.ro.js'; +import { TableFiltersEntity } from '../table-filters.entity.js'; +import { ICreateTableFilters } from './table-filters-use-cases.interface.js'; + +@Injectable({ scope: Scope.REQUEST }) +export class CreateTableFiltersUseCase + extends AbstractUseCase + implements ICreateTableFilters +{ + constructor( + @Inject(BaseType.GLOBAL_DB_CONTEXT) + protected _dbContext: IGlobalDatabaseContext, + ) { + super(); + } + + protected async implementation(inputData: CreateTableFiltersDto): Promise { + const { table_name, connection_id, filters, masterPwd } = inputData; + + const foundConnection = await this._dbContext.connectionRepository.findAndDecryptConnection( + connection_id, + masterPwd, + ); + if (foundConnection.is_frozen) { + throw new NonAvailableInFreePlanException(Messages.CONNECTION_IS_FROZEN); + } + + const foundTableFilters = await this._dbContext.tableFiltersRepository.findTableFiltersForTableInConnection( + table_name, + connection_id, + ); + if (foundTableFilters) { + await this._dbContext.tableFiltersRepository.remove(foundTableFilters); + } + + const errors = await this.validateFiltersData(inputData, foundConnection); + if (errors.length > 0) { + throw new BadRequestException(errors.join(',\n')); + } + const newTableFilters = new TableFiltersEntity(); + newTableFilters.table_name = table_name; + newTableFilters.connection = foundConnection; + newTableFilters.filters = filters; + const savedTableFilters = await this._dbContext.tableFiltersRepository.save(newTableFilters); + return { + id: savedTableFilters.id, + tableName: table_name, + connectionId: foundConnection.id, + filters: newTableFilters.filters, + }; + } + + private async validateFiltersData( + inputData: CreateTableFiltersDto, + foundConnection: ConnectionEntity, + ): Promise> { + const { table_name, filters } = inputData; + const errors: Array = []; + try { + const dao = getDataAccessObject(foundConnection); + const tablesInConnection = await dao.getTablesFromDB(); + const tableNames = tablesInConnection.map((table) => table.tableName); + if (!tableNames.includes(table_name)) { + errors.push(Messages.TABLE_NOT_FOUND); + } + if (errors.length > 0) { + return errors; + } + const tableStructure = await dao.getTableStructure(table_name, null); + const tableColumnNames = tableStructure.map((el) => el.column_name); + + for (const column_name in filters) { + if (!tableColumnNames.includes(column_name)) { + errors.push(Messages.NO_SUCH_FIELD_IN_TABLE(column_name, table_name)); + } + // eslint-disable-next-line security/detect-object-injection + for (const filterCriteria in filters[column_name]) { + if (!validateStringWithEnum(filterCriteria, FilterCriteriaEnum)) { + errors.push(`Invalid filter criteria: "${filterCriteria}".`); + } + } + } + return errors; + } catch (error) { + throw error; + } + } +} diff --git a/backend/src/entities/table-filters/use-cases/delete-table-filters.use.case.ts b/backend/src/entities/table-filters/use-cases/delete-table-filters.use.case.ts new file mode 100644 index 000000000..c4a4f2cc4 --- /dev/null +++ b/backend/src/entities/table-filters/use-cases/delete-table-filters.use.case.ts @@ -0,0 +1,36 @@ +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 { FindTableFiltersDs } from '../application/data-structures/find-table-filters.ds.js'; +import { CreatedTableFiltersRO } from '../application/response-objects/created-table-filters.ro.js'; + +@Injectable({ scope: Scope.REQUEST }) +export class DeleteTableFiltersUseCase extends AbstractUseCase { + constructor( + @Inject(BaseType.GLOBAL_DB_CONTEXT) + protected _dbContext: IGlobalDatabaseContext, + ) { + super(); + } + + protected async implementation(inputData: FindTableFiltersDs): Promise { + const { table_name, connection_id } = inputData; + const foundTableFilters = await this._dbContext.tableFiltersRepository.findTableFiltersForTableInConnection( + table_name, + connection_id, + ); + const filtersResponseCopy = { ...foundTableFilters }; + if (!foundTableFilters) { + throw new NotFoundException(Messages.TABLE_FILTERS_NOT_FOUND); + } + await this._dbContext.tableFiltersRepository.remove(foundTableFilters); + return { + id: filtersResponseCopy.id, + tableName: filtersResponseCopy.table_name, + connectionId: filtersResponseCopy.connectionId, + filters: filtersResponseCopy.filters, + }; + } +} diff --git a/backend/src/entities/table-filters/use-cases/find-table-filters.use.case.ts b/backend/src/entities/table-filters/use-cases/find-table-filters.use.case.ts new file mode 100644 index 000000000..47c66f7eb --- /dev/null +++ b/backend/src/entities/table-filters/use-cases/find-table-filters.use.case.ts @@ -0,0 +1,38 @@ +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 { FindTableFiltersDs } from '../application/data-structures/find-table-filters.ds.js'; +import { CreatedTableFiltersRO } from '../application/response-objects/created-table-filters.ro.js'; +import { IFindTableFilters } from './table-filters-use-cases.interface.js'; + +@Injectable() +export class FindTableFiltersUseCase + extends AbstractUseCase + implements IFindTableFilters +{ + constructor( + @Inject(BaseType.GLOBAL_DB_CONTEXT) + protected _dbContext: IGlobalDatabaseContext, + ) { + super(); + } + + protected async implementation(inputData: FindTableFiltersDs): Promise { + const { table_name, connection_id } = inputData; + const foundTableFilters = await this._dbContext.tableFiltersRepository.findTableFiltersForTableInConnection( + table_name, + connection_id, + ); + if (!foundTableFilters) { + throw new NotFoundException(Messages.TABLE_FILTERS_NOT_FOUND); + } + return { + id: foundTableFilters.id, + tableName: table_name, + connectionId: foundTableFilters.connectionId, + filters: foundTableFilters.filters, + }; + } +} diff --git a/backend/src/entities/table-filters/use-cases/table-filters-use-cases.interface.ts b/backend/src/entities/table-filters/use-cases/table-filters-use-cases.interface.ts new file mode 100644 index 000000000..537992738 --- /dev/null +++ b/backend/src/entities/table-filters/use-cases/table-filters-use-cases.interface.ts @@ -0,0 +1,16 @@ +import { InTransactionEnum } from '../../../enums/in-transaction.enum.js'; +import { CreateTableFiltersDto } from '../application/data-structures/create-table-filters.ds.js'; +import { FindTableFiltersDs } from '../application/data-structures/find-table-filters.ds.js'; +import { CreatedTableFiltersRO } from '../application/response-objects/created-table-filters.ro.js'; + +export interface ICreateTableFilters { + execute(inputData: CreateTableFiltersDto, inTransaction: InTransactionEnum): Promise; +} + +export interface IFindTableFilters { + execute(inputData: FindTableFiltersDs, inTransaction: InTransactionEnum): Promise; +} + +export interface IDeleteTableFilters { + execute(inputData: FindTableFiltersDs, inTransaction: InTransactionEnum): Promise; +} diff --git a/backend/src/entities/table/application/data-structures/found-table-rows.ds.ts b/backend/src/entities/table/application/data-structures/found-table-rows.ds.ts index ca0ee03a9..22bb30f06 100644 --- a/backend/src/entities/table/application/data-structures/found-table-rows.ds.ts +++ b/backend/src/entities/table/application/data-structures/found-table-rows.ds.ts @@ -59,6 +59,9 @@ export class FoundTableRowsDs { @ApiProperty() allow_csv_import: boolean; + + @ApiProperty({ type: Object }) + saved_filters: Record; } export class TableStructureDs { diff --git a/backend/src/entities/table/use-cases/get-table-rows.use.case.ts b/backend/src/entities/table/use-cases/get-table-rows.use.case.ts index ff7883c0c..202ccd70f 100644 --- a/backend/src/entities/table/use-cases/get-table-rows.use.case.ts +++ b/backend/src/entities/table/use-cases/get-table-rows.use.case.ts @@ -93,6 +93,7 @@ export class GetTableRowsUseCase extends AbstractUseCase = isObjectEmpty(filters) ? findFilteringFieldsUtil(query, tableStructure) : parseFilteringFieldsFromBodyData(filters, tableStructure); + // if (!filteringFields.length && savedTableFilters) { + // const parsedSavedTableFilters = parseFilteringFieldsFromBodyData(savedTableFilters.filters, tableStructure); + // filteringFields.push(...parsedSavedTableFilters); + // } + const orderingField = findOrderingFieldUtil(query, tableStructure, tableSettings); const configured = !!tableSettings; @@ -232,6 +239,7 @@ export class GetTableRowsUseCase extends AbstractUseCase(); diff --git a/backend/src/exceptions/text/messages.ts b/backend/src/exceptions/text/messages.ts index 850696185..10e2caefe 100644 --- a/backend/src/exceptions/text/messages.ts +++ b/backend/src/exceptions/text/messages.ts @@ -78,7 +78,8 @@ export const Messages = { CONNECTION_IS_FROZEN: `Connection is frozen. (This connection type is not available in free plan)`, CONNECTION_NOT_CREATED: 'Connection was not successfully created.', CONNECTION_NOT_FOUND: 'Connection with specified parameters not found', - CONNECTION_NOT_FOUND_OR_USER_NOT_ADDED_IN_ANY_CONNECTION_GROUP: 'Connection not found or user not added in any group of this', + CONNECTION_NOT_FOUND_OR_USER_NOT_ADDED_IN_ANY_CONNECTION_GROUP: + 'Connection not found or user not added in any group of this', CONNECTION_NOT_ENCRYPTED: 'Connection is not encrypted', CONNECTION_MASTER_PASSWORD_NOT_SET: 'Connection master password is not set (or connection created before this feature)', @@ -258,6 +259,7 @@ export const Messages = { SSH_USERNAME_MISSING: 'Ssh username is missing', SSH_PASSWORD_MISSING: 'Ssh private key is missing', TABLE_ACTION_TYPE_INCORRECT: `Incorrect table action. Now we supports types: ${enumToString(TableActionTypeEnum)}`, + TABLE_FILTERS_NOT_FOUND: 'Table filters not found', TABLE_ID_MISSING: 'Table id is missing', TABLE_LOGS_NOT_FOUND: `Unable to find logs for this table`, TABLE_NAME_MISSING: 'Table name missing.', diff --git a/backend/src/migrations/1744468758029-AddTableFiltersEntity.ts b/backend/src/migrations/1744468758029-AddTableFiltersEntity.ts new file mode 100644 index 000000000..6d9b5e86d --- /dev/null +++ b/backend/src/migrations/1744468758029-AddTableFiltersEntity.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddTableFiltersEntity1744468758029 implements MigrationInterface { + name = 'AddTableFiltersEntity1744468758029'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "table_filters" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "filters" jsonb, "table_name" character varying, "connectionId" character varying NOT NULL, CONSTRAINT "UQ_86da06a7ee22ca03ec25bf681de" UNIQUE ("connectionId", "table_name"), CONSTRAINT "PK_3e08a731402b35d32fe6f062c13" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `ALTER TABLE "table_filters" ADD CONSTRAINT "FK_e4b16eddf2b9b90d5776b9652fc" FOREIGN KEY ("connectionId") REFERENCES "connection"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "table_filters" DROP CONSTRAINT "FK_e4b16eddf2b9b90d5776b9652fc"`); + await queryRunner.query(`DROP TABLE "table_filters"`); + } +} diff --git a/backend/test/ava-tests/saas-tests/connection-e2e.test.ts b/backend/test/ava-tests/saas-tests/connection-e2e.test.ts index 363ec23b3..2f0315ce1 100644 --- a/backend/test/ava-tests/saas-tests/connection-e2e.test.ts +++ b/backend/test/ava-tests/saas-tests/connection-e2e.test.ts @@ -388,7 +388,7 @@ test.serial( t.is(findOneConnectionRO.connection.hasOwnProperty('host'), false); t.is(findOneConnectionRO.connection.hasOwnProperty('port'), false); t.is(findOneConnectionRO.connection.hasOwnProperty('username'), false); - t.is(findOneConnectionRO.connection.hasOwnProperty('database'), false); + t.is(findOneConnectionRO.connection.hasOwnProperty('database'), true); t.is(findOneConnectionRO.connection.hasOwnProperty('sid'), false); t.is(findOneConnectionRO.connection.hasOwnProperty('password'), false); t.is(findOneConnectionRO.connection.hasOwnProperty('groups'), false); diff --git a/backend/test/ava-tests/saas-tests/table-filters-e2e-test.ts b/backend/test/ava-tests/saas-tests/table-filters-e2e-test.ts new file mode 100644 index 000000000..9edd6b924 --- /dev/null +++ b/backend/test/ava-tests/saas-tests/table-filters-e2e-test.ts @@ -0,0 +1,362 @@ +/* eslint-disable security/detect-non-literal-fs-filename */ +/* eslint-disable @typescript-eslint/no-unused-vars */ +/* eslint-disable security/detect-object-injection */ +import { faker } from '@faker-js/faker'; +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 fs from 'fs'; +import path, { join } from 'path'; +import request from 'supertest'; +import { fileURLToPath } from 'url'; +import { ApplicationModule } from '../../../src/app.module.js'; +import { LogOperationTypeEnum, 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 { Messages } from '../../../src/exceptions/text/messages.js'; +import { Cacher } from '../../../src/helpers/cache/cacher.js'; +import { Constants } from '../../../src/helpers/constants/constants.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'; +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.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 = `POST /table-filters/:connectionId`; +test.serial(`${currentTest} should return list of tables in connection`, async (t) => { + try { + const connectionToTestDB = getTestData(mockFactory).connectionToPostgres; + const firstUserToken = (await registerUserAndReturnUserInfo(app)).token; + const { testTableName } = 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 fieldname = 'id'; + const fieldvalue = '45'; + + const filters = { + [fieldname]: { lt: fieldvalue }, + }; + + const createTableFiltersResponse = await request(app.getHttpServer()) + .post(`/table-filters/${createConnectionRO.id}/?tableName=${testTableName}`) + .send({ filters }) + .set('Cookie', firstUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + const createTableFiltersRO = JSON.parse(createTableFiltersResponse.text); + t.is(createTableFiltersResponse.status, 201); + t.is(createTableFiltersRO.hasOwnProperty('id'), true); + t.is(createTableFiltersRO.hasOwnProperty('tableName'), true); + t.is(createTableFiltersRO.hasOwnProperty('connectionId'), true); + t.is(createTableFiltersRO.hasOwnProperty('filters'), true); + t.deepEqual(createTableFiltersRO.filters, filters); + + // should rewrite filters when pass new one + + const newFieldValue = '18'; + const newFilters = { + [fieldname]: { gt: newFieldValue }, + }; + const updateTableFiltersResponse = await request(app.getHttpServer()) + .post(`/table-filters/${createConnectionRO.id}/?tableName=${testTableName}`) + .send({ filters: newFilters }) + .set('Cookie', firstUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + const updateTableFiltersRO = JSON.parse(updateTableFiltersResponse.text); + t.is(updateTableFiltersResponse.status, 201); + t.is(updateTableFiltersRO.hasOwnProperty('id'), true); + t.is(updateTableFiltersRO.hasOwnProperty('tableName'), true); + t.is(updateTableFiltersRO.hasOwnProperty('connectionId'), true); + t.is(updateTableFiltersRO.hasOwnProperty('filters'), true); + t.deepEqual(updateTableFiltersRO.filters, newFilters); + } catch (e) { + console.error(e); + t.fail(); + } +}); + +currentTest = `GET /table-filters/:slug`; +test.serial(`${currentTest} should return table filters`, async (t) => { + try { + const connectionToTestDB = getTestData(mockFactory).connectionToPostgres; + const firstUserToken = (await registerUserAndReturnUserInfo(app)).token; + const { testTableName, testTableColumnName, testEntitiesSeedsCount, testTableSecondColumnName } = + 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 fieldname = 'id'; + const fieldvalue = '45'; + + const filters = { + [fieldname]: { lt: fieldvalue }, + }; + + const createTableFiltersResponse = await request(app.getHttpServer()) + .post(`/table-filters/${createConnectionRO.id}/?tableName=${testTableName}`) + .send({ filters }) + .set('Cookie', firstUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + const createTableFiltersRO = JSON.parse(createTableFiltersResponse.text); + t.is(createTableFiltersResponse.status, 201); + t.is(createTableFiltersRO.hasOwnProperty('id'), true); + t.is(createTableFiltersRO.hasOwnProperty('tableName'), true); + t.is(createTableFiltersRO.hasOwnProperty('connectionId'), true); + t.is(createTableFiltersRO.hasOwnProperty('filters'), true); + t.deepEqual(createTableFiltersRO.filters, filters); + + const getTableFiltersResponse = await request(app.getHttpServer()) + .get(`/table-filters/${createConnectionRO.id}/?tableName=${testTableName}`) + .set('Cookie', firstUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + const getTableFiltersRO = JSON.parse(getTableFiltersResponse.text); + t.is(getTableFiltersResponse.status, 200); + t.is(getTableFiltersRO.hasOwnProperty('id'), true); + t.is(getTableFiltersRO.hasOwnProperty('tableName'), true); + t.is(getTableFiltersRO.hasOwnProperty('connectionId'), true); + t.is(getTableFiltersRO.hasOwnProperty('filters'), true); + t.deepEqual(getTableFiltersRO.filters, filters); + } catch (e) { + console.error(e); + t.fail(); + } +}); + +currentTest = `DELETE /table-filters/:slug`; + +test.serial(`${currentTest} should delete table filters`, async (t) => { + try { + const connectionToTestDB = getTestData(mockFactory).connectionToPostgres; + const firstUserToken = (await registerUserAndReturnUserInfo(app)).token; + const { testTableName, testTableColumnName, testEntitiesSeedsCount, testTableSecondColumnName } = + 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 fieldname = 'id'; + const fieldvalue = '45'; + + const filters = { + [fieldname]: { lt: fieldvalue }, + }; + + const createTableFiltersResponse = await request(app.getHttpServer()) + .post(`/table-filters/${createConnectionRO.id}/?tableName=${testTableName}`) + .send({ filters }) + .set('Cookie', firstUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + const createTableFiltersRO = JSON.parse(createTableFiltersResponse.text); + t.is(createTableFiltersResponse.status, 201); + t.is(createTableFiltersRO.hasOwnProperty('id'), true); + t.is(createTableFiltersRO.hasOwnProperty('tableName'), true); + t.is(createTableFiltersRO.hasOwnProperty('connectionId'), true); + t.is(createTableFiltersRO.hasOwnProperty('filters'), true); + t.deepEqual(createTableFiltersRO.filters, filters); + + const deleteTableFiltersResponse = await request(app.getHttpServer()) + .delete(`/table-filters/${createConnectionRO.id}/?tableName=${testTableName}`) + .set('Cookie', firstUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + const deleteTableFiltersRO = JSON.parse(deleteTableFiltersResponse.text); + t.is(deleteTableFiltersResponse.status, 200); + t.is(deleteTableFiltersRO.hasOwnProperty('id'), true); + t.is(deleteTableFiltersRO.hasOwnProperty('tableName'), true); + t.is(deleteTableFiltersRO.hasOwnProperty('connectionId'), true); + t.is(deleteTableFiltersRO.connectionId, createConnectionRO.id); + t.is(deleteTableFiltersRO.hasOwnProperty('filters'), true); + t.deepEqual(deleteTableFiltersRO.filters, filters); + + // should throw an error when try to get deleted table filters + const getTableFiltersResponse = await request(app.getHttpServer()) + .get(`/table-filters/${createConnectionRO.id}/?tableName=${testTableName}`) + .set('Cookie', firstUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(getTableFiltersResponse.status, 404); + } catch (e) { + console.error(e); + t.fail(); + } +}); + +currentTest = 'GET /table/rows/:slug'; +test.serial( + `${currentTest} should return rows with search, with pagination, with sorting and with filters applied from created table filters + with search and DESC sorting`, + async (t) => { + try { + const connectionToTestDB = getTestData(mockFactory).connectionToPostgres; + const firstUserToken = (await registerUserAndReturnUserInfo(app)).token; + const { testTableName, testTableColumnName } = await createTestTable(connectionToTestDB); + + testTables.push(testTableName); + + 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 createTableSettingsDTO = mockFactory.generateTableSettings( + createConnectionRO.id, + testTableName, + [testTableColumnName], + undefined, + undefined, + 3, + QueryOrderingEnum.DESC, + 'id', + undefined, + undefined, + undefined, + undefined, + undefined, + ); + + const createTableSettingsResponse = await request(app.getHttpServer()) + .post(`/settings?connectionId=${createConnectionRO.id}&tableName=${testTableName}`) + .send(createTableSettingsDTO) + .set('Cookie', firstUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + t.is(createTableSettingsResponse.status, 201); + + const fieldname = 'id'; + const fieldvalue = '2'; + + const filters = { + [fieldname]: { gt: fieldvalue }, + }; + + const createTableFiltersResponse = await request(app.getHttpServer()) + .post(`/table-filters/${createConnectionRO.id}/?tableName=${testTableName}`) + .send({ filters }) + .set('Cookie', firstUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + const createTableFiltersRO = JSON.parse(createTableFiltersResponse.text); + t.is(createTableFiltersResponse.status, 201); + t.is(createTableFiltersRO.hasOwnProperty('id'), true); + t.is(createTableFiltersRO.hasOwnProperty('tableName'), true); + t.is(createTableFiltersRO.hasOwnProperty('connectionId'), true); + t.is(createTableFiltersRO.hasOwnProperty('filters'), true); + t.deepEqual(createTableFiltersRO.filters, filters); + + const getTableRowsResponse = await request(app.getHttpServer()) + .get( + `/table/rows/${createConnectionRO.id}?tableName=${testTableName}&search=${testSearchedUserName}&page=1&perPage=200`, + ) + .set('Cookie', firstUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + t.is(getTableRowsResponse.status, 200); + + const getTableRowsRO = JSON.parse(getTableRowsResponse.text); + + t.is(typeof getTableRowsRO, 'object'); + t.is(getTableRowsRO.hasOwnProperty('rows'), true); + t.is(getTableRowsRO.hasOwnProperty('primaryColumns'), true); + t.is(getTableRowsRO.hasOwnProperty('pagination'), true); + t.is(getTableRowsRO.rows.length, 3); + t.is(Object.keys(getTableRowsRO.rows[1]).length, 5); + + t.is(getTableRowsRO.rows[0][testTableColumnName], testSearchedUserName); + t.is(getTableRowsRO.rows[0].id, 38); + t.is(getTableRowsRO.rows[1][testTableColumnName], testSearchedUserName); + t.is(getTableRowsRO.rows[1].id, 22); + + t.is(getTableRowsRO.pagination.currentPage, 1); + t.is(getTableRowsRO.pagination.perPage, 200); + + t.is(typeof getTableRowsRO.primaryColumns, 'object'); + t.is(getTableRowsRO.primaryColumns[0].hasOwnProperty('column_name'), true); + t.is(getTableRowsRO.primaryColumns[0].hasOwnProperty('data_type'), true); + t.is(getTableRowsRO.hasOwnProperty('saved_filters'), true); + t.deepEqual(getTableRowsRO.saved_filters, filters); + } catch (error) { + console.error(error); + t.fail(); + } + }, +);