Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 1 addition & 6 deletions .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -64,12 +64,7 @@ workflows:
branches:
only:
- develop
- pm-1127_1
- PM-4305
- PM-4490
- PM-4491-fix
- PM-3497_talent-search
- PM-4886
- PM-4949

# Production builds are exectuted only on tagged commits to the
# master branch.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ recent_payments AS (
SELECT
w.winning_id,
w.winner_id,
w.type,
w.description,
w.category,
w.external_id AS challenge_id,
Expand All @@ -52,36 +51,57 @@ recent_payments AS (
WHERE w.type = 'PAYMENT'
AND p.created_at >= pr.start_date
AND p.created_at <= pr.end_date
),
categorized_payments AS (
SELECT
rp.*,
CASE
WHEN rp.category = 'TAAS_PAYMENT' THEN 'TaaS Payment'
WHEN rp.category = 'TOPGEAR_PAYMENT' THEN 'Topgear Payment'
WHEN rp.category = 'ENGAGEMENT_PAYMENT' THEN 'Engagement Payment'
WHEN rp.category IN (
'TASK_PAYMENT',
'TASK_REVIEW_PAYMENT',
'TASK_COPILOT_PAYMENT',
'DEPLOYMENT_TASK_PAYMENT',
'PROJECT_DEPLOYMENT_TASK_PAYMENT'
) THEN 'Task Payment'
ELSE 'Challenge Payment'
END AS payment_type
FROM recent_payments rp
)
SELECT
rp.payment_created_at AS payment_created_at,
rp.payment_id,
rp.description AS payment_description,
rp.challenge_id,
rp.payment_status,
rp.type AS payment_type,
cp.payment_created_at AS payment_created_at,
cp.payment_id,
cp.description AS payment_description,
cp.challenge_id,
cp.payment_status,
cp.payment_type,
mem.handle AS payee_handle,
pm.name AS payment_method,
ba."name" AS billing_account_name,
cl."name" AS customer_name,
ba."subcontractingEndCustomer" AS reporting_account_name,
rp.winner_id AS member_id,
to_char(c."createdAt", 'YYYY-MM-DD') AS challenge_created_date,
rp.gross_amount AS user_payment_gross_amount
FROM recent_payments rp
cp.winner_id AS member_id,
CASE
WHEN cp.payment_type = 'Engagement Payment' THEN to_char(cp.payment_created_at, 'YYYY-MM-DD')
ELSE to_char(c."createdAt", 'YYYY-MM-DD')
END AS challenge_created_date, cp.gross_amount AS user_payment_gross_amount
FROM categorized_payments cp
LEFT JOIN challenges."Challenge" c
ON c."id" = rp.challenge_id
ON c."id" = cp.challenge_id
LEFT JOIN challenges."ChallengeBilling" cb
ON cb."challengeId" = c."id"
LEFT JOIN "billing-accounts"."BillingAccount" ba
ON ba."id" = COALESCE(
NULLIF(rp.billing_account, '')::int,
NULLIF(cp.billing_account, '')::int,
NULLIF(cb."billingAccountId", '')::int
)
LEFT JOIN "billing-accounts"."Client" cl
ON cl."id" = ba."clientId"
LEFT JOIN finance.payment_method pm
ON pm.payment_method_id = rp.payment_method_id
ON pm.payment_method_id = cp.payment_method_id
LEFT JOIN members.member mem
ON mem."userId"::text = rp.winner_id
ON mem."userId"::text = cp.winner_id
WHERE ($3::text[] IS NULL OR cp.payment_type = ANY($3::text[]))
ORDER BY payment_created_at DESC;
2 changes: 2 additions & 0 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { ChallengesReportsModule } from "./reports/challenges/challenges-reports
import { IdentityReportsModule } from "./reports/identity/identity-reports.module";
import { ReportsModule } from "./reports/reports.module";
import { MemberSearchModule } from "./reports/member/member-search.module";
import { PaymentReportsModule } from "./reports/payment/payment-reports.module";

