Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions backend/src/common/data-injection.tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Comment on lines +5 to +6

Copilot AI Feb 12, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

category_id is returned as null for the "All tables" category (see use case), but this response model declares it as non-nullable string. Update the DTO type to string | null and mark it nullable in the Swagger decorator.

Suggested change
@ApiProperty({ type: String })
category_id: string;
@ApiProperty({ type: String, nullable: true })
category_id: string | null;

Copilot uses AI. Check for mistakes.

@ApiProperty({ type: String })
category_name: string;

@ApiProperty({ type: String })
category_color: string;
Comment on lines +11 to +12

Copilot AI Feb 12, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

category_color is returned as null for the "All tables" category (see use case), but this response model declares it as non-nullable string. Update the DTO type to string | null and mark it nullable in the Swagger decorator.

Suggested change
@ApiProperty({ type: String })
category_color: string;
@ApiProperty({ type: String, nullable: true })
category_color: string | null;

Copilot uses AI. Check for mistakes.

@ApiProperty({ isArray: true, type: FoundTableDs })
tables: Array<FoundTableDs>;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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' })
Expand Down Expand Up @@ -64,4 +73,28 @@ export class TableCategoriesController {
async findTableCategories(@SlugUuid('connectionId') connectionId: string): Promise<Array<FoundTableCategoryRo>> {
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(

Copilot AI Feb 12, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Method name findTableCategoriesWithTAbles has inconsistent casing (capital "A" in the middle), which makes the API surface harder to scan and search. Rename to standard lowerCamelCase (e.g., findTableCategoriesWithTables).

Suggested change
async findTableCategoriesWithTAbles(
async findTableCategoriesWithTables(

Copilot uses AI. Check for mistakes.
@SlugUuid('connectionId') connectionId: string,
@UserId() userId: string,
@MasterPassword() masterPwd: string,
): Promise<Array<FoundTableCategoriesWithTablesRo>> {
const inputData: FindTablesDs = {
connectionId: connectionId,
hiddenTablesOption: false,
masterPwd: masterPwd,
userId: userId,
};
return await this.findTableCategoriesWithTablesUseCase.execute(inputData);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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])],
Expand All @@ -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],
})
Expand All @@ -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 },
);
}
}
Original file line number Diff line number Diff line change
@@ -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<FindTablesDs, Array<FoundTableCategoriesWithTablesRo>>
implements IFindTableCategoriesWithTables
{
constructor(
@Inject(BaseType.GLOBAL_DB_CONTEXT)
protected _dbContext: IGlobalDatabaseContext,
) {
super();
}

protected async implementation(inputData: FindTablesDs): Promise<Array<FoundTableCategoriesWithTablesRo>> {
const { connectionId, masterPwd, userId } = inputData;

Copilot AI Feb 12, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FindTablesDs includes hiddenTablesOption, but this use case ignores it (it destructures only connectionId, masterPwd, userId) and always filters out hidden tables. Either honor hiddenTablesOption (including the same permission/guard behavior as the existing find-tables use cases) or remove the field from this endpoint’s input to avoid misleading callers.

Suggested change
const { connectionId, masterPwd, userId } = inputData;
const { connectionId, masterPwd, userId, hiddenTablesOption } = inputData;
if (typeof hiddenTablesOption !== 'undefined' && hiddenTablesOption !== null) {
// This use case intentionally always excludes hidden tables.
// To avoid misleading callers, explicitly reject requests that try to
// control hidden table visibility via `hiddenTablesOption`.
throw new HttpException(
{
message: 'The hiddenTablesOption parameter is not supported for this endpoint.',
},
HttpStatus.BAD_REQUEST,
);
}

Copilot uses AI. Check for mistakes.
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<TableDS>;
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;
Comment on lines +90 to +94

Copilot AI Feb 12, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

findTableCategoriesForConnection() has no ORDER BY, so category order in the response can be nondeterministic. To keep the API output stable (and avoid flaky tests), sort the returned categories (e.g., by category_name/category_id) or add an orderBy in the repository query.

Copilot uses AI. Check for mistakes.
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<ITableAndViewPermissionData>,
): Promise<Array<FoundTableDs>> {
const tableSettings = await this._dbContext.tableSettingsRepository.findTableSettingsInConnectionPure(connectionId);
Comment on lines +119 to +123

Copilot AI Feb 12, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This use case duplicates substantial logic from the existing table listing use cases (e.g., display-name enrichment and permissions calculation). Keeping multiple copies increases the risk of behavior drift when one copy changes. Consider extracting these helpers into a shared service/util or reusing the existing implementation where possible.

Copilot uses AI. Check for mistakes.
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<TableDS>,
): Promise<Array<ITableAndViewPermissionData>> {
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<ITableAndViewPermissionData> = [];
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;
});
}
}
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -12,3 +14,7 @@ export interface ICreateTableCategories {
export interface IFindTableCategories {
execute(connectionId: string): Promise<Array<FoundTableCategoryRo>>;
}

export interface IFindTableCategoriesWithTables {
execute(inputData: FindTablesDs): Promise<Array<FoundTableCategoriesWithTablesRo>>;
}
Loading
Loading