Skip to content

Commit 2a7de67

Browse files
authored
Merge pull request #93 from topcoder-platform/develop
30/04 PROD Release
2 parents 5724ce3 + 3b0349c commit 2a7de67

20 files changed

Lines changed: 607 additions & 109 deletions

.circleci/config.yml

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -64,12 +64,7 @@ workflows:
6464
branches:
6565
only:
6666
- develop
67-
- pm-1127_1
68-
- PM-4305
69-
- PM-4490
70-
- PM-4491-fix
71-
- PM-3497_talent-search
72-
- PM-4886
67+
- PM-4949
7368

7469
# Production builds are exectuted only on tagged commits to the
7570
# master branch.

sql/reports/topcoder/member-payment-accrual.sql renamed to sql/reports/payment/member-payment-accrual.sql

Lines changed: 35 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,6 @@ recent_payments AS (
2626
SELECT
2727
w.winning_id,
2828
w.winner_id,
29-
w.type,
3029
w.description,
3130
w.category,
3231
w.external_id AS challenge_id,
@@ -52,36 +51,57 @@ recent_payments AS (
5251
WHERE w.type = 'PAYMENT'
5352
AND p.created_at >= pr.start_date
5453
AND p.created_at <= pr.end_date
54+
),
55+
categorized_payments AS (
56+
SELECT
57+
rp.*,
58+
CASE
59+
WHEN rp.category = 'TAAS_PAYMENT' THEN 'TaaS Payment'
60+
WHEN rp.category = 'TOPGEAR_PAYMENT' THEN 'Topgear Payment'
61+
WHEN rp.category = 'ENGAGEMENT_PAYMENT' THEN 'Engagement Payment'
62+
WHEN rp.category IN (
63+
'TASK_PAYMENT',
64+
'TASK_REVIEW_PAYMENT',
65+
'TASK_COPILOT_PAYMENT',
66+
'DEPLOYMENT_TASK_PAYMENT',
67+
'PROJECT_DEPLOYMENT_TASK_PAYMENT'
68+
) THEN 'Task Payment'
69+
ELSE 'Challenge Payment'
70+
END AS payment_type
71+
FROM recent_payments rp
5572
)
5673
SELECT
57-
rp.payment_created_at AS payment_created_at,
58-
rp.payment_id,
59-
rp.description AS payment_description,
60-
rp.challenge_id,
61-
rp.payment_status,
62-
rp.type AS payment_type,
74+
cp.payment_created_at AS payment_created_at,
75+
cp.payment_id,
76+
cp.description AS payment_description,
77+
cp.challenge_id,
78+
cp.payment_status,
79+
cp.payment_type,
6380
mem.handle AS payee_handle,
6481
pm.name AS payment_method,
6582
ba."name" AS billing_account_name,
6683
cl."name" AS customer_name,
6784
ba."subcontractingEndCustomer" AS reporting_account_name,
68-
rp.winner_id AS member_id,
69-
to_char(c."createdAt", 'YYYY-MM-DD') AS challenge_created_date,
70-
rp.gross_amount AS user_payment_gross_amount
71-
FROM recent_payments rp
85+
cp.winner_id AS member_id,
86+
CASE
87+
WHEN cp.payment_type = 'Engagement Payment' THEN to_char(cp.payment_created_at, 'YYYY-MM-DD')
88+
ELSE to_char(c."createdAt", 'YYYY-MM-DD')
89+
END AS challenge_created_date, cp.gross_amount AS user_payment_gross_amount
90+
FROM categorized_payments cp
7291
LEFT JOIN challenges."Challenge" c
73-
ON c."id" = rp.challenge_id
92+
ON c."id" = cp.challenge_id
7493
LEFT JOIN challenges."ChallengeBilling" cb
7594
ON cb."challengeId" = c."id"
7695
LEFT JOIN "billing-accounts"."BillingAccount" ba
7796
ON ba."id" = COALESCE(
78-
NULLIF(rp.billing_account, '')::int,
97+
NULLIF(cp.billing_account, '')::int,
7998
NULLIF(cb."billingAccountId", '')::int
8099
)
81100
LEFT JOIN "billing-accounts"."Client" cl
82101
ON cl."id" = ba."clientId"
83102
LEFT JOIN finance.payment_method pm
84-
ON pm.payment_method_id = rp.payment_method_id
103+
ON pm.payment_method_id = cp.payment_method_id
85104
LEFT JOIN members.member mem
86-
ON mem."userId"::text = rp.winner_id
105+
ON mem."userId"::text = cp.winner_id
106+
WHERE ($3::text[] IS NULL OR cp.payment_type = ANY($3::text[]))
87107
ORDER BY payment_created_at DESC;

src/app.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { ChallengesReportsModule } from "./reports/challenges/challenges-reports
1212
import { IdentityReportsModule } from "./reports/identity/identity-reports.module";
1313
import { ReportsModule } from "./reports/reports.module";
1414
import { MemberSearchModule } from "./reports/member/member-search.module";
15+
import { PaymentReportsModule } from "./reports/payment/payment-reports.module";
1516

1617
@Module({
1718
imports: [
@@ -25,6 +26,7 @@ import { MemberSearchModule } from "./reports/member/member-search.module";
2526
IdentityReportsModule,
2627
ReportsModule,
2728
MemberSearchModule,
29+
PaymentReportsModule,
2830
HealthModule,
2931
],
3032
})

src/reports/member/dto/member-search.dto.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,14 @@ export class MemberSearchBodyDto {
7575
@IsBoolean()
7676
verifiedProfile?: boolean;
7777

78+
@ApiPropertyOptional({
79+
description:
80+
"When true, apply 100% profile completeness checks after lightweight filters are applied.",
81+
})
82+
@IsOptional()
83+
@IsBoolean()
84+
profileComplete?: boolean;
85+
7886
@ApiPropertyOptional({
7987
description:
8088
"Filter by multiple country names or country codes (case-insensitive).",

src/reports/member/member-search.service.spec.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,56 @@ describe("MemberSearchService", () => {
145145
expect(dataSql).not.toContain("COALESCE(m.verified, false) = true");
146146
});
147147

148+
it("adds profileComplete CTE/join only when enabled and keeps count params free of pagination", async () => {
149+
mockDbService.query
150+
.mockResolvedValueOnce([])
151+
.mockResolvedValueOnce([{ total: 0 }]);
152+
153+
await service.search({
154+
countries: ["us"],
155+
profileComplete: true,
156+
page: 3,
157+
limit: 7,
158+
});
159+
160+
const enabledDataSql = mockDbService.query.mock.calls[0][0] as string;
161+
const enabledDataParams = mockDbService.query.mock.calls[0][1] as unknown[];
162+
const enabledCountSql = mockDbService.query.mock.calls[1][0] as string;
163+
const enabledCountParams = mockDbService.query.mock.calls[1][1] as unknown[];
164+
165+
expect(enabledDataSql).toContain("profile_complete_filtered AS (");
166+
expect(enabledDataSql).toContain(
167+
"INNER JOIN profile_complete_filtered pcf ON pcf.user_id = m.\"userId\"",
168+
);
169+
expect(enabledCountSql).toContain("FROM profile_complete_filtered pcf");
170+
expect(enabledCountSql).not.toContain(
171+
"INNER JOIN profile_complete_filtered pcf ON pcf.user_id = fm.user_id",
172+
);
173+
expect(enabledDataParams).toEqual([["us"], 7, 14]);
174+
expect(enabledCountParams).toEqual([["us"]]);
175+
176+
mockDbService.query.mockReset();
177+
mockDbService.query
178+
.mockResolvedValueOnce([])
179+
.mockResolvedValueOnce([{ total: 0 }]);
180+
181+
await service.search({
182+
countries: ["us"],
183+
page: 3,
184+
limit: 7,
185+
});
186+
187+
const disabledDataSql = mockDbService.query.mock.calls[0][0] as string;
188+
const disabledCountSql = mockDbService.query.mock.calls[1][0] as string;
189+
190+
expect(disabledDataSql).not.toContain("profile_complete_filtered AS (");
191+
expect(disabledDataSql).not.toContain(
192+
"INNER JOIN profile_complete_filtered pcf ON pcf.user_id = m.\"userId\"",
193+
);
194+
expect(disabledCountSql).toContain("FROM filtered_members fm");
195+
expect(disabledCountSql).not.toContain("profile_complete_filtered pcf");
196+
});
197+
148198
it("deduplicates skills and keeps last wins value when building skill query", async () => {
149199
const skillA = "550e8400-e29b-41d4-a716-446655440000";
150200
const skillB = "550e8400-e29b-41d4-a716-446655440001";

src/reports/member/member-search.service.ts

Lines changed: 78 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ export class MemberSearchService {
5252
openToWork,
5353
recentlyActive,
5454
verifiedProfile,
55+
profileComplete,
5556
countries,
5657
sortBy = "matchIndex",
5758
sortOrder = "desc",
@@ -203,7 +204,7 @@ member_address AS (
203204
id DESC
204205
)`);
205206

206-
// ------------------------------------------------- dynamic WHERE
207+
// ------------------------------------------------- dynamic WHERE (easy filters first)
207208
const where: string[] = [`m.status = 'ACTIVE'`];
208209

209210
if (openToWork === true) {
@@ -243,6 +244,75 @@ member_address AS (
243244
);
244245
}
245246

247+
const whereClause = where.join(" AND ");
248+
ctes.push(`filtered_members AS (
249+
SELECT m."userId" AS user_id
250+
FROM members.member m
251+
${skillJoin}
252+
WHERE ${whereClause}
253+
)`);
254+
255+
if (profileComplete === true) {
256+
ctes.push(`profile_complete_filtered AS (
257+
SELECT fm.user_id
258+
FROM filtered_members fm
259+
INNER JOIN members.member m2 ON m2."userId" = fm.user_id
260+
WHERE m2.description IS NOT NULL
261+
AND btrim(m2.description) <> ''
262+
AND m2."homeCountryCode" IS NOT NULL
263+
AND EXISTS (
264+
SELECT 1
265+
FROM members."memberAddress" ma2
266+
WHERE ma2."userId" = m2."userId"
267+
AND ma2.city IS NOT NULL
268+
AND btrim(ma2.city) <> ''
269+
)
270+
AND EXISTS (
271+
SELECT 1
272+
FROM members."memberTraits" mt2
273+
INNER JOIN members."memberTraitWork" mw2 ON mw2."memberTraitId" = mt2.id
274+
WHERE mt2."userId" = m2."userId"
275+
)
276+
AND EXISTS (
277+
SELECT 1
278+
FROM members."memberTraits" mt2
279+
INNER JOIN members."memberTraitEducation" me2 ON me2."memberTraitId" = mt2.id
280+
WHERE mt2."userId" = m2."userId"
281+
)
282+
AND EXISTS (
283+
SELECT 1
284+
FROM members."memberTraits" mt2
285+
INNER JOIN members."memberTraitPersonalization" mtp2 ON mtp2."memberTraitId" = mt2.id
286+
WHERE mt2."userId" = m2."userId"
287+
AND mtp2.key = 'openToWork'
288+
AND mtp2.value IS NOT NULL
289+
AND (
290+
NOT (mtp2.value::jsonb ? 'availability')
291+
OR (
292+
mtp2.value::jsonb ? 'availability'
293+
AND mtp2.value::jsonb ? 'preferredRoles'
294+
AND jsonb_typeof(mtp2.value::jsonb -> 'preferredRoles') = 'array'
295+
AND jsonb_array_length(mtp2.value::jsonb -> 'preferredRoles') > 0
296+
)
297+
)
298+
)
299+
AND EXISTS (
300+
SELECT 1
301+
FROM skills.user_skill us2
302+
INNER JOIN skills.user_skill_display_mode usdm2 ON usdm2.id = us2.user_skill_display_mode_id
303+
WHERE us2.user_id = m2."userId"
304+
AND LOWER(usdm2.name) = 'principal'
305+
)
306+
AND EXISTS (
307+
SELECT 1
308+
FROM skills.user_skill us2
309+
INNER JOIN skills.user_skill_display_mode usdm2 ON usdm2.id = us2.user_skill_display_mode_id
310+
WHERE us2.user_id = m2."userId"
311+
AND LOWER(usdm2.name) = 'additional'
312+
)
313+
)`);
314+
}
315+
246316
// Snapshot param count BEFORE adding pagination — count query stops here
247317
const filterParamCount = params.length;
248318

@@ -251,12 +321,15 @@ member_address AS (
251321

252322
// ---------------------------------------------------------------- queries
253323
const ctesBlock = ctes.join(",\n");
254-
const whereClause = where.join(" AND ");
255324
const direction = sortOrder === "asc" ? "ASC" : "DESC";
256325
const orderByClause =
257326
sortBy === "handle"
258327
? `m.handle ${direction}, "matchIndex" DESC NULLS LAST`
259328
: `"matchIndex" ${direction} NULLS LAST, m.handle ASC`;
329+
const profileCompleteJoin =
330+
profileComplete === true
331+
? `INNER JOIN profile_complete_filtered pcf ON pcf.user_id = m."userId"`
332+
: "";
260333

261334
const dataQuery = `
262335
WITH ${ctesBlock}
@@ -278,18 +351,17 @@ SELECT
278351
${matchedSkillsExpr} AS "matchedSkills",
279352
${matchIndexExpr} AS "matchIndex"
280353
FROM members.member m
354+
INNER JOIN filtered_members fm ON fm.user_id = m."userId"
355+
${profileCompleteJoin}
281356
${skillJoin}
282357
LEFT JOIN member_address maddr ON maddr."userId" = m."userId"
283-
WHERE ${whereClause}
284358
ORDER BY ${orderByClause}
285359
LIMIT ${pLimit} OFFSET ${pOffset}`;
286360

287361
const countQuery = `
288362
WITH ${ctesBlock}
289363
SELECT COUNT(*)::integer AS total
290-
FROM members.member m
291-
${skillJoin}
292-
WHERE ${whereClause}`;
364+
FROM ${profileComplete === true ? "profile_complete_filtered pcf" : "filtered_members fm"}`;
293365

294366
const [rows, countRows] = await Promise.all([
295367
this.db.query<RawMemberRow>(dataQuery, params),

src/reports/topcoder/dto/member-payment-accrual.dto.ts renamed to src/reports/payment/dto/member-payment-accrual.dto.ts

File renamed without changes.
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import {
2+
CanActivate,
3+
ExecutionContext,
4+
ForbiddenException,
5+
Injectable,
6+
UnauthorizedException,
7+
} from "@nestjs/common";
8+
import {
9+
AuthUserLike,
10+
getNormalizedRoles,
11+
hasAdminRole,
12+
} from "../../../auth/permissions.util";
13+
14+
@Injectable()
15+
export class AdminPaymentReportsGuard implements CanActivate {
16+
canActivate(context: ExecutionContext): boolean {
17+
const authUser: AuthUserLike | undefined = context
18+
.switchToHttp()
19+
.getRequest().authUser;
20+
21+
if (!authUser) {
22+
throw new UnauthorizedException("You are not authenticated.");
23+
}
24+
25+
if (authUser.isMachine) {
26+
throw new ForbiddenException(
27+
"You do not have the required permissions to access this resource.",
28+
);
29+
}
30+
31+
const roles = getNormalizedRoles(authUser);
32+
33+
if (hasAdminRole(roles)) {
34+
return true;
35+
}
36+
37+
throw new ForbiddenException(
38+
"You do not have the required permissions to access this resource.",
39+
);
40+
}
41+
}

0 commit comments

Comments
 (0)