@Module({
imports: [
Expand All @@ -25,6 +26,7 @@ import { MemberSearchModule } from "./reports/member/member-search.module";
IdentityReportsModule,
ReportsModule,
MemberSearchModule,
PaymentReportsModule,
HealthModule,
],
})
Expand Down
8 changes: 8 additions & 0 deletions src/reports/member/dto/member-search.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,14 @@ export class MemberSearchBodyDto {
@IsBoolean()
verifiedProfile?: boolean;

@ApiPropertyOptional({
description:
"When true, apply 100% profile completeness checks after lightweight filters are applied.",
})
@IsOptional()
@IsBoolean()
profileComplete?: boolean;

@ApiPropertyOptional({
description:
"Filter by multiple country names or country codes (case-insensitive).",
Expand Down
50 changes: 50 additions & 0 deletions src/reports/member/member-search.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,56 @@ describe("MemberSearchService", () => {
expect(dataSql).not.toContain("COALESCE(m.verified, false) = true");
});

it("adds profileComplete CTE/join only when enabled and keeps count params free of pagination", async () => {
mockDbService.query
.mockResolvedValueOnce([])
.mockResolvedValueOnce([{ total: 0 }]);

await service.search({
countries: ["us"],
profileComplete: true,
page: 3,
limit: 7,
});

const enabledDataSql = mockDbService.query.mock.calls[0][0] as string;
const enabledDataParams = mockDbService.query.mock.calls[0][1] as unknown[];
const enabledCountSql = mockDbService.query.mock.calls[1][0] as string;
const enabledCountParams = mockDbService.query.mock.calls[1][1] as unknown[];

expect(enabledDataSql).toContain("profile_complete_filtered AS (");
expect(enabledDataSql).toContain(
"INNER JOIN profile_complete_filtered pcf ON pcf.user_id = m.\"userId\"",
);
expect(enabledCountSql).toContain("FROM profile_complete_filtered pcf");
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"]]);

mockDbService.query.mockReset();
mockDbService.query
.mockResolvedValueOnce([])
.mockResolvedValueOnce([{ total: 0 }]);

await service.search({
countries: ["us"],
page: 3,
limit: 7,
});

const disabledDataSql = mockDbService.query.mock.calls[0][0] as string;
const disabledCountSql = mockDbService.query.mock.calls[1][0] as string;

expect(disabledDataSql).not.toContain("profile_complete_filtered AS (");
expect(disabledDataSql).not.toContain(
"INNER JOIN profile_complete_filtered pcf ON pcf.user_id = m.\"userId\"",
);
expect(disabledCountSql).toContain("FROM filtered_members fm");
expect(disabledCountSql).not.toContain("profile_complete_filtered pcf");
});

it("deduplicates skills and keeps last wins value when building skill query", async () => {
const skillA = "550e8400-e29b-41d4-a716-446655440000";
const skillB = "550e8400-e29b-41d4-a716-446655440001";
Expand Down
84 changes: 78 additions & 6 deletions src/reports/member/member-search.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ export class MemberSearchService {
openToWork,
recentlyActive,
verifiedProfile,
profileComplete,
countries,
sortBy = "matchIndex",
sortOrder = "desc",
Expand Down Expand Up @@ -203,7 +204,7 @@ member_address AS (
id DESC
)`);

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

if (openToWork === true) {
Expand Down Expand Up @@ -243,6 +244,75 @@ member_address AS (
);
}

const whereClause = where.join(" AND ");
ctes.push(`filtered_members AS (
SELECT m."userId" AS user_id
FROM members.member m
${skillJoin}
WHERE ${whereClause}
)`);

if (profileComplete === true) {
ctes.push(`profile_complete_filtered AS (
SELECT fm.user_id
FROM filtered_members fm
INNER JOIN members.member m2 ON m2."userId" = fm.user_id
WHERE m2.description IS NOT NULL
AND btrim(m2.description) <> ''
AND m2."homeCountryCode" IS NOT NULL
AND EXISTS (
SELECT 1
FROM members."memberAddress" ma2
WHERE ma2."userId" = m2."userId"
AND ma2.city IS NOT NULL
AND btrim(ma2.city) <> ''
)
AND EXISTS (
SELECT 1
FROM members."memberTraits" mt2
INNER JOIN members."memberTraitWork" mw2 ON mw2."memberTraitId" = mt2.id
WHERE mt2."userId" = m2."userId"
)
AND EXISTS (
SELECT 1
FROM members."memberTraits" mt2
INNER JOIN members."memberTraitEducation" me2 ON me2."memberTraitId" = mt2.id
WHERE mt2."userId" = m2."userId"
)
AND EXISTS (
SELECT 1
FROM members."memberTraits" mt2
INNER JOIN members."memberTraitPersonalization" mtp2 ON mtp2."memberTraitId" = mt2.id
WHERE mt2."userId" = m2."userId"
AND mtp2.key = 'openToWork'
AND mtp2.value IS NOT NULL
AND (
NOT (mtp2.value::jsonb ? 'availability')
OR (
mtp2.value::jsonb ? 'availability'
AND mtp2.value::jsonb ? 'preferredRoles'
AND jsonb_typeof(mtp2.value::jsonb -> 'preferredRoles') = 'array'
AND jsonb_array_length(mtp2.value::jsonb -> 'preferredRoles') > 0
)
)
)
AND EXISTS (
SELECT 1
FROM skills.user_skill us2
INNER JOIN skills.user_skill_display_mode usdm2 ON usdm2.id = us2.user_skill_display_mode_id
WHERE us2.user_id = m2."userId"
AND LOWER(usdm2.name) = 'principal'
)
AND EXISTS (
SELECT 1
FROM skills.user_skill us2
INNER JOIN skills.user_skill_display_mode usdm2 ON usdm2.id = us2.user_skill_display_mode_id
WHERE us2.user_id = m2."userId"
AND LOWER(usdm2.name) = 'additional'
)
)`);
}

// Snapshot param count BEFORE adding pagination — count query stops here
const filterParamCount = params.length;

Expand All @@ -251,12 +321,15 @@ member_address AS (

// ---------------------------------------------------------------- queries
const ctesBlock = ctes.join(",\n");
const whereClause = where.join(" AND ");
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 profileCompleteJoin =
profileComplete === true
? `INNER JOIN profile_complete_filtered pcf ON pcf.user_id = m."userId"`
: "";

const dataQuery = `
WITH ${ctesBlock}
Expand All @@ -278,18 +351,17 @@ SELECT
${matchedSkillsExpr} AS "matchedSkills",
${matchIndexExpr} AS "matchIndex"
FROM members.member m
INNER JOIN filtered_members fm ON fm.user_id = m."userId"
${profileCompleteJoin}
${skillJoin}
LEFT JOIN member_address maddr ON maddr."userId" = m."userId"
WHERE ${whereClause}
ORDER BY ${orderByClause}
LIMIT ${pLimit} OFFSET ${pOffset}`;

const countQuery = `
WITH ${ctesBlock}
SELECT COUNT(*)::integer AS total
FROM members.member m
${skillJoin}
WHERE ${whereClause}`;
FROM ${profileComplete === true ? "profile_complete_filtered pcf" : "filtered_members fm"}`;

const [rows, countRows] = await Promise.all([
this.db.query<RawMemberRow>(dataQuery, params),
Expand Down
41 changes: 41 additions & 0 deletions src/reports/payment/guards/admin-payment-reports.guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import {
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
UnauthorizedException,
} from "@nestjs/common";
import {
AuthUserLike,
getNormalizedRoles,
hasAdminRole,
} from "../../../auth/permissions.util";

@Injectable()
export class AdminPaymentReportsGuard 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) {
throw new ForbiddenException(
"You do not have the required permissions to access this resource.",
);
}

const roles = getNormalizedRoles(authUser);

if (hasAdminRole(roles)) {
return true;
}

throw new ForbiddenException(
"You do not have the required permissions to access this resource.",
);
}
}
Loading
Loading