diff --git a/src/adapters/cohortMembersservicelocator.ts b/src/adapters/cohortMembersservicelocator.ts index c0ee5b07..fdd4f11d 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 c8f8f534..324718d8 100644 --- a/src/adapters/postgres/cohortMembers-adapter.ts +++ b/src/adapters/postgres/cohortMembers-adapter.ts @@ -828,6 +828,14 @@ 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 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.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 @@ -2394,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); @@ -2524,6 +2539,11 @@ export class PostgresCohortMembersService { }); } else { // Create new cohort member + // 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, @@ -2535,6 +2555,7 @@ export class PostgresCohortMembersService { : MemberStatus.ACTIVE : MemberStatus.ACTIVE, statusReason: cohortMembersDto.statusReason || '', + userCohortCountryId, }; result = await this.cohortMembersRepository.save( @@ -6909,4 +6930,206 @@ export class PostgresCohortMembersService { ); return result.length > 0; } + + /** + * 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 + * 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 currentCountry set, + * or it doesn't match any row in `countries`) - expected, not an error. + */ + private async resolveUserCohortCountryIds( + userIds: string[] + ): Promise> { + const map = new Map(); + if (!userIds || userIds.length === 0) { + return map; + } + + 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."currentCountry")) + WHERE u."userId" = ANY($1::uuid[])`, + [userIds] + ); + for (const row of rows) { + map.set(row.userId, row.countryId); + } + return map; + } + + /** + * 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. + * + * 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 [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 = fieldValueRow?.dropdownValue || fieldValueRow?.value; + if (!rawValue) { + return []; + } + + const allCountries: { id: string; name: string }[] = + await this.usersRepository.query( + `SELECT id, name FROM countries ORDER BY LENGTH(name) DESC` + ); + + 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; + } + + /** + * 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. 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[] }, + 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); + + // 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 = { + 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 94c15deb..20ae0d4c 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: [ @@ -134,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 new file mode 100644 index 00000000..9e88a510 --- /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 500.', + }) + @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: 500, + 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 00000000..a8a5c76c --- /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 00000000..c0e3ced2 --- /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 00000000..766b2f20 --- /dev/null +++ b/src/aspire-leaders-specific/dto/list-countries.dto.ts @@ -0,0 +1,71 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { + IsOptional, + IsBoolean, + IsString, + MaxLength, + IsInt, + Min, + Max, +} from 'class-validator'; +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; + +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() + @Transform(({ value }) => { + if (value === 'true') return true; + if (value === 'false') return false; + return value; + }) + @IsBoolean() + is_active?: boolean; +} diff --git a/src/cohortMembers/cohortMembers.controller.ts b/src/cohortMembers/cohortMembers.controller.ts index 7c6fd28f..89221c6a 100644 --- a/src/cohortMembers/cohortMembers.controller.ts +++ b/src/cohortMembers/cohortMembers.controller.ts @@ -24,11 +24,14 @@ 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'; import { CohortMembersAdapter } from './cohortMembersadapter'; import { CohortMembersUpdateDto } from './dto/cohortMember-update.dto'; @@ -201,6 +204,50 @@ 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. + * + * 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.' }) + @ApiBadRequestResponse({ description: 'Bad request' }) + @ApiBody({ type: CohortMembersReportFilterDto }) + @UsePipes(new ValidationPipe({ transform: true, whitelist: true })) + public async reportFilterCohortMembers( + @Req() request: RequestWithUser, + @Body() cohortMembersReportFilterDto: CohortMembersReportFilterDto, + @Res() response: Response + ) { + const adminUserId = request.user?.userId; + if (!adminUserId || !isUUID(adminUserId)) { + throw new BadRequestException('unauthorized!'); + } + return this.cohortMemberAdapter + .buildCohortMembersAdapter() + .reportFilterCohortMembers( + cohortMembersReportFilterDto, + adminUserId, + 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 00000000..a5ae276c --- /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-4222-8333-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 1819f3dc..910a08ea 100644 --- a/src/cohortMembers/entities/cohort-member.entity.ts +++ b/src/cohortMembers/entities/cohort-member.entity.ts @@ -59,4 +59,18 @@ 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.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 + * 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 7ecd1ac1..45e9b265 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 d6c3e192..f963d928 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",