diff --git a/src/reports/member/dto/member-search.dto.ts b/src/reports/member/dto/member-search.dto.ts index 5d89c9b..a54d987 100644 --- a/src/reports/member/dto/member-search.dto.ts +++ b/src/reports/member/dto/member-search.dto.ts @@ -83,6 +83,17 @@ export class MemberSearchBodyDto { @IsBoolean() profileComplete?: boolean; + @ApiPropertyOptional({ + description: + "Filter by multiple preferred role values from the member's open-to-work personalization trait.", + type: [String], + example: ["AI_ML_ENGINEER", "FULL_STACK_DEVELOPER"], + }) + @IsOptional() + @IsArray() + @IsString({ each: true }) + preferredRoles?: string[]; + @ApiPropertyOptional({ description: "Filter by multiple country names or country codes (case-insensitive).", diff --git a/src/reports/member/dto/open-to-work-talent.dto.ts b/src/reports/member/dto/open-to-work-talent.dto.ts new file mode 100644 index 0000000..8688d7c --- /dev/null +++ b/src/reports/member/dto/open-to-work-talent.dto.ts @@ -0,0 +1,141 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { IsIn, IsInt, IsOptional, IsString, Max, Min } from "class-validator"; + +export type OpenToWorkAvailability = "FULL_TIME" | "PART_TIME"; + +/** + * Query filters for the open-to-work Talent report. + * + * The reports UI uses these filters to page through deployment-ready members + * and to request role-specific CSV exports. + */ +export class OpenToWorkTalentQueryDto { + @ApiPropertyOptional({ + description: + "Preferred role value from the openToWork personalization trait.", + example: "FULL_STACK_DEVELOPER", + }) + @IsOptional() + @IsString() + role?: string; + + @ApiPropertyOptional({ + description: + "Availability value from the openToWork personalization trait.", + enum: ["FULL_TIME", "PART_TIME"], + }) + @IsOptional() + @IsIn(["FULL_TIME", "PART_TIME"]) + availability?: OpenToWorkAvailability; + + @ApiPropertyOptional({ + description: "Page number (1-based). Defaults to 1.", + minimum: 1, + default: 1, + }) + @IsOptional() + @IsInt() + @Min(1) + @Type(() => Number) + page?: number; + + @ApiPropertyOptional({ + description: "Number of results per page. Defaults to 10, maximum 100.", + minimum: 1, + maximum: 100, + default: 10, + }) + @IsOptional() + @IsInt() + @Min(1) + @Max(100) + @Type(() => Number) + perPage?: number; +} + +/** + * Preferred-role aggregate for the open-to-work Talent report. + */ +export class OpenToWorkTalentRoleCountDto { + @ApiProperty({ description: "Preferred role value." }) + role!: string; + + @ApiProperty({ + description: "Number of open-to-work members with this role.", + }) + count!: number; +} + +/** + * Member row returned to the reports UI Talent tab. + */ +export class OpenToWorkTalentMemberDto { + @ApiProperty({ description: "Member user ID." }) + userId!: string; + + @ApiProperty({ description: "Topcoder handle." }) + handle!: string; + + @ApiPropertyOptional({ description: "First name.", nullable: true }) + firstName!: string | null; + + @ApiPropertyOptional({ description: "Last name.", nullable: true }) + lastName!: string | null; + + @ApiPropertyOptional({ + description: "Country or country code.", + nullable: true, + }) + country!: string | null; + + @ApiPropertyOptional({ + description: "Open-to-work availability value.", + nullable: true, + }) + availability!: string | null; + + @ApiProperty({ description: "Preferred role values.", type: [String] }) + preferredRoles!: string[]; + + @ApiPropertyOptional({ description: "Member signup date.", nullable: true }) + memberSince!: string | null; + + @ApiPropertyOptional({ + description: "Highest Topcoder rating.", + nullable: true, + }) + maxRating!: number | null; + + @ApiProperty({ description: "First-place challenge wins." }) + challengeWins!: number; + + @ApiProperty({ description: "First-place task wins." }) + taskWins!: number; + + @ApiProperty({ description: "Combined first-place challenge and task wins." }) + totalWins!: number; +} + +/** + * Dashboard response for the reports UI Talent tab. + */ +export class OpenToWorkTalentResponseDto { + @ApiProperty({ description: "Distinct open-to-work member count." }) + totalMembers!: number; + + @ApiProperty({ description: "Members matching the selected filters." }) + total!: number; + + @ApiProperty({ description: "Current page number." }) + page!: number; + + @ApiProperty({ description: "Results per page." }) + perPage!: number; + + @ApiProperty({ type: [OpenToWorkTalentRoleCountDto] }) + roleCounts!: OpenToWorkTalentRoleCountDto[]; + + @ApiProperty({ type: [OpenToWorkTalentMemberDto] }) + data!: OpenToWorkTalentMemberDto[]; +} diff --git a/src/reports/member/guards/member-talent-report.guard.spec.ts b/src/reports/member/guards/member-talent-report.guard.spec.ts new file mode 100644 index 0000000..9b2c3fd --- /dev/null +++ b/src/reports/member/guards/member-talent-report.guard.spec.ts @@ -0,0 +1,101 @@ +import { + ExecutionContext, + ForbiddenException, + UnauthorizedException, +} from "@nestjs/common"; +import { Scopes, UserRoles } from "src/app-constants"; +import { MemberTalentReportGuard } from "./member-talent-report.guard"; + +type AuthUserFixture = { + isMachine?: boolean; + roles?: string[]; + role?: string | string[]; + scopes?: string[]; +}; + +/** + * Builds a minimal Nest execution context for guard unit tests. + * @param authUser Optional authenticated-user fixture. + * @returns Execution context with the supplied auth user on the request. + */ +function createExecutionContext(authUser?: AuthUserFixture): ExecutionContext { + return { + switchToHttp: () => ({ + getRequest: () => ({ + authUser, + }), + }), + } as unknown as ExecutionContext; +} + +describe("MemberTalentReportGuard", () => { + const guard = new MemberTalentReportGuard(); + + it("throws when no auth user is present", () => { + expect(() => guard.canActivate(createExecutionContext())).toThrow( + UnauthorizedException, + ); + }); + + it("allows administrator role access", () => { + expect( + guard.canActivate( + createExecutionContext({ + roles: ["Administrator"], + }), + ), + ).toBe(true); + }); + + it("allows role claim with topcoder administrator prefix", () => { + expect( + guard.canActivate( + createExecutionContext({ + role: "Topcoder Administrator", + }), + ), + ).toBe(true); + }); + + it("allows machine clients with all reports scope", () => { + expect( + guard.canActivate( + createExecutionContext({ + isMachine: true, + scopes: [Scopes.AllReports], + }), + ), + ).toBe(true); + }); + + it("allows talent manager users", () => { + expect( + guard.canActivate( + createExecutionContext({ + roles: [UserRoles.TalentManager], + }), + ), + ).toBe(true); + }); + + it("allows role claim with topcoder talent manager prefix", () => { + expect( + guard.canActivate( + createExecutionContext({ + role: "Topcoder Talent Manager", + }), + ), + ).toBe(true); + }); + + it("denies machine clients without all reports scope", () => { + expect(() => + guard.canActivate( + createExecutionContext({ + isMachine: true, + scopes: [Scopes.Member.MemberSearch], + }), + ), + ).toThrow(ForbiddenException); + }); +}); diff --git a/src/reports/member/guards/member-talent-report.guard.ts b/src/reports/member/guards/member-talent-report.guard.ts new file mode 100644 index 0000000..34067f7 --- /dev/null +++ b/src/reports/member/guards/member-talent-report.guard.ts @@ -0,0 +1,58 @@ +import { + CanActivate, + ExecutionContext, + ForbiddenException, + Injectable, + UnauthorizedException, +} from "@nestjs/common"; +import { Scopes, UserRoles } from "src/app-constants"; +import { + AuthUserLike, + getNormalizedRoles, + hasAccessToScopes, + hasAdminRole, +} from "../../../auth/permissions.util"; + +const allowedHumanRoles = new Set([ + UserRoles.TalentManager.toLowerCase(), +]); + +/** + * Allows administrator and Talent Manager users, or machine clients with + * all-reports scope, to access the open-to-work Talent report and contact export. + */ +@Injectable() +export class MemberTalentReportGuard implements CanActivate { + canActivate(context: ExecutionContext): boolean { + const authUser: AuthUserLike | undefined = context + .switchToHttp() + .getRequest().authUser; + + if (!authUser) { + throw new UnauthorizedException("You are not authenticated."); + } + + if (authUser.isMachine) { + if (hasAccessToScopes(authUser, [Scopes.AllReports])) { + return true; + } + + throw new ForbiddenException( + "You do not have the required permissions to access this resource.", + ); + } + + const roles = getNormalizedRoles(authUser); + + if ( + hasAdminRole(roles) || + roles.some((role) => allowedHumanRoles.has(role)) + ) { + return true; + } + + throw new ForbiddenException( + "You do not have the required permissions to access this resource.", + ); + } +} diff --git a/src/reports/member/member-search.controller.spec.ts b/src/reports/member/member-search.controller.spec.ts index 0c1b3f0..75a0a15 100644 --- a/src/reports/member/member-search.controller.spec.ts +++ b/src/reports/member/member-search.controller.spec.ts @@ -1,5 +1,8 @@ import { BadRequestException } from "@nestjs/common"; +import { INTERCEPTORS_METADATA } from "@nestjs/common/constants"; import { Test, TestingModule } from "@nestjs/testing"; +import { CsvSerializer } from "../../common/csv/csv-serializer"; +import { CsvResponseInterceptor } from "../../common/interceptors/csv-response.interceptor"; import { MemberSearchResponseDto } from "./dto/member-search-response.dto"; import { MemberSearchController } from "./member-search.controller"; import { MemberSearchService } from "./member-search.service"; @@ -8,15 +11,21 @@ describe("MemberSearchController", () => { let controller: MemberSearchController; const mockMemberSearchService = { + exportOpenToWorkTalent: jest.fn(), + getOpenToWorkTalent: jest.fn(), search: jest.fn(), }; beforeEach(async () => { + mockMemberSearchService.exportOpenToWorkTalent.mockReset(); + mockMemberSearchService.getOpenToWorkTalent.mockReset(); mockMemberSearchService.search.mockReset(); const moduleRef: TestingModule = await Test.createTestingModule({ controllers: [MemberSearchController], providers: [ + CsvSerializer, + CsvResponseInterceptor, { provide: MemberSearchService, useValue: mockMemberSearchService, @@ -68,6 +77,63 @@ describe("MemberSearchController", () => { expect(result).toEqual(response); }); + it("delegates open-to-work Talent report requests to the service", async () => { + const query = { + page: 2, + perPage: 10, + role: "FULL_STACK_DEVELOPER", + }; + const response = { + totalMembers: 5, + total: 2, + page: 2, + perPage: 10, + roleCounts: [{ role: "FULL_STACK_DEVELOPER", count: 2 }], + data: [], + }; + + mockMemberSearchService.getOpenToWorkTalent.mockResolvedValue(response); + + const result = await controller.getOpenToWorkTalent(query); + + expect(mockMemberSearchService.getOpenToWorkTalent).toHaveBeenCalledWith( + query, + ); + expect(result).toEqual(response); + }); + + it("delegates open-to-work Talent export requests to the service", async () => { + const query = { role: "UX_DESIGNER" }; + const response = [ + { + handle: "designer", + firstName: "Design", + lastName: "User", + email: "designer@example.com", + phone: "+15555550100", + }, + ]; + + mockMemberSearchService.exportOpenToWorkTalent.mockResolvedValue(response); + + const result = await controller.exportOpenToWorkTalent(query); + + expect(mockMemberSearchService.exportOpenToWorkTalent).toHaveBeenCalledWith( + query, + ); + expect(result).toEqual(response); + }); + + it("uses the CSV interceptor for the open-to-work Talent export endpoint", () => { + const interceptors = + Reflect.getMetadata( + INTERCEPTORS_METADATA, + MemberSearchController.prototype.exportOpenToWorkTalent, + ) ?? []; + + expect(interceptors).toContain(CsvResponseInterceptor); + }); + it("propagates service errors", async () => { mockMemberSearchService.search.mockRejectedValue( new BadRequestException("Invalid request"), diff --git a/src/reports/member/member-search.controller.ts b/src/reports/member/member-search.controller.ts index 5702a98..2d3b025 100644 --- a/src/reports/member/member-search.controller.ts +++ b/src/reports/member/member-search.controller.ts @@ -1,21 +1,31 @@ import { Body, Controller, + Get, HttpCode, HttpStatus, Post, + Query, UseGuards, + UseInterceptors, } from "@nestjs/common"; import { ApiBearerAuth, ApiOperation, + ApiProduces, ApiResponse, ApiTags, } from "@nestjs/swagger"; import { MemberSearchBodyDto } from "./dto/member-search.dto"; import { MemberSearchResponseDto } from "./dto/member-search-response.dto"; +import { + OpenToWorkTalentQueryDto, + OpenToWorkTalentResponseDto, +} from "./dto/open-to-work-talent.dto"; import { MemberSearchService } from "./member-search.service"; import { MemberSearchGuard } from "./guards/member-search.guard"; +import { MemberTalentReportGuard } from "./guards/member-talent-report.guard"; +import { CsvResponseInterceptor } from "../../common/interceptors/csv-response.interceptor"; @ApiTags("Member Search") @ApiBearerAuth() @@ -24,6 +34,50 @@ import { MemberSearchGuard } from "./guards/member-search.guard"; export class MemberSearchController { constructor(private readonly memberSearchService: MemberSearchService) {} + /** + * Returns dashboard data for the open-to-work Talent report. + * @param query Role, availability, and pagination filters. + * @returns Dashboard summary and paginated member rows. + */ + @Get("open-to-work") + @UseGuards(MemberTalentReportGuard) + @ApiOperation({ + summary: "List open-to-work members by preferred role", + description: + "Returns open-to-work member totals, preferred-role counts, and a paginated member list. " + + "Accessible by Administrator and Talent Manager users only.", + }) + @ApiResponse({ status: 200, type: OpenToWorkTalentResponseDto }) + @ApiResponse({ status: 401, description: "Unauthenticated" }) + @ApiResponse({ status: 403, description: "Forbidden – insufficient role" }) + getOpenToWorkTalent( + @Query() query: OpenToWorkTalentQueryDto, + ): Promise { + return this.memberSearchService.getOpenToWorkTalent(query); + } + + /** + * Exports open-to-work Talent report rows including contact fields. + * @param query Role and availability filters. + * @returns Flat member rows serialized as CSV when requested with `Accept: text/csv`. + */ + @Get("open-to-work/export") + @UseGuards(MemberTalentReportGuard) + @UseInterceptors(CsvResponseInterceptor) + @ApiProduces("application/json", "text/csv") + @ApiOperation({ + summary: "Export open-to-work members by preferred role", + description: + "Exports open-to-work members with email and phone fields. " + + "Accessible by Administrator and Talent Manager users only.", + }) + @ApiResponse({ status: 200, description: "Export successful." }) + @ApiResponse({ status: 401, description: "Unauthenticated" }) + @ApiResponse({ status: 403, description: "Forbidden – insufficient role" }) + exportOpenToWorkTalent(@Query() query: OpenToWorkTalentQueryDto) { + return this.memberSearchService.exportOpenToWorkTalent(query); + } + @Post("search") @HttpCode(HttpStatus.OK) @ApiOperation({ diff --git a/src/reports/member/member-search.module.ts b/src/reports/member/member-search.module.ts index ab6ac5b..c334890 100644 --- a/src/reports/member/member-search.module.ts +++ b/src/reports/member/member-search.module.ts @@ -1,10 +1,19 @@ import { Module } from "@nestjs/common"; +import { CsvSerializer } from "src/common/csv/csv-serializer"; +import { CsvResponseInterceptor } from "src/common/interceptors/csv-response.interceptor"; import { MemberSearchController } from "./member-search.controller"; import { MemberSearchService } from "./member-search.service"; import { MemberSearchGuard } from "./guards/member-search.guard"; +import { MemberTalentReportGuard } from "./guards/member-talent-report.guard"; @Module({ controllers: [MemberSearchController], - providers: [MemberSearchService, MemberSearchGuard], + providers: [ + MemberSearchService, + MemberSearchGuard, + MemberTalentReportGuard, + CsvSerializer, + CsvResponseInterceptor, + ], }) export class MemberSearchModule {} diff --git a/src/reports/member/member-search.service.spec.ts b/src/reports/member/member-search.service.spec.ts index 0d6db9e..4c7504f 100644 --- a/src/reports/member/member-search.service.spec.ts +++ b/src/reports/member/member-search.service.spec.ts @@ -27,6 +27,139 @@ describe("MemberSearchService", () => { expect(service).toBeDefined(); }); + it("returns open-to-work Talent dashboard data and applies role filters", async () => { + mockDbService.query + .mockResolvedValueOnce([{ total: 5 }]) + .mockResolvedValueOnce([ + { role: "FULL_STACK_DEVELOPER", count: "3" }, + { role: "UX_DESIGNER", count: 2 }, + ]) + .mockResolvedValueOnce([{ total: 3 }]) + .mockResolvedValueOnce([ + { + userId: 101, + handle: "coder", + firstName: "Code", + lastName: "User", + email: "coder@example.com", + phone: "+15555550101", + country: "US", + availability: "FULL_TIME", + preferredRoles: ["FULL_STACK_DEVELOPER"], + memberSince: "2024-01-02T00:00:00.000Z", + maxRating: "1800", + challengeWins: "4", + taskWins: 1, + totalWins: "5", + }, + ]); + + const result = await service.getOpenToWorkTalent({ + role: "FULL_STACK_DEVELOPER", + page: 2, + perPage: 10, + }); + + expect(mockDbService.query).toHaveBeenCalledTimes(4); + + const totalSql = mockDbService.query.mock.calls[0][0] as string; + const roleCountParams = mockDbService.query.mock.calls[1][1] as unknown[]; + const countParams = mockDbService.query.mock.calls[2][1] as unknown[]; + const dataSql = mockDbService.query.mock.calls[3][0] as string; + const dataParams = mockDbService.query.mock.calls[3][1] as unknown[]; + + expect(totalSql).toContain("mtp.key = 'openToWork'"); + expect(totalSql).toContain("jsonb_array_length"); + expect(totalSql).toContain("COALESCE(m.email, '') NOT ILIKE '%@wipro.com'"); + expect(roleCountParams).toEqual([null]); + expect(countParams).toEqual([null, "FULL_STACK_DEVELOPER"]); + expect(dataSql).toContain( + '$2::text IS NULL OR $2::text = ANY(otw."preferredRoles")', + ); + expect(dataSql).toContain( + "(COALESCE(cw.challenge_wins, 0) + COALESCE(tw.task_wins, 0)) DESC", + ); + expect(dataParams).toEqual([null, "FULL_STACK_DEVELOPER", 10, 10]); + + expect(result).toEqual({ + totalMembers: 5, + total: 3, + page: 2, + perPage: 10, + roleCounts: [ + { role: "FULL_STACK_DEVELOPER", count: 3 }, + { role: "UX_DESIGNER", count: 2 }, + ], + data: [ + { + userId: "101", + handle: "coder", + firstName: "Code", + lastName: "User", + country: "US", + availability: "FULL_TIME", + preferredRoles: ["FULL_STACK_DEVELOPER"], + memberSince: "2024-01-02T00:00:00.000Z", + maxRating: 1800, + challengeWins: 4, + taskWins: 1, + totalWins: 5, + }, + ], + }); + }); + + it("exports open-to-work Talent rows with contact fields", async () => { + mockDbService.query.mockResolvedValueOnce([ + { + userId: "202", + handle: "designer", + firstName: "Design", + lastName: "User", + email: "designer@example.com", + phone: "+15555550102", + country: "Australia", + availability: "PART_TIME", + preferredRoles: ["UX_DESIGNER", "FULL_STACK_DEVELOPER"], + memberSince: new Date("2024-02-03T00:00:00.000Z"), + maxRating: null, + challengeWins: 0, + taskWins: "2", + totalWins: "2", + }, + ]); + + const result = await service.exportOpenToWorkTalent({ + availability: "PART_TIME", + role: "UX_DESIGNER", + }); + + const dataSql = mockDbService.query.mock.calls[0][0] as string; + const dataParams = mockDbService.query.mock.calls[0][1] as unknown[]; + + expect(dataSql).toContain("otw.email"); + expect(dataSql).toContain("otw.phone"); + expect(dataSql).not.toContain("LIMIT"); + expect(dataParams).toEqual(["PART_TIME", "UX_DESIGNER"]); + expect(result).toEqual([ + { + handle: "designer", + firstName: "Design", + lastName: "User", + email: "designer@example.com", + phone: "+15555550102", + country: "Australia", + availability: "PART_TIME", + preferredRoles: "UX_DESIGNER, FULL_STACK_DEVELOPER", + memberSince: "2024-02-03T00:00:00.000Z", + maxRating: null, + challengeWins: 0, + taskWins: 2, + totalWins: 2, + }, + ]); + }); + it("runs data and count queries with default pagination and maps response", async () => { mockDbService.query .mockResolvedValueOnce([ @@ -54,7 +187,8 @@ describe("MemberSearchService", () => { const countSql = mockDbService.query.mock.calls[1][0] as string; const countParams = mockDbService.query.mock.calls[1][1] as unknown[]; - expect(dataSql).toContain("WITH recently_active AS"); + expect(dataSql).toContain("active_members AS MATERIALIZED"); + expect(dataSql).toContain("recently_active AS"); expect(dataSql).not.toContain("requested_skills AS"); expect(dataSql).toContain( 'ORDER BY "matchIndex" DESC NULLS LAST, m.handle ASC', @@ -106,9 +240,10 @@ describe("MemberSearchService", () => { expect(dataSql).toContain( 'ORDER BY m.handle ASC, "matchIndex" DESC NULLS LAST', ); - expect(dataSql).toContain('LOWER(m."homeCountryCode") = ANY($1::text[])'); - expect(dataParams).toEqual([["us"], 5, 5]); - expect(countParams).toEqual([["us"]]); + expect(dataSql).toContain('m."homeCountryCode" = ANY($1::text[])'); + expect(dataSql).toContain("UPPER(m.country) = ANY($1::text[])"); + expect(dataParams).toEqual([["US"], 5, 5]); + expect(countParams).toEqual([["US"]]); }); it("treats empty countries as no country filter", async () => { @@ -121,7 +256,7 @@ describe("MemberSearchService", () => { const dataSql = mockDbService.query.mock.calls[0][0] as string; const countParams = mockDbService.query.mock.calls[1][1] as unknown[]; - expect(dataSql).not.toContain('LOWER(m."homeCountryCode") = ANY('); + expect(dataSql).not.toContain('m."homeCountryCode" = ANY('); expect(countParams).toEqual([]); }); @@ -170,8 +305,8 @@ describe("MemberSearchService", () => { expect(enabledCountSql).not.toContain( "INNER JOIN profile_complete_filtered pcf ON pcf.user_id = fm.user_id", ); - expect(enabledDataParams).toEqual([["us"], 7, 14]); - expect(enabledCountParams).toEqual([["us"]]); + expect(enabledDataParams).toEqual([["US"], 7, 14]); + expect(enabledCountParams).toEqual([["US"]]); mockDbService.query.mockReset(); mockDbService.query diff --git a/src/reports/member/member-search.service.ts b/src/reports/member/member-search.service.ts index e2e3d07..8bde5f5 100644 --- a/src/reports/member/member-search.service.ts +++ b/src/reports/member/member-search.service.ts @@ -7,6 +7,12 @@ import { MemberResultDto, MemberSearchResponseDto, } from "./dto/member-search-response.dto"; +import { + OpenToWorkTalentMemberDto, + OpenToWorkTalentQueryDto, + OpenToWorkTalentResponseDto, + OpenToWorkTalentRoleCountDto, +} from "./dto/open-to-work-talent.dto"; type RawMemberRow = { id: string; @@ -21,6 +27,138 @@ type RawMemberRow = { matchIndex: number; }; +type OpenToWorkTalentMemberRow = { + userId: string | number; + handle: string; + firstName: string | null; + lastName: string | null; + email: string | null; + phone: string | null; + country: string | null; + availability: string | null; + preferredRoles: string[] | null; + memberSince: Date | string | null; + maxRating: string | number | null; + challengeWins: string | number | null; + taskWins: string | number | null; + totalWins: string | number | null; +}; + +type OpenToWorkTalentRoleCountRow = { + role: string; + count: string | number; +}; + +type OpenToWorkTalentExportRow = { + handle: string; + firstName: string | null; + lastName: string | null; + email: string | null; + phone: string | null; + country: string | null; + availability: string | null; + preferredRoles: string; + memberSince: string | null; + maxRating: number | null; + challengeWins: number; + taskWins: number; + totalWins: number; +}; + +type NormalizedOpenToWorkTalentQuery = { + availability: string | null; + role: string | null; + page: number; + perPage: number; +}; + +const openToWorkTalentBaseCtes = ` +WITH latest_open_to_work AS MATERIALIZED ( + SELECT DISTINCT ON (mt."userId") + mt."userId" AS user_id, + mtp.value::jsonb AS value + FROM members."memberTraits" mt + INNER JOIN members."memberTraitPersonalization" mtp + ON mtp."memberTraitId" = mt.id + WHERE mtp.key = 'openToWork' + AND mtp.value IS NOT NULL + AND mtp.value::jsonb ? 'preferredRoles' + AND jsonb_typeof(mtp.value::jsonb -> 'preferredRoles') = 'array' + AND jsonb_array_length(mtp.value::jsonb -> 'preferredRoles') > 0 + ORDER BY mt."userId", mt."updatedAt" DESC NULLS LAST, mt.id DESC +), +open_to_work_members AS MATERIALIZED ( + SELECT + m."userId" AS "userId", + m.handle, + NULLIF(TRIM(m."firstName"), '') AS "firstName", + NULLIF(TRIM(m."lastName"), '') AS "lastName", + NULLIF(TRIM(m.email), '') AS email, + ph.phone, + COALESCE( + NULLIF(TRIM(m.country), ''), + NULLIF(TRIM(m."homeCountryCode"), ''), + NULLIF(TRIM(m."competitionCountryCode"), '') + ) AS country, + NULLIF(TRIM(latest_open_to_work.value ->> 'availability'), '') AS availability, + ARRAY( + SELECT jsonb_array_elements_text(latest_open_to_work.value -> 'preferredRoles') + ) AS "preferredRoles", + u.create_date AS "memberSince" + FROM members.member m + INNER JOIN latest_open_to_work + ON latest_open_to_work.user_id = m."userId" + LEFT JOIN identity."user" u + ON u.user_id = m."userId"::numeric(10, 0) + LEFT JOIN LATERAL ( + SELECT STRING_AGG(DISTINCT NULLIF(TRIM(mp."number"::text), ''), ', ') AS phone + FROM members."memberPhone" mp + WHERE mp."userId" = m."userId" + AND NULLIF(TRIM(mp."number"::text), '') IS NOT NULL + ) ph ON TRUE + WHERE COALESCE(m."availableForGigs", false) = true + AND COALESCE(m.email, '') NOT ILIKE '%@wipro.com' +), +max_rating AS ( + SELECT DISTINCT ON (mmr."userId") + mmr."userId" AS user_id, + mmr.rating + FROM members."memberMaxRating" mmr + INNER JOIN open_to_work_members otw + ON otw."userId" = mmr."userId" + ORDER BY mmr."userId", mmr.rating DESC NULLS LAST +), +challenge_wins AS ( + SELECT + cw."userId"::text AS user_id, + COUNT(DISTINCT cw."challengeId")::integer AS challenge_wins + FROM challenges."ChallengeWinner" cw + INNER JOIN open_to_work_members otw + ON otw."userId"::text = cw."userId"::text + INNER JOIN challenges."Challenge" c + ON c.id = cw."challengeId" + INNER JOIN challenges."ChallengeType" ct + ON ct.id = c."typeId" + WHERE cw.placement = 1 + AND COALESCE(ct."isTask", false) = false + GROUP BY cw."userId" +), +task_wins AS ( + SELECT + cw."userId"::text AS user_id, + COUNT(DISTINCT cw."challengeId")::integer AS task_wins + FROM challenges."ChallengeWinner" cw + INNER JOIN open_to_work_members otw + ON otw."userId"::text = cw."userId"::text + INNER JOIN challenges."Challenge" c + ON c.id = cw."challengeId" + INNER JOIN challenges."ChallengeType" ct + ON ct.id = c."typeId" + WHERE cw.placement = 1 + AND COALESCE(ct."isTask", false) = true + GROUP BY cw."userId" +)`; + function formatLocation(location: string): string { const normalizedLocation = String(location || "").trim(); if (!normalizedLocation) { @@ -41,10 +179,240 @@ function formatLocation(location: string): string { return `${parts.slice(0, -1).join(" ")}, ${mappedCountryName}`; } +/** + * Converts nullable database numeric values into a finite number. + * @param value Raw database value that may be a string, number, or null. + * @returns Numeric value, or zero when the input is missing/non-numeric. + */ +function toNumber(value: string | number | null | undefined): number { + const parsed = Number(value ?? 0); + return Number.isFinite(parsed) ? parsed : 0; +} + +/** + * Converts nullable database numeric values into a nullable finite number. + * @param value Raw database value that may be a string, number, or null. + * @returns Numeric value, or null when the input is missing/non-numeric. + */ +function toNullableNumber( + value: string | number | null | undefined, +): number | null { + if (value === null || value === undefined || value === "") { + return null; + } + + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; +} + +/** + * Normalizes Postgres text arrays into a safe string array. + * @param value Raw database array value. + * @returns Non-empty role values. + */ +function toStringArray(value: string[] | null | undefined): string[] { + return Array.isArray(value) + ? value.map((entry) => String(entry).trim()).filter(Boolean) + : []; +} + +/** + * Converts database dates into ISO strings for API responses and exports. + * @param value Raw date value from the database. + * @returns ISO string, or null when the value cannot be parsed. + */ +function toIsoString(value: Date | string | null | undefined): string | null { + if (!value) { + return null; + } + + const date = value instanceof Date ? value : new Date(value); + return Number.isNaN(date.getTime()) ? null : date.toISOString(); +} + +/** + * Normalizes and bounds Talent report query parameters. + * @param dto Raw query DTO. + * @returns Query values ready for SQL parameters and pagination. + */ +function normalizeOpenToWorkTalentQuery( + dto: OpenToWorkTalentQueryDto, +): NormalizedOpenToWorkTalentQuery { + return { + availability: dto.availability?.trim() || null, + role: dto.role?.trim() || null, + page: Math.max(Number(dto.page || 1), 1), + perPage: Math.min(Math.max(Number(dto.perPage || 10), 1), 100), + }; +} + @Injectable() export class MemberSearchService { constructor(private readonly db: DbService) {} + /** + * Returns the open-to-work Talent report dashboard data. + * + * The report includes distinct member totals, preferred-role aggregates, and + * a paginated member list sorted by platform-backed wins and rating. + * + * @param dto Query filters and pagination values from the reports UI. + * @returns Dashboard response for the Talent tab. + * @throws Does not throw intentionally; database errors propagate to Nest. + */ + async getOpenToWorkTalent( + dto: OpenToWorkTalentQueryDto, + ): Promise { + const query = normalizeOpenToWorkTalentQuery(dto); + const offset = (query.page - 1) * query.perPage; + + const [totalMemberRows, roleRows, countRows, memberRows] = + await Promise.all([ + this.db.query<{ total: number }>( + `${openToWorkTalentBaseCtes} +SELECT COUNT(*)::integer AS total +FROM open_to_work_members otw +WHERE ($1::text IS NULL OR otw.availability = $1::text)`, + [query.availability], + ), + this.db.query( + `${openToWorkTalentBaseCtes} +SELECT + role, + COUNT(DISTINCT otw."userId")::integer AS count +FROM open_to_work_members otw +CROSS JOIN LATERAL unnest(otw."preferredRoles") AS role +WHERE ($1::text IS NULL OR otw.availability = $1::text) +GROUP BY role +ORDER BY count DESC, role ASC`, + [query.availability], + ), + this.db.query<{ total: number }>( + `${openToWorkTalentBaseCtes} +SELECT COUNT(*)::integer AS total +FROM open_to_work_members otw +WHERE ($1::text IS NULL OR otw.availability = $1::text) + AND ($2::text IS NULL OR $2::text = ANY(otw."preferredRoles"))`, + [query.availability, query.role], + ), + this.db.query( + `${openToWorkTalentBaseCtes} +SELECT + otw."userId", + otw.handle, + otw."firstName", + otw."lastName", + otw.email, + otw.phone, + otw.country, + otw.availability, + otw."preferredRoles", + otw."memberSince", + mr.rating AS "maxRating", + COALESCE(cw.challenge_wins, 0)::integer AS "challengeWins", + COALESCE(tw.task_wins, 0)::integer AS "taskWins", + (COALESCE(cw.challenge_wins, 0) + COALESCE(tw.task_wins, 0))::integer AS "totalWins" +FROM open_to_work_members otw +LEFT JOIN max_rating mr + ON mr.user_id = otw."userId" +LEFT JOIN challenge_wins cw + ON cw.user_id = otw."userId"::text +LEFT JOIN task_wins tw + ON tw.user_id = otw."userId"::text +WHERE ($1::text IS NULL OR otw.availability = $1::text) + AND ($2::text IS NULL OR $2::text = ANY(otw."preferredRoles")) +ORDER BY + (COALESCE(cw.challenge_wins, 0) + COALESCE(tw.task_wins, 0)) DESC, + COALESCE(mr.rating, 0) DESC, + otw."memberSince" ASC NULLS LAST, + LOWER(otw.handle) ASC +LIMIT $3::integer OFFSET $4::integer`, + [query.availability, query.role, query.perPage, offset], + ), + ]); + + return { + totalMembers: totalMemberRows[0]?.total ?? 0, + total: countRows[0]?.total ?? 0, + page: query.page, + perPage: query.perPage, + roleCounts: roleRows.map( + (row): OpenToWorkTalentRoleCountDto => ({ + role: row.role, + count: toNumber(row.count), + }), + ), + data: memberRows.map((row) => this.toOpenToWorkTalentMember(row)), + }; + } + + /** + * Returns open-to-work Talent report rows formatted for CSV serialization. + * + * The export intentionally includes email and phone fields required by the + * leadership handoff while using the same role/availability filters as the UI. + * + * @param dto Query filters from the reports UI. + * @returns Flat export rows that the CSV interceptor can serialize. + * @throws Does not throw intentionally; database errors propagate to Nest. + */ + async exportOpenToWorkTalent( + dto: OpenToWorkTalentQueryDto, + ): Promise { + const query = normalizeOpenToWorkTalentQuery(dto); + const rows = await this.db.query( + `${openToWorkTalentBaseCtes} +SELECT + otw."userId", + otw.handle, + otw."firstName", + otw."lastName", + otw.email, + otw.phone, + otw.country, + otw.availability, + otw."preferredRoles", + otw."memberSince", + mr.rating AS "maxRating", + COALESCE(cw.challenge_wins, 0)::integer AS "challengeWins", + COALESCE(tw.task_wins, 0)::integer AS "taskWins", + (COALESCE(cw.challenge_wins, 0) + COALESCE(tw.task_wins, 0))::integer AS "totalWins" +FROM open_to_work_members otw +LEFT JOIN max_rating mr + ON mr.user_id = otw."userId" +LEFT JOIN challenge_wins cw + ON cw.user_id = otw."userId"::text +LEFT JOIN task_wins tw + ON tw.user_id = otw."userId"::text +WHERE ($1::text IS NULL OR otw.availability = $1::text) + AND ($2::text IS NULL OR $2::text = ANY(otw."preferredRoles")) +ORDER BY + (COALESCE(cw.challenge_wins, 0) + COALESCE(tw.task_wins, 0)) DESC, + COALESCE(mr.rating, 0) DESC, + otw."memberSince" ASC NULLS LAST, + LOWER(otw.handle) ASC`, + [query.availability, query.role], + ); + + return rows.map( + (row): OpenToWorkTalentExportRow => ({ + handle: row.handle, + firstName: row.firstName, + lastName: row.lastName, + email: row.email, + phone: row.phone, + country: row.country, + availability: row.availability, + preferredRoles: toStringArray(row.preferredRoles).join(", "), + memberSince: toIsoString(row.memberSince), + maxRating: toNullableNumber(row.maxRating), + challengeWins: toNumber(row.challengeWins), + taskWins: toNumber(row.taskWins), + totalWins: toNumber(row.totalWins), + }), + ); + } + async search(dto: MemberSearchBodyDto): Promise { const { skills, @@ -54,6 +422,7 @@ export class MemberSearchService { verifiedProfile, profileComplete, countries, + preferredRoles, sortBy = "matchIndex", sortOrder = "desc", page = 1, @@ -225,6 +594,14 @@ member_address AS ( ] : []; + const normalizedPreferredRoles = Array.isArray(preferredRoles) + ? [ + ...new Set( + preferredRoles.map((value) => String(value).trim()).filter(Boolean), + ), + ] + : []; + if (normalizedCountries.length > 0) { const pCountries = p(normalizedCountries); where.push( @@ -236,6 +613,30 @@ member_address AS ( ); } + if (normalizedPreferredRoles.length > 0) { + const pPreferredRoles = p( + normalizedPreferredRoles.map((value) => value.toUpperCase()), + ); + where.push( + `EXISTS ( + SELECT 1 + FROM members."memberTraits" mtpr + INNER JOIN members."memberTraitPersonalization" mtpp + ON mtpp."memberTraitId" = mtpr.id + WHERE mtpr."userId" = m."userId" + AND mtpp.key = 'openToWork' + AND mtpp.value IS NOT NULL + AND mtpp.value::jsonb ? 'preferredRoles' + AND jsonb_typeof(mtpp.value::jsonb -> 'preferredRoles') = 'array' + AND EXISTS ( + SELECT 1 + FROM jsonb_array_elements_text(mtpp.value::jsonb -> 'preferredRoles') pr + WHERE UPPER(pr) = ANY(${pPreferredRoles}::text[]) + ) + )`, + ); + } + const whereClause = where.join(" AND "); const skillJoin = deduped.length > 0 @@ -316,10 +717,20 @@ member_address AS ( // ---------------------------------------------------------------- queries const ctesBlock = ctes.join(",\n"); const direction = sortOrder === "asc" ? "ASC" : "DESC"; - const orderByClause = - sortBy === "handle" - ? `m.handle ${direction}, "matchIndex" DESC NULLS LAST` - : `"matchIndex" ${direction} NULLS LAST, m.handle ASC`; + const fallbackPreferredRoleOrder = + deduped.length === 0 && normalizedPreferredRoles.length > 0; + let orderByClause = ""; + + if (sortBy === "handle") { + orderByClause = `m.handle ${direction}, "matchIndex" DESC NULLS LAST`; + } else if (fallbackPreferredRoleOrder) { + orderByClause = + `EXISTS (SELECT 1 FROM recently_active ra WHERE ra.user_id = m."userId") DESC, ` + + `COALESCE(m."availableForGigs", false) DESC, m.handle ASC`; + } else { + orderByClause = `"matchIndex" ${direction} NULLS LAST, m.handle ASC`; + } + const profileCompleteJoin = profileComplete === true ? `INNER JOIN profile_complete_filtered pcf ON pcf.user_id = m."userId"` @@ -344,7 +755,7 @@ SELECT FROM members.member m INNER JOIN filtered_members fm ON fm.user_id = m."userId" ${profileCompleteJoin} -LEFT JOIN user_match_data umd ON umd.user_id = m."userId" +${deduped.length > 0 ? 'LEFT JOIN user_match_data umd ON umd.user_id = m."userId"' : ""} LEFT JOIN verified_via_trolley vt ON vt.user_id = m."userId" LEFT JOIN member_address maddr ON maddr."userId" = m."userId" ORDER BY ${orderByClause} @@ -393,4 +804,28 @@ LIMIT ${pLimit} OFFSET ${pOffset}`; throw new NotFoundException(`Skill not found or is disabled: ${missing}`); } } + + /** + * Maps database rows into the public Talent report member shape. + * @param row Raw member row selected by the open-to-work Talent query. + * @returns API-safe member row for the reports UI. + */ + private toOpenToWorkTalentMember( + row: OpenToWorkTalentMemberRow, + ): OpenToWorkTalentMemberDto { + return { + userId: String(row.userId), + handle: row.handle, + firstName: row.firstName, + lastName: row.lastName, + country: row.country, + availability: row.availability, + preferredRoles: toStringArray(row.preferredRoles), + memberSince: toIsoString(row.memberSince), + maxRating: toNullableNumber(row.maxRating), + challengeWins: toNumber(row.challengeWins), + taskWins: toNumber(row.taskWins), + totalWins: toNumber(row.totalWins), + }; + } }