From 48671165826c66bfa0b01c816002b2f6e1d47141 Mon Sep 17 00:00:00 2001 From: Tushar Mahajan Date: Mon, 10 Aug 2026 10:51:42 +0530 Subject: [PATCH 1/6] Task#0000 Changes For Hidden Country Field --- src/adapters/cohortMembersservicelocator.ts | 17 ++ .../postgres/cohortMembers-adapter.ts | 179 ++++++++++++++++++ src/app.module.ts | 2 + .../aspire-leaders-specific.controller.ts | 102 ++++++++++ .../aspire-leaders-specific.module.ts | 13 ++ .../aspire-leaders-specific.service.ts | 82 ++++++++ .../dto/list-countries.dto.ts | 67 +++++++ src/cohortMembers/cohortMembers.controller.ts | 37 ++++ .../dto/cohortMembers-report-filter.dto.ts | 34 ++++ .../entities/cohort-member.entity.ts | 13 ++ src/common/utils/api-id.config.ts | 3 + src/common/utils/response.messages.ts | 5 + 12 files changed, 554 insertions(+) create mode 100644 src/aspire-leaders-specific/aspire-leaders-specific.controller.ts create mode 100644 src/aspire-leaders-specific/aspire-leaders-specific.module.ts create mode 100644 src/aspire-leaders-specific/aspire-leaders-specific.service.ts create mode 100644 src/aspire-leaders-specific/dto/list-countries.dto.ts create mode 100644 src/cohortMembers/dto/cohortMembers-report-filter.dto.ts diff --git a/src/adapters/cohortMembersservicelocator.ts b/src/adapters/cohortMembersservicelocator.ts index c0ee5b071..fdd4f11da 100644 --- a/src/adapters/cohortMembersservicelocator.ts +++ b/src/adapters/cohortMembersservicelocator.ts @@ -1,6 +1,7 @@ import { CohortMembersSearchDto } from 'src/cohortMembers/dto/cohortMembers-search.dto'; import { CohortMembersDto } from 'src/cohortMembers/dto/cohortMembers.dto'; import { CohortMembersUpdateDto } from 'src/cohortMembers/dto/cohortMember-update.dto'; +import { CohortMembersReportFilterDto } from 'src/cohortMembers/dto/cohortMembers-report-filter.dto'; import { Response } from 'express'; /** @@ -138,4 +139,20 @@ export interface IServicelocatorcohortMembers { userId: string, response: Response ); + + /** + * Aspire Leaders-specific lean reporting endpoint. Given a cohort and a chunk + * of userIds, returns the matching CohortMembers rows - automatically + * country-filtered when the calling admin (adminUserId) is a Regional Admin, + * unfiltered when they're an Admin. Role and allowed countries are always + * resolved server-side from adminUserId, never accepted from the caller. + * @param reportFilterDto - cohortId + userIds chunk to filter + * @param adminUserId - the calling admin's userId, used to resolve their role/countries + * @param response - Express response object + */ + reportFilterCohortMembers( + reportFilterDto: CohortMembersReportFilterDto, + adminUserId: string, + response: Response + ); } diff --git a/src/adapters/postgres/cohortMembers-adapter.ts b/src/adapters/postgres/cohortMembers-adapter.ts index 3f70cbd66..2b415c27e 100644 --- a/src/adapters/postgres/cohortMembers-adapter.ts +++ b/src/adapters/postgres/cohortMembers-adapter.ts @@ -828,6 +828,11 @@ export class PostgresCohortMembersService { cohortMembers.createdBy = loginUser; cohortMembers.updatedBy = loginUser; cohortMembers.cohortAcademicYearId = cohortacAdemicyearId; + // Aspire Leaders-specific: resolve and cache this member's country id once, + // at insert time - see resolveUserCohortCountryId() for why this is never + // recomputed afterward. + (cohortMembers as any).userCohortCountryId = + await this.resolveUserCohortCountryId(cohortMembers.userId); // Create a new CohortMembers entity and populate it with cohortMembers data const savedCohortMember = await this.cohortMembersRepository.save( cohortMembers @@ -2519,6 +2524,12 @@ export class PostgresCohortMembersService { }); } else { // Create new cohort member + // Aspire Leaders-specific: resolve and cache this member's country id + // once, at insert time - see resolveUserCohortCountryId() for why this + // is never recomputed afterward. + const userCohortCountryId = await this.resolveUserCohortCountryId( + userId + ); const cohortMemberForAcademicYear = { ...cohortMembers, cohortAcademicYearId: cohortExists[0].cohortAcademicYearId, @@ -2530,6 +2541,7 @@ export class PostgresCohortMembersService { : MemberStatus.ACTIVE : MemberStatus.ACTIVE, statusReason: cohortMembersDto.statusReason || '', + userCohortCountryId, }; result = await this.cohortMembersRepository.save( @@ -6895,4 +6907,171 @@ export class PostgresCohortMembersService { ); return result.length > 0; } + + /** + * Aspire Leaders-specific: resolves a member's Users.country free-text value to + * countries.id via a single indexed join (case + whitespace insensitive match on + * countries.name). Called exactly once, at CohortMembers insert time + * (createCohortMembers / createBulkCohortMembers), and the result is cached on + * user_cohort_country_id - it is never recomputed after that, even if the user + * later changes their profile country, because the country is a property of + * *that cohort application*, not a live mirror of the user's current profile. + * Returns null when the user has no country set, or it doesn't match any row + * in `countries` - both are expected, not error, cases. + */ + private async resolveUserCohortCountryId( + userId: string + ): Promise { + const rows = await this.usersRepository.query( + `SELECT c.id AS "countryId" + FROM "Users" u + JOIN countries c ON LOWER(TRIM(c.name)) = LOWER(TRIM(u.country)) + WHERE u."userId" = $1 + LIMIT 1`, + [userId] + ); + return rows?.[0]?.countryId ?? null; + } + + /** + * Aspire Leaders-specific: resolves the calling Regional Admin's own allowed + * countries into countries.id values, for use as the report-filter's + * user_cohort_country_id IN (...) clause. Country ids are NEVER accepted from + * the caller/frontend - they are always looked up here, server-side, from the + * admin's own profile. + * + * Mirrors the existing (already-live) frontend convention in + * mfes/authentication/src/pages/login.tsx (storeUserRoleAndDetails): a Regional + * Admin's allowed countries live in their first custom field value, stored as + * either a JSON array string or a comma-separated string of country names - + * there is exactly one such custom field on a Regional Admin's profile today. + */ + private async resolveRegionalAdminCountryIds( + adminUserId: string + ): Promise { + const [firstFieldValue] = await this.fieldValuesRepository.find({ + where: { itemId: adminUserId }, + order: { createdAt: 'ASC' }, + take: 1, + }); + + const rawValue = + firstFieldValue?.value ?? + firstFieldValue?.textValue ?? + firstFieldValue?.checkboxValue ?? + firstFieldValue?.dropdownValue; + if (!rawValue) { + return []; + } + + // Tolerate both storage shapes already used across the codebase for + // multi-value custom fields: a JSON array string, or a comma-separated string. + let countryNames: string[]; + try { + const parsed = JSON.parse(rawValue); + countryNames = Array.isArray(parsed) ? parsed : [String(parsed)]; + } catch { + countryNames = String(rawValue).split(','); + } + countryNames = countryNames.map((name) => name.trim()).filter(Boolean); + if (countryNames.length === 0) { + return []; + } + + const normalizedNames = countryNames.map((name) => name.toLowerCase()); + const rows = await this.usersRepository.query( + `SELECT id FROM countries WHERE LOWER(TRIM(name)) = ANY($1::text[])`, + [normalizedNames] + ); + return rows.map((row: { id: string }) => row.id); + } + + /** + * Aspire Leaders-specific lean reporting endpoint: given a cohort and a chunk + * of userIds (as produced by LMS/Assessment/Event/Referral report pagination), + * returns the matching CohortMembers rows - automatically country-filtered + * when the calling admin is a Regional Admin, unfiltered when they're an Admin. + * + * Role is resolved server-side from `adminUserId` via the existing + * getFirstRoleName() helper - never accepted as a client-supplied field, since + * it is this endpoint's access-control boundary. + */ + public async reportFilterCohortMembers( + reportFilterDto: { cohortId: string; userIds: string[] }, + adminUserId: string, + response: Response + ) { + const apiId = APIID.COHORT_MEMBER_REPORT_FILTER; + try { + const { cohortId, userIds } = reportFilterDto; + + // Nothing to match - skip the query entirely rather than issuing an + // `IN ()` query. + if (!userIds || userIds.length === 0) { + return APIResponse.success( + response, + apiId, + { count: 0, items: [] }, + HttpStatus.OK, + API_RESPONSES.COHORT_MEMBER_REPORT_FILTER_SUCCESS + ); + } + + const roleName = await this.userService.getFirstRoleName(adminUserId); + const isRegionalAdmin = roleName === 'Regional Admin'; + + const whereCondition: Record = { + cohortId, + userId: In(userIds), + }; + + if (isRegionalAdmin) { + const allowedCountryIds = await this.resolveRegionalAdminCountryIds( + adminUserId + ); + + // Regional Admin has no resolvable allowed country - nothing can match, + // skip the query entirely rather than issuing an `IN ()` query. + if (allowedCountryIds.length === 0) { + return APIResponse.success( + response, + apiId, + { count: 0, items: [] }, + HttpStatus.OK, + API_RESPONSES.COHORT_MEMBER_REPORT_FILTER_SUCCESS + ); + } + + whereCondition.userCohortCountryId = In(allowedCountryIds); + } + // Admin role (or any non-Regional-Admin role): no country filtering, + // behaves exactly as the cohortId + userId IN (...) match always has. + + const items = await this.cohortMembersRepository.find({ + where: whereCondition, + }); + + return APIResponse.success( + response, + apiId, + { count: items.length, items }, + HttpStatus.OK, + API_RESPONSES.COHORT_MEMBER_REPORT_FILTER_SUCCESS + ); + } catch (error) { + const fullMessage = error?.message ?? String(error); + LoggerUtil.error( + API_RESPONSES.SERVER_ERROR, + `Error in reportFilterCohortMembers: ${fullMessage}`, + apiId + ); + return APIResponse.error( + response, + apiId, + API_RESPONSES.INTERNAL_SERVER_ERROR, + API_RESPONSES.INTERNAL_SERVER_ERROR, + HttpStatus.INTERNAL_SERVER_ERROR + ); + } + } } \ No newline at end of file diff --git a/src/app.module.ts b/src/app.module.ts index 94c15deb5..a812eb670 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -36,6 +36,7 @@ import { PathwaysModule } from './pathways/pathways.module'; import { CountriesModule } from './countries/countries.module'; import { ContentModule } from './content/content.module'; import { ReferralsModule } from './referrals/referrals.module'; +import { AspireLeadersSpecificModule } from './aspire-leaders-specific/aspire-leaders-specific.module'; /** * Main Application Module @@ -95,6 +96,7 @@ import { ReferralsModule } from './referrals/referrals.module'; CountriesModule, ContentModule, ReferralsModule, + AspireLeadersSpecificModule, ], controllers: [AppController, HealthController], providers: [ diff --git a/src/aspire-leaders-specific/aspire-leaders-specific.controller.ts b/src/aspire-leaders-specific/aspire-leaders-specific.controller.ts new file mode 100644 index 000000000..a6d3509aa --- /dev/null +++ b/src/aspire-leaders-specific/aspire-leaders-specific.controller.ts @@ -0,0 +1,102 @@ +import { + Controller, + Post, + Body, + Res, + HttpCode, + HttpStatus, + UsePipes, + ValidationPipe, +} from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiResponse, + ApiHeader, + ApiBody, + ApiBadRequestResponse, + ApiUnauthorizedResponse, + ApiInternalServerErrorResponse, +} from '@nestjs/swagger'; +import { Response } from 'express'; +import { AspireLeadersSpecificService } from './aspire-leaders-specific.service'; +import { ListCountriesQueryDto } from './dto/list-countries.dto'; + +@ApiTags('Aspire Leaders Specific') +@Controller('aspire-leaders-specific') +export class AspireLeadersSpecificController { + constructor( + private readonly aspireLeadersSpecificService: AspireLeadersSpecificService, + ) {} + + @Post('countries') + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'List countries', + description: + 'Retrieves countries with optional filter by name (case-insensitive partial match) and status (is_active), with pagination and total count. Default limit is 100.', + }) + @ApiHeader({ + name: 'Authorization', + description: 'Bearer token for authentication', + required: true, + }) + @ApiBody({ + type: ListCountriesQueryDto, + required: false, + examples: { + all: { + summary: 'List all countries', + value: {}, + }, + paginated: { + summary: 'List with pagination', + value: { limit: 20, offset: 0 }, + }, + byName: { + summary: 'Search by name', + value: { name: 'India' }, + }, + byStatus: { + summary: 'Filter by active status', + value: { is_active: true }, + }, + combined: { + summary: 'Pagination, name search and status', + value: { name: 'United', is_active: true, limit: 20, offset: 0 }, + }, + }, + }) + @ApiResponse({ + status: 200, + description: 'Countries retrieved successfully', + schema: { + example: { + result: { + count: 1, + totalCount: 1, + limit: 100, + offset: 0, + items: [ + { + id: 'a1b2c3d4-e111-2222-3333-444455556666', + name: 'India', + is_active: true, + created_at: '2026-03-02T12:00:00.000Z', + }, + ], + }, + }, + }, + }) + @ApiBadRequestResponse({ description: 'Bad Request' }) + @ApiUnauthorizedResponse({ description: 'Unauthorized' }) + @ApiInternalServerErrorResponse({ description: 'Internal Server Error' }) + @UsePipes(new ValidationPipe({ transform: true, whitelist: true })) + async listCountries( + @Body() query: ListCountriesQueryDto, + @Res() response: Response, + ): Promise { + return this.aspireLeadersSpecificService.listCountries(query, response); + } +} diff --git a/src/aspire-leaders-specific/aspire-leaders-specific.module.ts b/src/aspire-leaders-specific/aspire-leaders-specific.module.ts new file mode 100644 index 000000000..a8a5c76c5 --- /dev/null +++ b/src/aspire-leaders-specific/aspire-leaders-specific.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Country } from '../countries/entities/country.entity'; +import { AspireLeadersSpecificController } from './aspire-leaders-specific.controller'; +import { AspireLeadersSpecificService } from './aspire-leaders-specific.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([Country])], + controllers: [AspireLeadersSpecificController], + providers: [AspireLeadersSpecificService], + exports: [AspireLeadersSpecificService], +}) +export class AspireLeadersSpecificModule {} diff --git a/src/aspire-leaders-specific/aspire-leaders-specific.service.ts b/src/aspire-leaders-specific/aspire-leaders-specific.service.ts new file mode 100644 index 000000000..c0e3ced2e --- /dev/null +++ b/src/aspire-leaders-specific/aspire-leaders-specific.service.ts @@ -0,0 +1,82 @@ +import { Injectable, HttpStatus } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, ILike } from 'typeorm'; +import { Response } from 'express'; +import { Country } from '../countries/entities/country.entity'; +import { + ListCountriesQueryDto, + ASPIRE_LEADERS_COUNTRY_LIST_DEFAULT_LIMIT, + ASPIRE_LEADERS_COUNTRY_LIST_MAX_LIMIT, +} from './dto/list-countries.dto'; +import APIResponse from 'src/common/responses/response'; +import { API_RESPONSES } from '@utils/response.messages'; +import { APIID } from '@utils/api-id.config'; +import { LoggerUtil } from 'src/common/logger/LoggerUtil'; + +@Injectable() +export class AspireLeadersSpecificService { + constructor( + @InjectRepository(Country) + private readonly countryRepository: Repository, + ) {} + + async listCountries( + query: ListCountriesQueryDto, + response: Response, + ): Promise { + const apiId = APIID.ASPIRE_LEADERS_COUNTRY_LIST; + try { + const whereCondition: Record = {}; + + if (query.name !== undefined && query.name.trim() !== '') { + whereCondition.name = ILike(`%${query.name.trim()}%`); + } + if (query.is_active !== undefined) { + whereCondition.is_active = query.is_active; + } + + const requestedLimit = query.limit ?? ASPIRE_LEADERS_COUNTRY_LIST_DEFAULT_LIMIT; + const limit = Math.min(requestedLimit, ASPIRE_LEADERS_COUNTRY_LIST_MAX_LIMIT); + const offset = query.offset ?? 0; + + const [items, totalCount] = await this.countryRepository.findAndCount({ + where: whereCondition, + order: { name: 'ASC' }, + take: limit, + skip: offset, + select: ['id', 'name', 'is_active', 'created_at'], + }); + + const result = { + count: items.length, + totalCount, + limit, + offset, + items, + }; + + return APIResponse.success( + response, + apiId, + result, + HttpStatus.OK, + API_RESPONSES.ASPIRE_LEADERS_COUNTRY_LIST_SUCCESS, + ); + } catch (error) { + const fullMessage = error?.message ?? String(error); + const stack = error?.stack; + LoggerUtil.error( + API_RESPONSES.SERVER_ERROR, + `Error listing countries: ${fullMessage}${stack ? `\n${stack}` : ''}`, + apiId, + ); + return APIResponse.error( + response, + apiId, + API_RESPONSES.INTERNAL_SERVER_ERROR, + API_RESPONSES.INTERNAL_SERVER_ERROR, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } +} diff --git a/src/aspire-leaders-specific/dto/list-countries.dto.ts b/src/aspire-leaders-specific/dto/list-countries.dto.ts new file mode 100644 index 000000000..ee3fbb4f5 --- /dev/null +++ b/src/aspire-leaders-specific/dto/list-countries.dto.ts @@ -0,0 +1,67 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { + IsOptional, + IsBoolean, + IsString, + MaxLength, + IsInt, + Min, + Max, +} from 'class-validator'; +import { Expose, Type } from 'class-transformer'; + +export const ASPIRE_LEADERS_COUNTRY_LIST_DEFAULT_LIMIT = 500; +export const ASPIRE_LEADERS_COUNTRY_LIST_MAX_LIMIT = 500; + +export class ListCountriesQueryDto { + @ApiPropertyOptional({ + description: 'Maximum number of countries to return', + example: 100, + minimum: 1, + maximum: ASPIRE_LEADERS_COUNTRY_LIST_MAX_LIMIT, + default: ASPIRE_LEADERS_COUNTRY_LIST_DEFAULT_LIMIT, + }) + @Expose() + @IsOptional() + @Type(() => Number) + @IsInt({ message: 'Limit must be an integer' }) + @Min(1, { message: 'Limit must be at least 1' }) + @Max(ASPIRE_LEADERS_COUNTRY_LIST_MAX_LIMIT, { + message: `Limit cannot exceed ${ASPIRE_LEADERS_COUNTRY_LIST_MAX_LIMIT}`, + }) + limit?: number; + + @ApiPropertyOptional({ + description: 'Number of items to skip for pagination', + example: 0, + minimum: 0, + default: 0, + }) + @Expose() + @IsOptional() + @Type(() => Number) + @IsInt({ message: 'Offset must be an integer' }) + @Min(0, { message: 'Offset must be non-negative' }) + offset?: number; + + @ApiPropertyOptional({ + description: 'Filter countries by name (case-insensitive partial match)', + example: 'India', + maxLength: 150, + }) + @Expose() + @IsOptional() + @IsString() + @MaxLength(150) + name?: string; + + @ApiPropertyOptional({ + description: 'Filter countries by active status', + example: true, + }) + @Expose() + @IsOptional() + @Type(() => Boolean) + @IsBoolean() + is_active?: boolean; +} diff --git a/src/cohortMembers/cohortMembers.controller.ts b/src/cohortMembers/cohortMembers.controller.ts index 7c6fd28fe..ecfaca044 100644 --- a/src/cohortMembers/cohortMembers.controller.ts +++ b/src/cohortMembers/cohortMembers.controller.ts @@ -29,6 +29,7 @@ import { HttpStatus, } from '@nestjs/common'; import { CohortMembersSearchDto } from './dto/cohortMembers-search.dto'; +import { CohortMembersReportFilterDto } from './dto/cohortMembers-report-filter.dto'; import { CohortMembersDto } from './dto/cohortMembers.dto'; import { CohortMembersAdapter } from './cohortMembersadapter'; import { CohortMembersUpdateDto } from './dto/cohortMember-update.dto'; @@ -201,6 +202,42 @@ export class CohortMembersController { ); } + /** + * Aspire Leaders-specific lean reporting endpoint (see + * docs/regional-admin-cohort-country-report.md). Given a cohortId + a chunk of + * userIds, returns the matching CohortMembers rows - automatically + * country-filtered when the calling admin is a Regional Admin, unfiltered when + * they're an Admin. Role/allowed-countries are resolved server-side from + * `userId` (never accepted as client-supplied fields), using the same + * caller-identity convention already used by createCohortMembers() above - + * this controller does not enforce JwtAuthGuard, so `userId` here is the admin + * identity asserted by the calling service (Aspire-specific-service/LMS/Event + * Management Service), exactly as it already is on /create. + */ + @UseFilters(new AllExceptionsFilter(APIID.COHORT_MEMBER_REPORT_FILTER)) + @Post('/report-filter') + @ApiBasicAuth('access-token') + @ApiCreatedResponse({ description: 'Filtered cohort members list.' }) + @ApiBadRequestResponse({ description: 'Bad request' }) + @ApiBody({ type: CohortMembersReportFilterDto }) + @UsePipes(new ValidationPipe({ transform: true, whitelist: true })) + public async reportFilterCohortMembers( + @Query('userId') userId: string, + @Body() cohortMembersReportFilterDto: CohortMembersReportFilterDto, + @Res() response: Response + ) { + if (!userId || !isUUID(userId)) { + throw new BadRequestException('unauthorized!'); + } + return this.cohortMemberAdapter + .buildCohortMembersAdapter() + .reportFilterCohortMembers( + cohortMembersReportFilterDto, + userId, + response + ); + } + //update @UseFilters(new AllExceptionsFilter(APIID.COHORT_MEMBER_UPDATE)) @Put('/update/:cohortmembershipid') diff --git a/src/cohortMembers/dto/cohortMembers-report-filter.dto.ts b/src/cohortMembers/dto/cohortMembers-report-filter.dto.ts new file mode 100644 index 000000000..5d1a7fda0 --- /dev/null +++ b/src/cohortMembers/dto/cohortMembers-report-filter.dto.ts @@ -0,0 +1,34 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { + IsArray, + IsUUID, + ArrayNotEmpty, + ArrayMaxSize, +} from 'class-validator'; + +/** + * Aspire Leaders-specific lean reporting request: a cohort plus a chunk of + * enrolled/participant userIds (as paginated by LMS/Assessment/Event/Referral + * report sources). No role or country fields here by design - both are + * resolved server-side from the calling admin's identity, never accepted from + * the caller (see docs/regional-admin-cohort-country-report.md, Decisions 2 & 3). + */ +export class CohortMembersReportFilterDto { + @ApiProperty({ + description: 'Cohort to filter within', + example: 'a1b2c3d4-e111-2222-3333-444455556666', + }) + @IsUUID() + cohortId: string; + + @ApiProperty({ + type: [String], + description: + 'Chunk of userIds to check membership/country-eligibility for (e.g. one LMS report page)', + }) + @IsArray() + @ArrayNotEmpty() + @IsUUID('4', { each: true }) + @ArrayMaxSize(2000) + userIds: string[]; +} diff --git a/src/cohortMembers/entities/cohort-member.entity.ts b/src/cohortMembers/entities/cohort-member.entity.ts index 1819f3dc5..111efafc2 100644 --- a/src/cohortMembers/entities/cohort-member.entity.ts +++ b/src/cohortMembers/entities/cohort-member.entity.ts @@ -59,4 +59,17 @@ export class CohortMembers { */ @Column({ name: 'rejection_email_sent', type: 'boolean', default: false }) rejectionEmailSent: boolean; + + /** + * Aspire Leaders-specific: resolved countries.id for this member's Users.country + * free-text value, at the moment they joined this cohort. Set exactly once by + * PostgresCohortMembersService at insert time (create/bulkCreate) and never + * updated afterward - a user's country can legitimately differ across the + * different cohorts they've applied to over time, so this is a per-application + * snapshot, not a live mirror of the user's current profile. Null when the + * user's country was blank or didn't match any row in `countries`. Not a + * generic platform field - used only for Regional Admin report filtering. + */ + @Column({ name: 'user_cohort_country_id', type: 'uuid', nullable: true }) + userCohortCountryId: string | null; } diff --git a/src/common/utils/api-id.config.ts b/src/common/utils/api-id.config.ts index 7ecd1ac1d..45e9b2650 100644 --- a/src/common/utils/api-id.config.ts +++ b/src/common/utils/api-id.config.ts @@ -69,6 +69,7 @@ export const APIID = { COHORT_MEMBER_SEND_REJECTION_EMAILS: "api.cohortmember.sendRejectionEmails", COHORT_MEMBER_SEND_SHORTLISTING_EMAILS: "api.cohortmember.sendShortlistingEmails", + COHORT_MEMBER_REPORT_FILTER: "api.cohortmember.reportFilter", // Privilege Assignment APIs ASSIGNPRIVILEGE_CREATE: "api.assignprivilege.create", @@ -196,4 +197,6 @@ export const APIID = { CACHE_CLEAR_ALL: "api.cache.clear.all", // Country Management APIs COUNTRY_LIST: "api.country.list", + // Aspire Leaders Specific APIs + ASPIRE_LEADERS_COUNTRY_LIST: "api.aspire-leaders-specific.country.list", } as const; diff --git a/src/common/utils/response.messages.ts b/src/common/utils/response.messages.ts index d6c3e1922..f963d9285 100644 --- a/src/common/utils/response.messages.ts +++ b/src/common/utils/response.messages.ts @@ -63,6 +63,8 @@ export const API_RESPONSES = { COHORT_VALID_UUID: "Invalid input: CohortId must be a valid UUID.", COHORT_MEMBER_GET_SUCCESSFULLY: "Cohort members details fetched successfully.", + COHORT_MEMBER_REPORT_FILTER_SUCCESS: + "Cohort members report-filter results fetched successfully.", COHORTMEMBER_NOTFOUND: "Invalid input: Cohort Member not exist.", ACADEMICYEAR_GET_SUCCESSFULLY: "Get Successfully Academic year list", FORM_CREATED_SUCCESSFULLY: "Form created successfully", @@ -299,6 +301,9 @@ export const API_RESPONSES = { // Country Management Messages COUNTRY_LIST_SUCCESS: "Countries retrieved successfully", + // Aspire Leaders Specific Messages + ASPIRE_LEADERS_COUNTRY_LIST_SUCCESS: "Countries retrieved successfully", + // Referral Management Messages REFERRAL_CREATED_SUCCESSFULLY: "Referral created successfully", REFERRAL_GET_SUCCESS: "Referral retrieved successfully", From 2a0bc794bd1bdc9912a4cedac34c9499bd13ecdf Mon Sep 17 00:00:00 2001 From: Tushar Mahajan Date: Mon, 10 Aug 2026 16:43:57 +0530 Subject: [PATCH 2/6] Task#0000 Changes For Hidden Country Field --- .../postgres/cohortMembers-adapter.ts | 75 ++++++++++--------- 1 file changed, 41 insertions(+), 34 deletions(-) diff --git a/src/adapters/postgres/cohortMembers-adapter.ts b/src/adapters/postgres/cohortMembers-adapter.ts index 2b415c27e..5aaa0d6d2 100644 --- a/src/adapters/postgres/cohortMembers-adapter.ts +++ b/src/adapters/postgres/cohortMembers-adapter.ts @@ -6940,50 +6940,57 @@ export class PostgresCohortMembersService { * the caller/frontend - they are always looked up here, server-side, from the * admin's own profile. * - * Mirrors the existing (already-live) frontend convention in - * mfes/authentication/src/pages/login.tsx (storeUserRoleAndDetails): a Regional - * Admin's allowed countries live in their first custom field value, stored as - * either a JSON array string or a comma-separated string of country names - - * there is exactly one such custom field on a Regional Admin's profile today. + * Verified against real data (not assumed): a Regional Admin's allowed + * countries live in the ONE custom field with Fields.name = 'country' AND + * Fields.context IS NULL - a general profile-level field, fieldId + * 6469c3ac-8c46-49d7-852a-00f9589737c5 in QA. This must NOT be confused with + * the several COHORTMEMBER-scoped "country of origin"/"country of residence" + * fields (Fields.context = 'COHORTS'), which hold a *cohort applicant's* + * country, not the admin's own - hence the explicit `context IS NULL` filter. + * + * The value is a single string joining the selected countries.name values + * with commas (e.g. "India,United States"). A plain split(',') is unsafe: + * several real countries.name values contain an internal comma themselves + * (e.g. "Bolivia, Plurinational State of", "Korea, Democratic People's + * Republic of") - so instead of splitting, this matches by substring + * containment against the real country list, longest name first, removing + * each match as it's found so a comma-containing name is matched whole + * before its comma-free prefix gets a chance to spuriously match on its own. */ private async resolveRegionalAdminCountryIds( adminUserId: string ): Promise { - const [firstFieldValue] = await this.fieldValuesRepository.find({ - where: { itemId: adminUserId }, - order: { createdAt: 'ASC' }, - take: 1, - }); + const [fieldValueRow] = await this.usersRepository.query( + `SELECT fv."dropdownValue", fv.value + FROM "FieldValues" fv + JOIN "Fields" f ON f."fieldId" = fv."fieldId" + WHERE fv."itemId" = $1 + AND f.name = 'country' + AND f.context IS NULL + LIMIT 1`, + [adminUserId] + ); - const rawValue = - firstFieldValue?.value ?? - firstFieldValue?.textValue ?? - firstFieldValue?.checkboxValue ?? - firstFieldValue?.dropdownValue; + const rawValue = fieldValueRow?.dropdownValue || fieldValueRow?.value; if (!rawValue) { return []; } - // Tolerate both storage shapes already used across the codebase for - // multi-value custom fields: a JSON array string, or a comma-separated string. - let countryNames: string[]; - try { - const parsed = JSON.parse(rawValue); - countryNames = Array.isArray(parsed) ? parsed : [String(parsed)]; - } catch { - countryNames = String(rawValue).split(','); - } - countryNames = countryNames.map((name) => name.trim()).filter(Boolean); - if (countryNames.length === 0) { - return []; - } + const allCountries: { id: string; name: string }[] = + await this.usersRepository.query( + `SELECT id, name FROM countries ORDER BY LENGTH(name) DESC` + ); - const normalizedNames = countryNames.map((name) => name.toLowerCase()); - const rows = await this.usersRepository.query( - `SELECT id FROM countries WHERE LOWER(TRIM(name)) = ANY($1::text[])`, - [normalizedNames] - ); - return rows.map((row: { id: string }) => row.id); + let remaining = String(rawValue); + const matchedIds: string[] = []; + for (const country of allCountries) { + const idx = remaining.toLowerCase().indexOf(country.name.toLowerCase()); + if (idx !== -1) { + matchedIds.push(country.id); + remaining = remaining.slice(0, idx) + remaining.slice(idx + country.name.length); + } + } + return matchedIds; } /** From 5099ec59ca3e76a6f2b9019a929dff104ba1d7bb Mon Sep 17 00:00:00 2001 From: Tushar Mahajan Date: Mon, 10 Aug 2026 17:02:08 +0530 Subject: [PATCH 3/6] Task#0000 Changes For Hidden Country Field --- .../postgres/cohortMembers-adapter.ts | 19 ++++++++++++++++++- src/app.module.ts | 1 + .../aspire-leaders-specific.controller.ts | 4 ++-- .../dto/list-countries.dto.ts | 8 ++++++-- .../dto/cohortMembers-report-filter.dto.ts | 2 +- 5 files changed, 28 insertions(+), 6 deletions(-) diff --git a/src/adapters/postgres/cohortMembers-adapter.ts b/src/adapters/postgres/cohortMembers-adapter.ts index 5aaa0d6d2..dfe2db3ed 100644 --- a/src/adapters/postgres/cohortMembers-adapter.ts +++ b/src/adapters/postgres/cohortMembers-adapter.ts @@ -7001,7 +7001,10 @@ export class PostgresCohortMembersService { * * Role is resolved server-side from `adminUserId` via the existing * getFirstRoleName() helper - never accepted as a client-supplied field, since - * it is this endpoint's access-control boundary. + * it is this endpoint's access-control boundary. Fails closed: only the two + * known roles ('Admin', 'Regional Admin') are allowed through - an + * unresolvable adminUserId or any other/unrecognized role is rejected rather + * than falling through to Admin's unfiltered behavior. */ public async reportFilterCohortMembers( reportFilterDto: { cohortId: string; userIds: string[] }, @@ -7025,6 +7028,20 @@ export class PostgresCohortMembersService { } const roleName = await this.userService.getFirstRoleName(adminUserId); + + // Fail closed: only these two known roles are allowed through. An + // unresolvable adminUserId (null) or any other/unrecognized role must + // be rejected here rather than falling through to Admin's unfiltered + // behavior - this check is this endpoint's entire access-control boundary. + if (roleName !== 'Admin' && roleName !== 'Regional Admin') { + return APIResponse.error( + response, + apiId, + API_RESPONSES.UNAUTHORIZED, + API_RESPONSES.UNAUTHORIZED, + HttpStatus.FORBIDDEN + ); + } const isRegionalAdmin = roleName === 'Regional Admin'; const whereCondition: Record = { diff --git a/src/app.module.ts b/src/app.module.ts index a812eb670..20ae0d4c5 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -136,6 +136,7 @@ export class AppModule implements OnModuleInit { 'PaymentsModule', 'PathwaysModule', 'ContentModule', + 'AspireLeadersSpecificModule', ]); } } diff --git a/src/aspire-leaders-specific/aspire-leaders-specific.controller.ts b/src/aspire-leaders-specific/aspire-leaders-specific.controller.ts index a6d3509aa..9e88a5102 100644 --- a/src/aspire-leaders-specific/aspire-leaders-specific.controller.ts +++ b/src/aspire-leaders-specific/aspire-leaders-specific.controller.ts @@ -34,7 +34,7 @@ export class AspireLeadersSpecificController { @ApiOperation({ summary: 'List countries', description: - 'Retrieves countries with optional filter by name (case-insensitive partial match) and status (is_active), with pagination and total count. Default limit is 100.', + 'Retrieves countries with optional filter by name (case-insensitive partial match) and status (is_active), with pagination and total count. Default limit is 500.', }) @ApiHeader({ name: 'Authorization', @@ -75,7 +75,7 @@ export class AspireLeadersSpecificController { result: { count: 1, totalCount: 1, - limit: 100, + limit: 500, offset: 0, items: [ { diff --git a/src/aspire-leaders-specific/dto/list-countries.dto.ts b/src/aspire-leaders-specific/dto/list-countries.dto.ts index ee3fbb4f5..766b2f20e 100644 --- a/src/aspire-leaders-specific/dto/list-countries.dto.ts +++ b/src/aspire-leaders-specific/dto/list-countries.dto.ts @@ -8,7 +8,7 @@ import { Min, Max, } from 'class-validator'; -import { Expose, Type } from 'class-transformer'; +import { Expose, Transform, Type } from 'class-transformer'; export const ASPIRE_LEADERS_COUNTRY_LIST_DEFAULT_LIMIT = 500; export const ASPIRE_LEADERS_COUNTRY_LIST_MAX_LIMIT = 500; @@ -61,7 +61,11 @@ export class ListCountriesQueryDto { }) @Expose() @IsOptional() - @Type(() => Boolean) + @Transform(({ value }) => { + if (value === 'true') return true; + if (value === 'false') return false; + return value; + }) @IsBoolean() is_active?: boolean; } diff --git a/src/cohortMembers/dto/cohortMembers-report-filter.dto.ts b/src/cohortMembers/dto/cohortMembers-report-filter.dto.ts index 5d1a7fda0..a5ae276c7 100644 --- a/src/cohortMembers/dto/cohortMembers-report-filter.dto.ts +++ b/src/cohortMembers/dto/cohortMembers-report-filter.dto.ts @@ -16,7 +16,7 @@ import { export class CohortMembersReportFilterDto { @ApiProperty({ description: 'Cohort to filter within', - example: 'a1b2c3d4-e111-2222-3333-444455556666', + example: 'a1b2c3d4-e111-4222-8333-444455556666', }) @IsUUID() cohortId: string; From df9e300903f3415d1eb0640b24385286b67fff02 Mon Sep 17 00:00:00 2001 From: Tushar Mahajan Date: Wed, 12 Aug 2026 12:41:09 +0530 Subject: [PATCH 4/6] made changes --- src/cohortMembers/cohortMembers.controller.ts | 28 +++++++++++++------ 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/src/cohortMembers/cohortMembers.controller.ts b/src/cohortMembers/cohortMembers.controller.ts index ecfaca044..89221c6a2 100644 --- a/src/cohortMembers/cohortMembers.controller.ts +++ b/src/cohortMembers/cohortMembers.controller.ts @@ -24,10 +24,12 @@ import { ValidationPipe, Query, UseFilters, + UseGuards, BadRequestException, Request, HttpStatus, } from '@nestjs/common'; +import { JwtAuthGuard } from 'src/common/guards/keycloak.guard'; import { CohortMembersSearchDto } from './dto/cohortMembers-search.dto'; import { CohortMembersReportFilterDto } from './dto/cohortMembers-report-filter.dto'; import { CohortMembersDto } from './dto/cohortMembers.dto'; @@ -207,14 +209,21 @@ export class CohortMembersController { * docs/regional-admin-cohort-country-report.md). Given a cohortId + a chunk of * userIds, returns the matching CohortMembers rows - automatically * country-filtered when the calling admin is a Regional Admin, unfiltered when - * they're an Admin. Role/allowed-countries are resolved server-side from - * `userId` (never accepted as client-supplied fields), using the same - * caller-identity convention already used by createCohortMembers() above - - * this controller does not enforce JwtAuthGuard, so `userId` here is the admin - * identity asserted by the calling service (Aspire-specific-service/LMS/Event - * Management Service), exactly as it already is on /create. + * they're an Admin. + * + * Unlike every other endpoint on this controller, the caller identity here + * is NOT taken from a client-supplied `?userId=`/`userid` header - country + * filtering is this endpoint's access-control boundary, so a spoofable + * identity would defeat the whole point. JwtAuthGuard verifies the forwarded + * Authorization bearer token's signature against Keycloak's RSA public key + * and derives `adminUserId` from its verified `sub` claim. LMS, + * Aspire-specific-service, and Event Management Service already forward the + * original caller's Authorization header on every call into user-microservice + * (needed for their own downstream calls anyway), so this requires no change + * on their side beyond continuing to do that. */ @UseFilters(new AllExceptionsFilter(APIID.COHORT_MEMBER_REPORT_FILTER)) + @UseGuards(JwtAuthGuard) @Post('/report-filter') @ApiBasicAuth('access-token') @ApiCreatedResponse({ description: 'Filtered cohort members list.' }) @@ -222,18 +231,19 @@ export class CohortMembersController { @ApiBody({ type: CohortMembersReportFilterDto }) @UsePipes(new ValidationPipe({ transform: true, whitelist: true })) public async reportFilterCohortMembers( - @Query('userId') userId: string, + @Req() request: RequestWithUser, @Body() cohortMembersReportFilterDto: CohortMembersReportFilterDto, @Res() response: Response ) { - if (!userId || !isUUID(userId)) { + const adminUserId = request.user?.userId; + if (!adminUserId || !isUUID(adminUserId)) { throw new BadRequestException('unauthorized!'); } return this.cohortMemberAdapter .buildCohortMembersAdapter() .reportFilterCohortMembers( cohortMembersReportFilterDto, - userId, + adminUserId, response ); } From c9b4eea952789f3d983fdc96c6ef7bfd742c8d13 Mon Sep 17 00:00:00 2001 From: Tushar Mahajan Date: Wed, 12 Aug 2026 13:05:03 +0530 Subject: [PATCH 5/6] made changes --- .../postgres/cohortMembers-adapter.ts | 72 ++++++++++++------- 1 file changed, 46 insertions(+), 26 deletions(-) diff --git a/src/adapters/postgres/cohortMembers-adapter.ts b/src/adapters/postgres/cohortMembers-adapter.ts index 74543c076..61cea16fa 100644 --- a/src/adapters/postgres/cohortMembers-adapter.ts +++ b/src/adapters/postgres/cohortMembers-adapter.ts @@ -829,10 +829,13 @@ export class PostgresCohortMembersService { cohortMembers.updatedBy = loginUser; cohortMembers.cohortAcademicYearId = cohortacAdemicyearId; // Aspire Leaders-specific: resolve and cache this member's country id once, - // at insert time - see resolveUserCohortCountryId() for why this is never - // recomputed afterward. + // at insert time - see resolveUserCohortCountryIds() for why this is never + // recomputed afterward. Single-user call is just a batch of one - no + // separate single-user query method needed. (cohortMembers as any).userCohortCountryId = - await this.resolveUserCohortCountryId(cohortMembers.userId); + ( + await this.resolveUserCohortCountryIds([cohortMembers.userId]) + ).get(cohortMembers.userId) ?? null; // Create a new CohortMembers entity and populate it with cohortMembers data const savedCohortMember = await this.cohortMembersRepository.save( cohortMembers @@ -2399,6 +2402,13 @@ export class PostgresCohortMembersService { ); } + // Aspire Leaders-specific: resolve every userId's cohort-country in one + // batched query up front, rather than once per user inside the loop below + // (see resolveUserCohortCountryIds() - avoids an N+1 query per bulk import). + const userCohortCountryIds = await this.resolveUserCohortCountryIds( + cohortMembersDto.userId + ); + for (const userId of cohortMembersDto.userId) { // why checking from user table because it is possible to make first time part of any cohort const userExists = await this.checkUserExist(userId); @@ -2529,12 +2539,11 @@ export class PostgresCohortMembersService { }); } else { // Create new cohort member - // Aspire Leaders-specific: resolve and cache this member's country id - // once, at insert time - see resolveUserCohortCountryId() for why this - // is never recomputed afterward. - const userCohortCountryId = await this.resolveUserCohortCountryId( - userId - ); + // Aspire Leaders-specific: cache this member's country id, once, + // at insert time - resolved above in one batched query for the + // whole bulk request, never recomputed afterward. + const userCohortCountryId = + userCohortCountryIds.get(userId) ?? null; const cohortMemberForAcademicYear = { ...cohortMembers, cohortAcademicYearId: cohortExists[0].cohortAcademicYearId, @@ -6923,28 +6932,39 @@ export class PostgresCohortMembersService { } /** - * Aspire Leaders-specific: resolves a member's Users.country free-text value to - * countries.id via a single indexed join (case + whitespace insensitive match on - * countries.name). Called exactly once, at CohortMembers insert time - * (createCohortMembers / createBulkCohortMembers), and the result is cached on - * user_cohort_country_id - it is never recomputed after that, even if the user - * later changes their profile country, because the country is a property of - * *that cohort application*, not a live mirror of the user's current profile. - * Returns null when the user has no country set, or it doesn't match any row - * in `countries` - both are expected, not error, cases. + * Aspire Leaders-specific: resolves each given userId's Users.country + * free-text value to countries.id via a single indexed join (case + + * whitespace insensitive match on countries.name) - one query for however + * many userIds are passed, not one per userId, so this is the only method + * needed for both createCohortMembers() (a batch of one) and + * createBulkCohortMembers() (a real batch, avoiding an N+1 query there). + * Called exactly once per insert, at CohortMembers insert time, and the + * result is cached on user_cohort_country_id - it is never recomputed + * after that, even if the user later changes their profile country, + * because the country is a property of *that cohort application*, not a + * live mirror of the user's current profile. userIds absent from the + * returned Map simply have no resolvable country (no country set, or it + * doesn't match any row in `countries`) - expected, not an error. */ - private async resolveUserCohortCountryId( - userId: string - ): Promise { + private async resolveUserCohortCountryIds( + userIds: string[] + ): Promise> { + const map = new Map(); + if (!userIds || userIds.length === 0) { + return map; + } + const rows = await this.usersRepository.query( - `SELECT c.id AS "countryId" + `SELECT u."userId" AS "userId", c.id AS "countryId" FROM "Users" u JOIN countries c ON LOWER(TRIM(c.name)) = LOWER(TRIM(u.country)) - WHERE u."userId" = $1 - LIMIT 1`, - [userId] + WHERE u."userId" = ANY($1::uuid[])`, + [userIds] ); - return rows?.[0]?.countryId ?? null; + for (const row of rows) { + map.set(row.userId, row.countryId); + } + return map; } /** From c72a364c6108197b1d8f8f9a4615f9e66eb3c7a1 Mon Sep 17 00:00:00 2001 From: Tushar Mahajan Date: Thu, 13 Aug 2026 14:43:31 +0530 Subject: [PATCH 6/6] changes --- src/adapters/postgres/cohortMembers-adapter.ts | 8 ++++---- src/cohortMembers/entities/cohort-member.entity.ts | 5 +++-- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/adapters/postgres/cohortMembers-adapter.ts b/src/adapters/postgres/cohortMembers-adapter.ts index 61cea16fa..324718d88 100644 --- a/src/adapters/postgres/cohortMembers-adapter.ts +++ b/src/adapters/postgres/cohortMembers-adapter.ts @@ -6932,7 +6932,7 @@ export class PostgresCohortMembersService { } /** - * Aspire Leaders-specific: resolves each given userId's Users.country + * Aspire Leaders-specific: resolves each given userId's Users.currentCountry * free-text value to countries.id via a single indexed join (case + * whitespace insensitive match on countries.name) - one query for however * many userIds are passed, not one per userId, so this is the only method @@ -6943,8 +6943,8 @@ export class PostgresCohortMembersService { * after that, even if the user later changes their profile country, * because the country is a property of *that cohort application*, not a * live mirror of the user's current profile. userIds absent from the - * returned Map simply have no resolvable country (no country set, or it - * doesn't match any row in `countries`) - expected, not an error. + * returned Map simply have no resolvable country (no currentCountry set, + * or it doesn't match any row in `countries`) - expected, not an error. */ private async resolveUserCohortCountryIds( userIds: string[] @@ -6957,7 +6957,7 @@ export class PostgresCohortMembersService { const rows = await this.usersRepository.query( `SELECT u."userId" AS "userId", c.id AS "countryId" FROM "Users" u - JOIN countries c ON LOWER(TRIM(c.name)) = LOWER(TRIM(u.country)) + JOIN countries c ON LOWER(TRIM(c.name)) = LOWER(TRIM(u."currentCountry")) WHERE u."userId" = ANY($1::uuid[])`, [userIds] ); diff --git a/src/cohortMembers/entities/cohort-member.entity.ts b/src/cohortMembers/entities/cohort-member.entity.ts index 111efafc2..910a08ea6 100644 --- a/src/cohortMembers/entities/cohort-member.entity.ts +++ b/src/cohortMembers/entities/cohort-member.entity.ts @@ -61,8 +61,9 @@ export class CohortMembers { rejectionEmailSent: boolean; /** - * Aspire Leaders-specific: resolved countries.id for this member's Users.country - * free-text value, at the moment they joined this cohort. Set exactly once by + * Aspire Leaders-specific: resolved countries.id for this member's + * Users.currentCountry free-text value, at the moment they joined this + * cohort. Set exactly once by * PostgresCohortMembersService at insert time (create/bulkCreate) and never * updated afterward - a user's country can legitimately differ across the * different cohorts they've applied to over time, so this is a per-application