diff --git a/backend/src/common/data-injection.tokens.ts b/backend/src/common/data-injection.tokens.ts index 474e912c0..92cf20ff6 100644 --- a/backend/src/common/data-injection.tokens.ts +++ b/backend/src/common/data-injection.tokens.ts @@ -174,6 +174,7 @@ export enum UseCaseType { CREATE_UPDATE_TABLE_CATEGORIES = 'CREATE_UPDATE_TABLE_CATEGORIES', FIND_TABLE_CATEGORIES = 'FIND_TABLE_CATEGORIES', + FIND_TABLE_CATEGORIES_WITH_TABLES = 'FIND_TABLE_CATEGORIES_WITH_TABLES', CREATE_SECRET = 'CREATE_SECRET', GET_SECRETS = 'GET_SECRETS', diff --git a/backend/src/entities/table-categories/dto/found-table-categories-with-tables.ro.ts b/backend/src/entities/table-categories/dto/found-table-categories-with-tables.ro.ts new file mode 100644 index 000000000..34ab37382 --- /dev/null +++ b/backend/src/entities/table-categories/dto/found-table-categories-with-tables.ro.ts @@ -0,0 +1,16 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { FoundTableDs } from '../../table/application/data-structures/found-table.ds.js'; + +export class FoundTableCategoriesWithTablesRo { + @ApiProperty({ type: String }) + category_id: string; + + @ApiProperty({ type: String }) + category_name: string; + + @ApiProperty({ type: String }) + category_color: string; + + @ApiProperty({ isArray: true, type: FoundTableDs }) + tables: Array; +} diff --git a/backend/src/entities/table-categories/table-categories.controller.ts b/backend/src/entities/table-categories/table-categories.controller.ts index 46636f145..7b3b450e6 100644 --- a/backend/src/entities/table-categories/table-categories.controller.ts +++ b/backend/src/entities/table-categories/table-categories.controller.ts @@ -11,7 +11,14 @@ 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'; +import { + ICreateTableCategories, + IFindTableCategories, + IFindTableCategoriesWithTables, +} from './use-cases/table-categories-use-cases.interface.js'; +import { FoundTableCategoriesWithTablesRo } from './dto/found-table-categories-with-tables.ro.js'; +import { UserId } from '../../decorators/user-id.decorator.js'; +import { FindTablesDs } from '../table/application/data-structures/find-tables.ds.js'; @UseInterceptors(SentryInterceptor) @Timeout() @@ -25,6 +32,8 @@ export class TableCategoriesController { private readonly createTableCategoriesUseCase: ICreateTableCategories, @Inject(UseCaseType.FIND_TABLE_CATEGORIES) private readonly findTableCategoriesUseCase: IFindTableCategories, + @Inject(UseCaseType.FIND_TABLE_CATEGORIES_WITH_TABLES) + private readonly findTableCategoriesWithTablesUseCase: IFindTableCategoriesWithTables, ) {} @ApiOperation({ summary: 'Add new table categories' }) @@ -64,4 +73,28 @@ export class TableCategoriesController { async findTableCategories(@SlugUuid('connectionId') connectionId: string): Promise> { return await this.findTableCategoriesUseCase.execute(connectionId); } + + @ApiOperation({ summary: 'Find table categories with tables' }) + @ApiResponse({ + status: 200, + description: 'Table categories with tables found.', + type: FoundTableCategoriesWithTablesRo, + isArray: true, + }) + @ApiParam({ name: 'connectionId', required: true }) + @UseGuards(TablesReceiveGuard) + @Get('/v2/:connectionId') + async findTableCategoriesWithTAbles( + @SlugUuid('connectionId') connectionId: string, + @UserId() userId: string, + @MasterPassword() masterPwd: string, + ): Promise> { + const inputData: FindTablesDs = { + connectionId: connectionId, + hiddenTablesOption: false, + masterPwd: masterPwd, + userId: userId, + }; + return await this.findTableCategoriesWithTablesUseCase.execute(inputData); + } } diff --git a/backend/src/entities/table-categories/table-categories.module.ts b/backend/src/entities/table-categories/table-categories.module.ts index 352081e41..0663dbeb9 100644 --- a/backend/src/entities/table-categories/table-categories.module.ts +++ b/backend/src/entities/table-categories/table-categories.module.ts @@ -8,6 +8,7 @@ import { UserEntity } from '../user/user.entity.js'; import { TableCategoriesController } from './table-categories.controller.js'; import { CreateOrUpdateTableCategoriesUseCase } from './use-cases/create-or-update-table-categories.use.case.js'; import { FindTableCategoriesUseCase } from './use-cases/find-table-categories.use.case.js'; +import { FindTableCategoriesWithTablesUseCase } from './use-cases/find-table-categories-with-tables.use.case.js'; @Module({ imports: [TypeOrmModule.forFeature([UserEntity, LogOutEntity])], @@ -24,6 +25,10 @@ import { FindTableCategoriesUseCase } from './use-cases/find-table-categories.us provide: UseCaseType.CREATE_UPDATE_TABLE_CATEGORIES, useClass: CreateOrUpdateTableCategoriesUseCase, }, + { + provide: UseCaseType.FIND_TABLE_CATEGORIES_WITH_TABLES, + useClass: FindTableCategoriesWithTablesUseCase, + }, ], controllers: [TableCategoriesController], }) @@ -34,6 +39,7 @@ export class TableCategoriesModule { .forRoutes( { path: '/table-categories/:connectionId/', method: RequestMethod.GET }, { path: '/table-categories/:connectionId/', method: RequestMethod.PUT }, + { path: '/table-categories/v2/:connectionId/', method: RequestMethod.GET }, ); } } diff --git a/backend/src/entities/table-categories/use-cases/find-table-categories-with-tables.use.case.ts b/backend/src/entities/table-categories/use-cases/find-table-categories-with-tables.use.case.ts new file mode 100644 index 000000000..7f9d5b0a9 --- /dev/null +++ b/backend/src/entities/table-categories/use-cases/find-table-categories-with-tables.use.case.ts @@ -0,0 +1,211 @@ +import { HttpException, HttpStatus, Inject, Injectable, Scope } from '@nestjs/common'; +import { getDataAccessObject } from '@rocketadmin/shared-code/dist/src/data-access-layer/shared/create-data-access-object.js'; +import { TableDS } from '@rocketadmin/shared-code/dist/src/data-access-layer/shared/data-structures/table.ds.js'; +import * as Sentry from '@sentry/node'; +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 { AccessLevelEnum } from '../../../enums/access-level.enum.js'; +import { ExceptionOperations } from '../../../exceptions/custom-exceptions/exception-operation.js'; +import { UnknownSQLException } from '../../../exceptions/custom-exceptions/unknown-sql-exception.js'; +import { Messages } from '../../../exceptions/text/messages.js'; +import { isConnectionTypeAgent } from '../../../helpers/is-connection-entity-agent.js'; +import { isObjectPropertyExists } from '../../../helpers/validators/is-object-property-exists-validator.js'; +import { ConnectionEntity } from '../../connection/connection.entity.js'; +import { ITableAndViewPermissionData } from '../../permission/permission.interface.js'; +import { TableSettingsEntity } from '../../table-settings/common-table-settings/table-settings.entity.js'; +import { FindTablesDs } from '../../table/application/data-structures/find-tables.ds.js'; +import { FoundTableDs } from '../../table/application/data-structures/found-table.ds.js'; +import { FoundTableCategoriesWithTablesRo } from '../dto/found-table-categories-with-tables.ro.js'; +import { IFindTableCategoriesWithTables } from './table-categories-use-cases.interface.js'; + +@Injectable({ scope: Scope.REQUEST }) +export class FindTableCategoriesWithTablesUseCase + extends AbstractUseCase> + implements IFindTableCategoriesWithTables +{ + constructor( + @Inject(BaseType.GLOBAL_DB_CONTEXT) + protected _dbContext: IGlobalDatabaseContext, + ) { + super(); + } + + protected async implementation(inputData: FindTablesDs): Promise> { + const { connectionId, masterPwd, userId } = inputData; + let connection: ConnectionEntity; + try { + connection = await this._dbContext.connectionRepository.findAndDecryptConnection(connectionId, masterPwd); + } catch (error) { + if (error.message === Messages.MASTER_PASSWORD_MISSING) { + throw new HttpException( + { + message: Messages.MASTER_PASSWORD_MISSING, + type: 'no_master_key', + }, + HttpStatus.BAD_REQUEST, + ); + } + if (error.message === Messages.MASTER_PASSWORD_INCORRECT) { + throw new HttpException( + { + message: Messages.MASTER_PASSWORD_INCORRECT, + type: 'invalid_master_key', + }, + HttpStatus.BAD_REQUEST, + ); + } + } + if (!connection) { + throw new HttpException( + { + message: Messages.CONNECTION_NOT_FOUND, + }, + HttpStatus.BAD_REQUEST, + ); + } + const dao = getDataAccessObject(connection); + let userEmail: string; + + if (isConnectionTypeAgent(connection.type)) { + userEmail = await this._dbContext.userRepository.getUserEmailOrReturnNull(userId); + } + let tables: Array; + try { + tables = await dao.getTablesFromDB(userEmail); + } catch (e) { + Sentry.captureException(e); + throw new UnknownSQLException(e.message, ExceptionOperations.FAILED_TO_GET_TABLES); + } + + const tablesWithPermissions = await this.getUserPermissionsForAvailableTables(userId, connectionId, tables); + const excludedTables = await this._dbContext.connectionPropertiesRepository.findConnectionProperties(connectionId); + let tablesRO = await this.addDisplayNamesForTables(connectionId, tablesWithPermissions); + if (excludedTables?.hidden_tables?.length) { + tablesRO = tablesRO.filter((tableRO) => { + return !excludedTables.hidden_tables.includes(tableRO.table); + }); + } + + const foundTableCategories = + await this._dbContext.tableCategoriesRepository.findTableCategoriesForConnection(connectionId); + + const sortedTables = tablesRO.sort((tableRO1, tableRO2) => { + const name1 = tableRO1.display_name || tableRO1.table; + const name2 = tableRO2.display_name || tableRO2.table; + return name1.localeCompare(name2); + }); + + const allTableCategory: FoundTableCategoriesWithTablesRo = { + category_id: null, + category_color: null, + category_name: 'All tables', + tables: sortedTables, + }; + const foundTableCategoriesRO = foundTableCategories.map((category) => { + const tablesInCategory = sortedTables.filter((tableRO) => { + return category.tables.includes(tableRO.table); + }); + return { + category_id: category.category_id, + category_color: category.category_color, + category_name: category.category_name, + tables: tablesInCategory, + }; + }); + return [allTableCategory, ...foundTableCategoriesRO]; + } + + private async addDisplayNamesForTables( + connectionId: string, + tablesObjArr: Array, + ): Promise> { + const tableSettings = await this._dbContext.tableSettingsRepository.findTableSettingsInConnectionPure(connectionId); + return tablesObjArr.map((tableObj: ITableAndViewPermissionData) => { + const foundTableSettings = + tableSettings[ + tableSettings.findIndex((el: TableSettingsEntity) => { + return el.table_name === tableObj.tableName; + }) + ]; + const displayName = foundTableSettings ? foundTableSettings.display_name : undefined; + const icon = foundTableSettings ? foundTableSettings.icon : undefined; + return { + table: tableObj.tableName, + isView: tableObj.isView || false, + permissions: tableObj.accessLevel, + display_name: displayName, + icon: icon, + }; + }); + } + + private async getUserPermissionsForAvailableTables( + userId: string, + connectionId: string, + tables: Array, + ): Promise> { + const connectionEdit = await this._dbContext.userAccessRepository.checkUserConnectionEdit(userId, connectionId); + if (connectionEdit) { + return tables.map((table) => { + return { + tableName: table.tableName, + isView: table.isView, + accessLevel: { + visibility: true, + readonly: false, + add: true, + delete: true, + edit: true, + }, + }; + }); + } + + const allTablePermissions = + await this._dbContext.permissionRepository.getAllUserPermissionsForAllTablesInConnection(userId, connectionId); + const tablesAndAccessLevels = {}; + tables.map((table) => { + if (table.tableName !== '__proto__') { + tablesAndAccessLevels[table.tableName] = []; + } + }); + tables.map((table) => { + allTablePermissions.map((permission) => { + if ( + permission.tableName === table.tableName && + isObjectPropertyExists(tablesAndAccessLevels, table.tableName) + ) { + tablesAndAccessLevels[table.tableName].push(permission.accessLevel); + } + }); + }); + const tablesWithPermissions: Array = []; + for (const key in tablesAndAccessLevels) { + // eslint-disable-next-line security/detect-object-injection + const addPermission = tablesAndAccessLevels[key].includes(AccessLevelEnum.add); + // eslint-disable-next-line security/detect-object-injection + const deletePermission = tablesAndAccessLevels[key].includes(AccessLevelEnum.delete); + // eslint-disable-next-line security/detect-object-injection + const editPermission = tablesAndAccessLevels[key].includes(AccessLevelEnum.edit); + + const readOnly = !(addPermission || deletePermission || editPermission); + tablesWithPermissions.push({ + tableName: key, + isView: tables.find((table) => table.tableName === key).isView, + accessLevel: { + // eslint-disable-next-line security/detect-object-injection + visibility: tablesAndAccessLevels[key].includes(AccessLevelEnum.visibility), + // eslint-disable-next-line security/detect-object-injection + readonly: tablesAndAccessLevels[key].includes(AccessLevelEnum.readonly) && !readOnly, + add: addPermission, + delete: deletePermission, + edit: editPermission, + }, + }); + } + return tablesWithPermissions.filter((tableWithPermission: ITableAndViewPermissionData) => { + return !!tableWithPermission.accessLevel.visibility; + }); + } +} 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 index 69238c63c..4501a32e1 100644 --- 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 @@ -1,5 +1,7 @@ import { InTransactionEnum } from '../../../enums/in-transaction.enum.js'; +import { FindTablesDs } from '../../table/application/data-structures/find-tables.ds.js'; import { CreateOrUpdateTableCategoriesDS } from '../data-sctructures/create-or-update-table-categories.ds.js'; +import { FoundTableCategoriesWithTablesRo } from '../dto/found-table-categories-with-tables.ro.js'; import { FoundTableCategoryRo } from '../dto/found-table-category.ro.js'; export interface ICreateTableCategories { @@ -12,3 +14,7 @@ export interface ICreateTableCategories { export interface IFindTableCategories { execute(connectionId: string): Promise>; } + +export interface IFindTableCategoriesWithTables { + execute(inputData: FindTablesDs): Promise>; +} 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 index e0a591018..2677655dd 100644 --- a/backend/test/ava-tests/saas-tests/table-categories-e2e.test.ts +++ b/backend/test/ava-tests/saas-tests/table-categories-e2e.test.ts @@ -300,3 +300,91 @@ test.serial(`${currentTest} find table categories`, async (t) => { t.fail(); } }); + +currentTest = `GET /table-categories/v2/:connectionId`; +test.serial(`${currentTest} find table categories with tables`, async (t) => { + try { + const connectionToTestDB = getTestData(mockFactory).connectionToPostgres; + const firstUserToken = (await registerUserAndReturnUserInfo(app)).token; + const { testTableName: firstTestTableName } = await createTestTable(connectionToTestDB); + const { testTableName: secondTestTableName } = 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: [firstTestTableName], + category_id: 'cat-001', + }, + { + category_name: 'Category 2', + category_color: '#33FF57', + tables: [secondTestTableName], + category_id: 'cat-002', + }, + ]; + + 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 findTableCategoriesWithTablesResponse = await request(app.getHttpServer()) + .get(`/table-categories/v2/${createConnectionRO.id}`) + .set('Cookie', firstUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + const findTableCategoriesWithTablesRO = JSON.parse(findTableCategoriesWithTablesResponse.text); + + t.is(findTableCategoriesWithTablesResponse.status, 200); + + t.is(findTableCategoriesWithTablesRO[0].category_name, 'All tables'); + t.is(findTableCategoriesWithTablesRO[0].category_id, null); + t.is(findTableCategoriesWithTablesRO[0].category_color, null); + t.true(findTableCategoriesWithTablesRO[0].tables.length >= 2); + + const allTablesCategory = findTableCategoriesWithTablesRO[0]; + const firstTableInAll = allTablesCategory.tables.find((table) => table.table === firstTestTableName); + const secondTableInAll = allTablesCategory.tables.find((table) => table.table === secondTestTableName); + + t.truthy(firstTableInAll); + t.truthy(secondTableInAll); + t.is(typeof firstTableInAll.isView, 'boolean'); + t.truthy(firstTableInAll.permissions); + t.is(typeof firstTableInAll.permissions.visibility, 'boolean'); + t.is(typeof firstTableInAll.permissions.readonly, 'boolean'); + t.is(typeof firstTableInAll.permissions.add, 'boolean'); + t.is(typeof firstTableInAll.permissions.delete, 'boolean'); + t.is(typeof firstTableInAll.permissions.edit, 'boolean'); + + t.is(findTableCategoriesWithTablesRO.length, 3); + + t.is(findTableCategoriesWithTablesRO[1].category_name, 'Category 1'); + t.is(findTableCategoriesWithTablesRO[1].category_color, '#FF5733'); + t.is(findTableCategoriesWithTablesRO[1].category_id, 'cat-001'); + t.is(findTableCategoriesWithTablesRO[1].tables.length, 1); + t.is(findTableCategoriesWithTablesRO[1].tables[0].table, firstTestTableName); + + t.is(findTableCategoriesWithTablesRO[2].category_name, 'Category 2'); + t.is(findTableCategoriesWithTablesRO[2].category_color, '#33FF57'); + t.is(findTableCategoriesWithTablesRO[2].category_id, 'cat-002'); + t.is(findTableCategoriesWithTablesRO[2].tables.length, 1); + t.is(findTableCategoriesWithTablesRO[2].tables[0].table, secondTestTableName); + } catch (e) { + console.error(e); + t.fail(); + } +});