diff --git a/src/adapters/postgres/fields-adapter.ts b/src/adapters/postgres/fields-adapter.ts index 4563c3ae..7c0c8964 100644 --- a/src/adapters/postgres/fields-adapter.ts +++ b/src/adapters/postgres/fields-adapter.ts @@ -26,13 +26,16 @@ import { LoggerUtil } from "src/common/logger/LoggerUtil"; import { API_RESPONSES } from "@utils/response.messages"; import { FieldValuesDeleteDto } from "src/fields/dto/field-values-delete.dto"; import { check } from "prettier"; +import { Tenants } from "src/userTenantMapping/entities/tenant.entity"; @Injectable() export class PostgresFieldsService implements IServicelocatorfields { constructor( @InjectRepository(Fields) private fieldsRepository: Repository, @InjectRepository(FieldValues) - private fieldsValuesRepository: Repository + private fieldsValuesRepository: Repository, + @InjectRepository(Tenants) + private tenantsRepository: Repository ) { } async getFormCustomField(requiredData, response) { @@ -1864,7 +1867,8 @@ export class PostgresFieldsService implements IServicelocatorfields { public async getCustomFieldDetails( itemId: string, tableName: string, - fieldOption?: boolean + fieldOption?: boolean, + tenantId?: string ) { let joinCond; if (tableName === "Users") { @@ -1873,24 +1877,47 @@ export class PostgresFieldsService implements IServicelocatorfields { joinCond = `fv."itemId" = u."cohortId"`; } try { + let tenantFilter = ''; + let queryParams: any[] = [itemId]; + + // If tenantId is provided, fetch parent tenant ID and build filter + if (tenantId) { + const tenant = await this.tenantsRepository.findOne({ + where: { tenantId: tenantId } + }); + + if (tenant?.parentId) { + // Include both tenant and parent tenant in filter + tenantFilter = 'AND f."tenantId" IN ($2, $3)'; + queryParams = [itemId, tenantId, tenant.parentId]; + LoggerUtil.log(`Fetching custom fields for tenant ${tenantId} and parent ${tenant.parentId}`); + } else { + // Only tenant filter + tenantFilter = 'AND f."tenantId" = $2'; + queryParams = [itemId, tenantId]; + LoggerUtil.log(`Fetching custom fields for tenant ${tenantId} only`); + } + } + const query = ` - SELECT DISTINCT - f."fieldId", - f."label", - fv."value", - f."type", - f."fieldParams", - f."sourceDetails" - FROM public."${tableName}" u - LEFT JOIN ( - SELECT DISTINCT ON (fv."fieldId", fv."itemId") fv.* - FROM public."FieldValues" fv - ) fv ON ${joinCond} - INNER JOIN public."Fields" f ON fv."fieldId" = f."fieldId" - WHERE fv."itemId" = $1; - `; - - let result = await this.fieldsRepository.query(query, [itemId]); + SELECT DISTINCT + f."fieldId", + f."label", + fv."value", + f."type", + f."fieldParams", + f."sourceDetails", + f."tenantId" + FROM public."${tableName}" u + LEFT JOIN ( + SELECT DISTINCT ON (fv."fieldId", fv."itemId") fv.* + FROM public."FieldValues" fv + ) fv ON ${joinCond} + INNER JOIN public."Fields" f ON fv."fieldId" = f."fieldId" + WHERE fv."itemId" = $1 ${tenantFilter}; + `; + + let result = await this.fieldsRepository.query(query, queryParams); result = result.map(async (data) => { const allIds = data.value; let optionValues; @@ -1982,6 +2009,179 @@ export class PostgresFieldsService implements IServicelocatorfields { } } + /** + * Batch fetch custom fields for multiple items (optimized for N+1 query problem) + * @param itemIds - Array of item IDs (userIds or cohortIds) + * @param tableName - Table name ('Users' or 'Cohort') + * @returns Object mapping itemId to their custom fields array + */ + public async getBulkCustomFieldDetails( + itemIds: string[], + tableName: string, + tenantId?: string + ): Promise> { + if (!itemIds || itemIds.length === 0) { + return {}; + } + + let joinCond: string; + if (tableName === "Users") { + joinCond = `fv."itemId" = u."userId"`; + } else if (tableName === "Cohort") { + joinCond = `fv."itemId" = u."cohortId"`; + } + + try { + let tenantFilter = ''; + let fieldTenantFilter = ''; + let queryParams: any[] = [itemIds]; + + // If tenantId is provided, fetch parent tenant ID and build filter + if (tenantId) { + const tenant = await this.tenantsRepository.findOne({ + where: { tenantId: tenantId } + }); + + if (tenant?.parentId) { + // Include both tenant and parent tenant in filter + tenantFilter = 'AND fv."tenantId" IN ($2, $3)'; + queryParams = [itemIds, tenantId, tenant.parentId]; + } else { + // Only tenant filter + tenantFilter = 'AND fv."tenantId" = $2'; + queryParams = [itemIds, tenantId]; + } + } + + const query = ` + SELECT DISTINCT + fv."itemId", + f."fieldId", + f."label", + fv."value", + f."type", + f."fieldParams", + f."sourceDetails", + f."tenantId" + FROM public."${tableName}" u + LEFT JOIN ( + SELECT DISTINCT ON (fv."fieldId", fv."itemId") fv.* + FROM public."FieldValues" fv + WHERE fv."itemId" = ANY($1) ${tenantFilter} + ) fv ON ${joinCond} + INNER JOIN public."Fields" f ON fv."fieldId" = f."fieldId" + WHERE fv."itemId" = ANY($1) ${fieldTenantFilter} + ORDER BY fv."itemId", f."fieldId"; + `; + console.log("query -->> ", query); + console.log("queryParams -->> ", queryParams); + let results = await this.fieldsRepository.query(query, queryParams); + + // Process all results + const processedResults = await Promise.all( + results.map(async (data) => { + const allIds = data.value; + let processedValue = []; + let allSelectedValues; + const selectedValues = data.value; + const allFieldsOptions = data?.fieldParams?.options + ? data.fieldParams.options + : null; + + if (data.sourceDetails) { + if (data.sourceDetails.source === "fieldparams") { + allFieldsOptions.forEach((option) => { + const selectedOptionKey = option.value; + + if (data.type === "checkbox" || data.type === "drop_down") { + if (selectedValues.includes(selectedOptionKey)) { + allSelectedValues = { + id: option?.value, + value: option?.value, + label: option?.label, + }; + processedValue.push(allSelectedValues); + } + } else { + if (selectedValues.includes(selectedOptionKey)) { + allSelectedValues = { + id: option?.name, + value: option?.value, + label: option?.label, + order: option?.order, + }; + processedValue.push(allSelectedValues); + } + } + }); + } else if (data.sourceDetails.source === "table") { + const whereCond = `"${data.sourceDetails.table}_id" IN (${allIds})`; + const labels = await this.findDynamicOptions( + data.sourceDetails.table, + whereCond + ); + const tableName = data.sourceDetails.table; + + const idField = `${tableName}_id`; + const nameField = `${tableName}_name`; + + processedValue = labels.map((data) => ({ + id: data[idField], + value: data[nameField], + })); + } else if (data.sourceDetails?.externalsource) { + processedValue = data?.value; + } + } else { + processedValue = selectedValues; + } + + return { + itemId: data.itemId, + fieldId: data.fieldId, + label: data.label, + type: data.type, + selectedValues: processedValue, + }; + }) + ); + + // Group by itemId + const groupedByItemId: Record = {}; + + // Initialize all itemIds with empty arrays + itemIds.forEach(itemId => { + groupedByItemId[itemId] = []; + }); + + // Group results by itemId + processedResults.forEach((field) => { + if (!groupedByItemId[field.itemId]) { + groupedByItemId[field.itemId] = []; + } + groupedByItemId[field.itemId].push({ + fieldId: field.fieldId, + label: field.label, + selectedValues: field.selectedValues, + type: field.type, + }); + }); + + return groupedByItemId; + } catch (error) { + LoggerUtil.error( + `${API_RESPONSES.SERVER_ERROR}`, + `Error in getBulkCustomFieldDetails: ${error.message}` + ); + // Return empty object for all items on error + const emptyResult: Record = {}; + itemIds.forEach(itemId => { + emptyResult[itemId] = []; + }); + return emptyResult; + } + } + public async getFieldsByIds(fieldIds: string[]) { return this.fieldsRepository.find({ where: { diff --git a/src/adapters/postgres/user-adapter.ts b/src/adapters/postgres/user-adapter.ts index 63e6aea2..34566e55 100644 --- a/src/adapters/postgres/user-adapter.ts +++ b/src/adapters/postgres/user-adapter.ts @@ -50,6 +50,7 @@ import { randomInt } from 'crypto'; import { UUID } from "aws-sdk/clients/cloudtrail"; import { AutomaticMemberService } from "src/automatic-member/automatic-member.service"; import { KafkaService } from "src/kafka/kafka.service"; +import { isAllowedTenant } from "src/config/tenant.config"; interface UpdateField { userId: string; // Required @@ -356,11 +357,12 @@ export class PostgresUserService implements IServicelocator { tenantId: string, request: any, response: any, - userSearchDto: UserSearchDto + userSearchDto: UserSearchDto, + includeCustomFields: boolean = true ) { const apiId = APIID.USER_LIST; try { - const findData = await this.findAllUserDetails(userSearchDto, tenantId); + const findData = await this.findAllUserDetails(userSearchDto, tenantId, includeCustomFields); if (findData === false) { LoggerUtil.error( @@ -402,8 +404,47 @@ export class PostgresUserService implements IServicelocator { } } + /** + * Multi-tenant user list service function + * Calls the existing searchUser function + */ + async searchUserMultiTenant( + tenantId: string, + request: any, + response: any, + userSearchDto: UserSearchDto + ) { + const apiId = APIID.USER_HIERARCHY_VIEW; + + let searchUserData = await this.findAllUserDetails(userSearchDto, null, false); + + if (!(searchUserData && searchUserData.getUserDetails?.length)) { + return APIResponse.error( + response, + apiId, + API_RESPONSES.USER_NOT_FOUND, + API_RESPONSES.NOT_FOUND, + HttpStatus.NOT_FOUND + ); + } + // Fetch and assign custom fields for each user + for (let user of searchUserData.getUserDetails) { + const parentTenantCustomFieldData = await this.fieldsService.getCustomFieldDetails(user.userId, 'Users', false, tenantId); + user.customFields = parentTenantCustomFieldData || []; + } + + LoggerUtil.log(API_RESPONSES.USER_HIERARCHY_VIEW_SUCCESS, apiId); + return await APIResponse.success( + response, + apiId, + searchUserData, + HttpStatus.OK, + API_RESPONSES.USER_HIERARCHY_VIEW_SUCCESS + ); + } + - async findAllUserDetails(userSearchDto, tenantId?: string) { + async findAllUserDetails(userSearchDto, tenantId?: string, includeCustomFields: boolean = true) { let { limit, offset, filters, exclude, sort } = userSearchDto; let excludeCohortIdes; let excludeUserIdes; @@ -574,7 +615,7 @@ export class PostgresUserService implements IServicelocator { } //Get user core fields data - const query = `SELECT U."userId",U."enrollmentId", U."username",U."email", U."firstName", U."name",UTM."tenantId", U."middleName", U."lastName", U."gender", U."dob", R."name" AS role, U."mobile", U."createdBy",U."updatedBy", U."createdAt", U."updatedAt", U."status", COUNT(*) OVER() AS total_count + const query = `SELECT U."userId",U."enrollmentId", U."username",U."email", U."firstName", U."name",UTM."tenantId", U."middleName", U."lastName", U."gender", U."dob", R."name" AS role, U."mobile", U."createdBy",U."updatedBy", U."createdAt", U."updatedAt", U."status", UTM."status" AS "platformStatus", COUNT(*) OVER() AS total_count FROM public."Users" U LEFT JOIN public."CohortMembers" CM ON CM."userId" = U."userId" @@ -583,28 +624,41 @@ export class PostgresUserService implements IServicelocator { LEFT JOIN public."UserTenantMapping" UTM ON UTM."userId" = U."userId" LEFT JOIN public."Roles" R - ON R."roleId" = UR."roleId" ${whereCondition} GROUP BY U."userId",UTM."tenantId", R."name" ${orderingCondition} ${offset} ${limit}`; + ON R."roleId" = UR."roleId" ${whereCondition} GROUP BY U."userId",UTM."tenantId", UTM."status", R."name" ${orderingCondition} ${offset} ${limit}`; const userDetails = await this.usersRepository.query(query); if (userDetails.length > 0) { result.totalCount = parseInt(userDetails[0].total_count, 10); - // Get user custom field data - for (const userData of userDetails) { - const customFields = await this.fieldsService.getCustomFieldDetails( - userData.userId, 'Users' + // OPTIMIZED: Conditionally fetch custom fields only when requested + if (includeCustomFields) { + // OPTIMIZED: Batch fetch custom fields for all users in one query (instead of N+1 queries) + const userIds = userDetails.map(user => user.userId); + const bulkCustomFields = await this.fieldsService.getBulkCustomFieldDetails( + userIds, 'Users',tenantId ); - userData["customFields"] = Array.isArray(customFields) - ? customFields.map((data) => ({ - fieldId: data?.fieldId, - label: data?.label, - selectedValues: data?.selectedValues, - type: data?.type, - })) - : []; + // Map custom fields back to users (in-memory operation - fast!) + for (const userData of userDetails) { + const customFields = bulkCustomFields[userData.userId] || []; + + userData["customFields"] = Array.isArray(customFields) + ? customFields.map((data) => ({ + fieldId: data?.fieldId, + label: data?.label, + selectedValues: data?.selectedValues, + type: data?.type, + })) + : []; - result.getUserDetails.push(userData); + result.getUserDetails.push(userData); + } + } else { + // Skip custom fields fetch - much faster for listing + for (const userData of userDetails) { + userData["customFields"] = []; + result.getUserDetails.push(userData); + } } } else { return false; @@ -980,7 +1034,7 @@ export class PostgresUserService implements IServicelocator { if (userDto?.customFields?.length > 0) { // additionalData?: { tenantId?: string, contextType?: string, createdBy?: string, updatedBy?: string } let additionalData = { - tenantId : userDto.userData?.tenantId, + tenantId: userDto.userData?.tenantId, contextType: "USER", createdBy: userDto.userData?.createdBy, updatedBy: userDto.userData?.updatedBy @@ -1526,7 +1580,7 @@ export class PostgresUserService implements IServicelocator { // Prepare additional data for FieldValues table const additionalData = { - tenantId: userCreateDto.tenantCohortRoleMapping?.[0]?.tenantId || null, + tenantId: userCreateDto.tenantCohortRoleMapping?.[0]?.tenantId || null, contextType: "USER", createdBy: userCreateDto.createdBy, updatedBy: userCreateDto.updatedBy, @@ -1902,27 +1956,98 @@ export class PostgresUserService implements IServicelocator { } - async assignUserToTenantAndRoll(tenantsData, createdBy) { + async assignUserToTenantAndRoll(tenantsData, createdBy, userType?: string) { try { const tenantId = tenantsData?.tenantRoleMapping?.tenantId; const userId = tenantsData?.userId; const roleId = tenantsData?.tenantRoleMapping?.roleId; if (roleId) { - const data = await this.userRoleMappingRepository.save({ - userId: userId, - tenantId: tenantId, - roleId: roleId, - createdBy: createdBy, - }); + // Check if userType is 'assignTenant' and handle accordingly + if (userType === 'assignedUserToChildTenant') { + // Find existing role mapping for this user + const existingRoleMapping = await this.userRoleMappingRepository.findOne({ + where: { userId: userId }, + }); + + if (existingRoleMapping) { + // Check if existing tenantId matches the config file tenant + if (isAllowedTenant(existingRoleMapping.tenantId)) { + // Update existing mapping with new tenantId and roleId + existingRoleMapping.tenantId = tenantId; + existingRoleMapping.roleId = roleId; + existingRoleMapping.createdBy = createdBy; + await this.userRoleMappingRepository.save(existingRoleMapping); + LoggerUtil.log(`Updated role mapping for user ${userId} from tenant ${existingRoleMapping.tenantId} to ${tenantId}`); + } else { + // Create new mapping if existing tenant is not in config + await this.userRoleMappingRepository.save({ + userId: userId, + tenantId: tenantId, + roleId: roleId, + createdBy: createdBy, + }); + } + } else { + // No existing mapping, create new one + await this.userRoleMappingRepository.save({ + userId: userId, + tenantId: tenantId, + roleId: roleId, + createdBy: createdBy, + }); + } + } else { + // Default behavior - create new mapping + const data = await this.userRoleMappingRepository.save({ + userId: userId, + tenantId: tenantId, + roleId: roleId, + createdBy: createdBy, + }); + } } if (tenantId) { - const data = await this.userTenantMappingRepository.save({ - userId: userId, - tenantId: tenantId, - createdBy: createdBy, - }); + // Check if userType is 'assignTenant' and handle accordingly + if (userType === 'assignedUserToChildTenant') { + // Find existing tenant mapping for this user + const existingMapping = await this.userTenantMappingRepository.findOne({ + where: { userId: userId }, + }); + + if (existingMapping) { + // Check if existing tenantId matches the config file tenant + if (isAllowedTenant(existingMapping.tenantId)) { + // Update existing mapping with new tenantId + existingMapping.tenantId = tenantId; + existingMapping.createdBy = createdBy; + await this.userTenantMappingRepository.save(existingMapping); + LoggerUtil.log(`Updated tenant mapping for user ${userId} from ${existingMapping.tenantId} to ${tenantId}`); + } else { + // Create new mapping if existing tenant is not in config + await this.userTenantMappingRepository.save({ + userId: userId, + tenantId: tenantId, + createdBy: createdBy, + }); + } + } else { + // No existing mapping, create new one + await this.userTenantMappingRepository.save({ + userId: userId, + tenantId: tenantId, + createdBy: createdBy, + }); + } + } else { + // Default behavior - create new mapping + const data = await this.userTenantMappingRepository.save({ + userId: userId, + tenantId: tenantId, + createdBy: createdBy, + }); + } } LoggerUtil.log(API_RESPONSES.USER_TENANT); diff --git a/src/adapters/postgres/userTenantMapping-adapter.ts b/src/adapters/postgres/userTenantMapping-adapter.ts index c1646def..383854d7 100644 --- a/src/adapters/postgres/userTenantMapping-adapter.ts +++ b/src/adapters/postgres/userTenantMapping-adapter.ts @@ -141,7 +141,8 @@ export class PostgresAssignTenantService await this.postgresUserService.assignUserToTenantAndRoll( tenantsData, - request["user"].userId + request["user"].userId, + 'assignedUserToChildTenant' ); LoggerUtil.log( diff --git a/src/adapters/userservicelocator.ts b/src/adapters/userservicelocator.ts index b20d5609..ac494d87 100644 --- a/src/adapters/userservicelocator.ts +++ b/src/adapters/userservicelocator.ts @@ -22,6 +22,13 @@ export interface IServicelocator { createUser(request: any, userDto: UserCreateDto, academicYearId: string, response: Response); findUserDetails(userID: any, username: string, tenantId?: string); searchUser( + tenantId: string, + request: any, + response: any, + userSearchDto: UserSearchDto, + includeCustomFields?: boolean + ); + searchUserMultiTenant( tenantId: string, request: any, response: any, diff --git a/src/common/decorators/getTenantId.decorator.ts b/src/common/decorators/getTenantId.decorator.ts new file mode 100644 index 00000000..f436d0ab --- /dev/null +++ b/src/common/decorators/getTenantId.decorator.ts @@ -0,0 +1,27 @@ +import { createParamDecorator, ExecutionContext, BadRequestException } from '@nestjs/common'; +import { isUUID } from 'class-validator'; + +export const GetTenantId = createParamDecorator( + (data: unknown, ctx: ExecutionContext): string => { + const request = ctx.switchToHttp().getRequest(); + const tenantId = request.headers.tenantid; + + // Check if tenantId is present + if (!tenantId) { + throw new BadRequestException('tenantid header is required'); + } + + // Check if tenantId is a non-empty string + if (typeof tenantId !== 'string' || tenantId.trim().length === 0) { + throw new BadRequestException('tenantid must be a non-empty string'); + } + + // Check if tenantId is a valid UUID format + if (!isUUID(tenantId)) { + throw new BadRequestException('tenantid must be a valid UUID format'); + } + + return tenantId; + }, +); + diff --git a/src/common/utils/api-id.config.ts b/src/common/utils/api-id.config.ts index db03fde4..3174cae6 100644 --- a/src/common/utils/api-id.config.ts +++ b/src/common/utils/api-id.config.ts @@ -4,6 +4,7 @@ export const APIID = { SUGGEST_USERNAME: "api.suggest.username", USER_UPDATE: "api.user.update", USER_LIST: "api.user.list", + USER_HIERARCHY_VIEW: "api.user.hierarchyView", USER_RESET_PASSWORD: "api.user.resetPassword", USER_RESET_PASSWORD_LINK: "api.user.sendLinkForResetPassword", USER_FORGOT_PASSWORD: "api.user.forgotPassword", diff --git a/src/common/utils/response.messages.ts b/src/common/utils/response.messages.ts index 64aaa07c..1654ec5d 100644 --- a/src/common/utils/response.messages.ts +++ b/src/common/utils/response.messages.ts @@ -97,6 +97,7 @@ export const API_RESPONSES = { //get User Details USER_GET_SUCCESSFULLY: "User details fetched successfully.", + USER_HIERARCHY_VIEW_SUCCESS: "User hierarchy view fetched successfully.", USER_GET_BY_EMAIL_SUCCESSFULLY: "User details fetched successfully by email", USER_GET_BY_PHONE_SUCCESSFULLY: "User details fetched successfully by phone", USER_GET_BY_USERNAME_SUCCESSFULLY: diff --git a/src/config/tenant.config.ts b/src/config/tenant.config.ts new file mode 100644 index 00000000..ee2cdecb --- /dev/null +++ b/src/config/tenant.config.ts @@ -0,0 +1,30 @@ +export interface TenantConfig { + tenantId: string; + name: string; +} + +export const ALLOWED_TENANTS: TenantConfig[] = [ + { + tenantId: 'e39447df-069d-4ccf-b92c-576f70b350f3', + name: 'Pratham' + } +]; + +/** + * Check if a tenant ID is allowed + * @param tenantId - Tenant ID to validate + * @returns boolean indicating if tenant is allowed + */ +export function isAllowedTenant(tenantId: string): boolean { + return ALLOWED_TENANTS.some(tenant => tenant.tenantId === tenantId); +} + +/** + * Get tenant configuration by ID + * @param tenantId - Tenant ID to lookup + * @returns TenantConfig or undefined if not found + */ +export function getTenantConfig(tenantId: string): TenantConfig | undefined { + return ALLOWED_TENANTS.find(tenant => tenant.tenantId === tenantId); +} + diff --git a/src/fields/fields.module.ts b/src/fields/fields.module.ts index 3ade8fbf..0e2f6d24 100644 --- a/src/fields/fields.module.ts +++ b/src/fields/fields.module.ts @@ -7,10 +7,11 @@ import { FieldValues } from "./entities/fields-values.entity"; import { TypeOrmModule } from "@nestjs/typeorm"; import { PostgresModule } from "src/adapters/postgres/postgres-module"; import { FieldsService } from "./fields.service"; +import { Tenants } from "src/userTenantMapping/entities/tenant.entity"; @Module({ imports: [ - TypeOrmModule.forFeature([Fields, FieldValues]), + TypeOrmModule.forFeature([Fields, FieldValues, Tenants]), HttpModule, PostgresModule, ], diff --git a/src/forms/forms.module.ts b/src/forms/forms.module.ts index b10b699f..216089cf 100644 --- a/src/forms/forms.module.ts +++ b/src/forms/forms.module.ts @@ -6,10 +6,11 @@ import { TypeOrmModule } from "@nestjs/typeorm"; import { PostgresFieldsService } from "src/adapters/postgres/fields-adapter"; import { Fields } from "src/fields/entities/fields.entity"; import { FieldValues } from "src/fields/entities/fields-values.entity"; +import { Tenants } from "src/userTenantMapping/entities/tenant.entity"; @Module({ controllers: [FormsController], - imports: [TypeOrmModule.forFeature([Form, Fields, FieldValues])], + imports: [TypeOrmModule.forFeature([Form, Fields, FieldValues, Tenants])], providers: [FormsService, PostgresFieldsService], }) export class FormsModule {} diff --git a/src/user/dto/user-search.dto.ts b/src/user/dto/user-search.dto.ts index d7406477..cb84329b 100644 --- a/src/user/dto/user-search.dto.ts +++ b/src/user/dto/user-search.dto.ts @@ -298,6 +298,16 @@ export class UserSearchDto { return this.sort ? this.sort[1] : undefined; } + @ApiPropertyOptional({ + type: String, + description: "Include custom fields in response (default: true). Set to false for faster response.", + default: "true", + }) + @Expose() + @IsOptional() + @IsString() + includeCustomFields?: string = "true"; + constructor(partial: Partial) { Object.assign(this, partial); } diff --git a/src/user/user.controller.ts b/src/user/user.controller.ts index 15cbd4f0..734fe205 100644 --- a/src/user/user.controller.ts +++ b/src/user/user.controller.ts @@ -55,6 +55,8 @@ import { OtpSendDTO } from "./dto/otpSend.dto"; import { OtpVerifyDTO } from "./dto/otpVerify.dto"; import { UploadS3Service } from "src/common/services/upload-S3.service"; import { GetUserId } from "src/common/decorators/getUserId.decorator"; +import { GetTenantId } from "src/common/decorators/getTenantId.decorator"; +import { isAllowedTenant, getTenantConfig } from "src/config/tenant.config"; export interface UserData { context: string; tenantId: string; @@ -181,8 +183,8 @@ export class UserController { @UseFilters(new AllExceptionsFilter(APIID.USER_LIST)) @Post("/list") - // @UseGuards(JwtAuthGuard) - // @ApiBasicAuth("access-token") + @UseGuards(JwtAuthGuard) + @ApiBasicAuth("access-token") @ApiCreatedResponse({ description: "User list." }) @ApiBody({ type: UserSearchDto }) @UsePipes(ValidationPipe) @@ -191,17 +193,19 @@ export class UserController { }) @ApiHeader({ name: "tenantid", + required: true, + description: "Tenant ID (must be a valid UUID)", }) public async searchUser( - @Headers() headers, + @GetTenantId() tenantId: string, @Req() request: Request, @Res() response: Response, @Body() userSearchDto: UserSearchDto ) { - const tenantId = headers["tenantid"]; + const shouldIncludeCustomFields = userSearchDto.includeCustomFields !== "false"; return await this.userAdapter .buildUserAdapter() - .searchUser(tenantId, request, response, userSearchDto); + .searchUser(tenantId, request, response, userSearchDto, shouldIncludeCustomFields); } @Post("/password-reset-link") @@ -223,6 +227,44 @@ export class UserController { ); } + @UseFilters(new AllExceptionsFilter(APIID.USER_HIERARCHY_VIEW)) + @Post("/user/v1/users-hierarchy-view") + @UseGuards(JwtAuthGuard) + @ApiBasicAuth("access-token") + @ApiCreatedResponse({ description: "Multi-tenant user list." }) + @ApiForbiddenResponse({ description: "Tenant is not authorized to access this resource." }) + @ApiBody({ type: UserSearchDto }) + @UsePipes(ValidationPipe) + @SerializeOptions({ + strategy: "excludeAll", + }) + @ApiHeader({ + name: "tenantid", + required: true, + description: "Tenant ID (must be a valid UUID)", + }) + public async searchUserMultiTenant( + @GetTenantId() tenantId: string, + @Req() request: Request, + @Res() response: Response, + @Body() userSearchDto: UserSearchDto + ) { + // Check if tenant ID is in the allowed list + if (!isAllowedTenant(tenantId)) { + const tenantConfig = getTenantConfig(tenantId); + return response.status(403).json({ + statusCode: 403, + message: "Access denied. Tenant is not authorized to access this resource.", + error: "Forbidden", + tenantId: tenantId + }); + } + + return await this.userAdapter + .buildUserAdapter() + .searchUserMultiTenant(tenantId, request, response, userSearchDto); + } + @Post("/forgot-password") @ApiOkResponse({ description: "Forgot password reset successfully." }) @ApiBody({ type: ForgotPasswordDto }) @@ -385,42 +427,15 @@ export class UserController { }) @ApiHeader({ name: "tenantid", - description: "Tenant ID for filtering users within specific tenant (Required)", - required: true + required: true, + description: "Tenant ID (must be a valid UUID)", }) public async getUsersByHierarchicalLocation( - @Headers() headers, + @GetTenantId() tenantId: string, @Req() request: Request, @Res() response: Response, @Body() hierarchicalFiltersDto: HierarchicalLocationFiltersDto ) { - const tenantId = headers["tenantid"]; - const apiId = APIID.USER_LIST; - - // Comprehensive tenantId validation - const tenantValidation = this.validateTenantId(tenantId); - if (!tenantValidation.isValid) { - LoggerUtil.error( - `TenantId validation failed: ${tenantValidation.error}`, - `Received tenantId: ${tenantId}`, - apiId - ); - - return response.status(400).json({ - id: apiId, - ver: "1.0", - ts: new Date().toISOString(), - params: { - resmsgid: "", - status: "failed", - err: tenantValidation.error, - errmsg: "Invalid tenant information" - }, - responseCode: 400, - result: {} - }); - } - return await this.userAdapter .buildUserAdapter() .getUsersByHierarchicalLocation(tenantId, request, response, hierarchicalFiltersDto); diff --git a/src/userTenantMapping/entities/tenant.entity.ts b/src/userTenantMapping/entities/tenant.entity.ts index 90fd21fd..b223121c 100644 --- a/src/userTenantMapping/entities/tenant.entity.ts +++ b/src/userTenantMapping/entities/tenant.entity.ts @@ -30,6 +30,9 @@ export class Tenants { }) status: TenantStatus; + @Column({ type: "uuid", nullable: true }) + parentId: string; + @Column({ type: "uuid", nullable: true }) createdBy: string;