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
1 change: 1 addition & 0 deletions .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ workflows:
- PM-4490
- PM-4491-fix
- PM-3497_talent-search
- PM-4886

# Production builds are exectuted only on tagged commits to the
# master branch.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ ENGAGEMENTS_DB_URL="postgresql://user:password@localhost:5432/engagements"

# The same report also reads member/profile/project data from the main
# DATABASE_URL connection, including members.member, members.memberAddress,
# members.memberPhone, identity.country, and projects.projects.
# members.memberPhone, identity.country, lookups.Country, and projects.projects.

# Old tc-payments database URL (used by member-tax CSV export script)
OLD_PAYMENTS_DATABASE_URL="postgresql://user:password@localhost:5432/tc_payments?schema=public"
Expand Down
10 changes: 6 additions & 4 deletions TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ it('passes all filters in correct order', async () => {
await service.getPaymentsReport({
billingAccountIds: ['12345'],
challengeIds: ['uuid1'],
engagementIds: ['engagement-uuid1'],
handles: ['user1'],
challengeName: 'Task',
startDate: '2023-01-01',
Expand All @@ -73,12 +74,13 @@ it('passes all filters in correct order', async () => {
['12345'], // include billing accounts
undefined, // exclude billing accounts
['uuid1'], // challenge IDs
['user1'], // handles
'Task', // challenge name
['engagement-uuid1'], // engagement IDs
['user1'], // handles
'Task', // challenge name
'2023-01-01', // start date
'2023-12-31', // end date
100, // min amount
1000, // max amount
100, // min amount
1000, // max amount
['COMPLETED'] // challenge status
]);
});
Expand Down
116 changes: 77 additions & 39 deletions sql/reports/sfdc/payments.sql
Original file line number Diff line number Diff line change
@@ -1,44 +1,82 @@
WITH resolved_payment_references AS (
SELECT
p.payment_id,
p.created_at,
p.billing_account,
p.payment_status,
p.challenge_fee,
p.total_amount,
w.external_id,
w.winner_id,
w.category,
CASE
WHEN w.category IS DISTINCT FROM 'ENGAGEMENT_PAYMENT' THEN w.external_id
END AS challenge_filter_id,
CASE
WHEN w.category = 'ENGAGEMENT_PAYMENT' THEN ea."engagementId"
END AS engagement_filter_id,
CASE
WHEN w.category = 'ENGAGEMENT_PAYMENT' THEN e.title
ELSE c.name
END AS challenge_name,
c.status AS challenge_status,
CASE
WHEN w.category = 'ENGAGEMENT_PAYMENT' THEN 'COMPLETED'
ELSE c.status
END AS reported_challenge_status,
m.handle,
m."userId",
m."firstName",
m."lastName"
FROM finance.payment p
LEFT JOIN finance.winnings w
ON w.winning_id = p.winnings_id
LEFT JOIN challenges."Challenge" c
ON c.id = w.external_id
AND w.category IS DISTINCT FROM 'ENGAGEMENT_PAYMENT'
LEFT JOIN engagements."EngagementAssignment" ea
ON ea.id = w.external_id
AND w.category = 'ENGAGEMENT_PAYMENT'
LEFT JOIN engagements."Engagement" e
ON e.id = ea."engagementId"
LEFT JOIN members.member m
ON m."userId" = w.winner_id::bigint
)
SELECT
p.payment_id as "paymentId",
p.created_at AT TIME ZONE 'America/New_York' as "paymentDate",
p.billing_account as "billingAccountId",
p.payment_status as "paymentStatus",
p.challenge_fee as "challengeFee",
p.total_amount as "paymentAmount", -- Looker cost_transaction.member_payments
w.external_id as "challengeId",
w.category,
(w.category = 'TASK_PAYMENT') AS "isTask",
c.name AS "challengeName",
c.status AS "challengeStatus",
m.handle AS "winnerHandle",
m."userId" as "winnerId",
m."firstName" as "winnerFirstName",
m."lastName" as "winnerLastName"
FROM finance.payment p
LEFT JOIN finance.winnings w
ON w.winning_id = p.winnings_id
LEFT JOIN challenges."Challenge" c
ON c.id = w.external_id
LEFT JOIN members.member m
ON m."userId" = w.winner_id::bigint
payment_id as "paymentId",
created_at AT TIME ZONE 'America/New_York' as "paymentDate",
billing_account as "billingAccountId",
payment_status as "paymentStatus",
challenge_fee as "challengeFee",
total_amount as "paymentAmount", -- Looker cost_transaction.member_payments
external_id as "challengeId",
category,
(category = 'TASK_PAYMENT') AS "isTask",
challenge_name AS "challengeName",
reported_challenge_status AS "challengeStatus",
handle AS "winnerHandle",
"userId" as "winnerId",
"firstName" as "winnerFirstName",
"lastName" as "winnerLastName"
FROM resolved_payment_references
WHERE
($1::text[] IS NULL OR p.billing_account = ANY($1::text[]))
AND ($2::text[] IS NULL OR p.billing_account <> ANY($2::text[]))
AND ($3::text[] IS NULL OR c.id = ANY($3::text[]))
AND ($4::text[] IS NULL OR w.winner_id::text IN (
($1::text[] IS NULL OR billing_account = ANY($1::text[]))
AND ($2::text[] IS NULL OR billing_account <> ANY($2::text[]))
AND (
($3::text[] IS NULL AND $4::text[] IS NULL)
OR ($3::text[] IS NOT NULL AND challenge_filter_id = ANY($3::text[]))
OR ($4::text[] IS NOT NULL AND engagement_filter_id = ANY($4::text[]))
)
AND ($5::text[] IS NULL OR winner_id::text IN (
SELECT m2."userId"::text
FROM members.member m2
WHERE m2.handle = ANY($4::text[])
WHERE m2.handle = ANY($5::text[])
))
AND ($5::text IS NULL OR w.external_id IN (
SELECT c2.id
FROM challenges."Challenge" c2
WHERE c2.name ILIKE '%' || $5 || '%'
))
AND p.created_at >= COALESCE($6::timestamptz, (NOW() AT TIME ZONE 'UTC') - INTERVAL '45 days')
AND ($7::timestamptz IS NULL OR p.created_at <= $7::timestamptz)
AND ($8::numeric IS NULL OR p.total_amount >= $8::numeric)
AND ($9::numeric IS NULL OR p.total_amount <= $9::numeric)
AND ($10::text[] IS NULL OR c.status::text = ANY($10::text[]))
AND ($11::text[] IS NULL OR p.payment_status::text = ANY($11::text[]))
ORDER BY p.created_at DESC
AND ($6::text IS NULL OR challenge_name ILIKE '%' || $6 || '%')
AND created_at >= COALESCE($7::timestamptz, (NOW() AT TIME ZONE 'UTC') - INTERVAL '45 days')
AND ($8::timestamptz IS NULL OR created_at <= $8::timestamptz)
AND ($9::numeric IS NULL OR total_amount >= $9::numeric)
AND ($10::numeric IS NULL OR total_amount <= $10::numeric)
AND ($11::text[] IS NULL OR reported_challenge_status::text = ANY($11::text[]))
AND ($12::text[] IS NULL OR payment_status::text = ANY($12::text[]))
ORDER BY created_at DESC
40 changes: 36 additions & 4 deletions sql/reports/topcoder/engagement-data-members.sql
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,49 @@ SELECT
NULLIF(BTRIM(m."firstName"), '') AS first_name,
NULLIF(BTRIM(m."lastName"), '') AS last_name,
NULLIF(BTRIM(m.email), '') AS email,
COALESCE(NULLIF(BTRIM(c.country_name), ''), NULLIF(BTRIM(m.country), ''))
AS country,
COALESCE(
NULLIF(BTRIM(home_lookup_code.name), ''),
NULLIF(BTRIM(home_lookup_id.name), ''),
NULLIF(BTRIM(home_identity_alpha3.country_name), ''),
NULLIF(BTRIM(home_identity_code.country_name), ''),
NULLIF(BTRIM(home_identity_alpha2.country_name), '')
) AS home_country,
COALESCE(
NULLIF(BTRIM(comp_lookup_code.name), ''),
NULLIF(BTRIM(comp_lookup_id.name), ''),
NULLIF(BTRIM(comp_identity_alpha3.country_name), ''),
NULLIF(BTRIM(comp_identity_code.country_name), ''),
NULLIF(BTRIM(comp_identity_alpha2.country_name), '')
) AS competition_country,
NULLIF(BTRIM(m."homeCountryCode"), '') AS home_country_code,
NULLIF(BTRIM(m."competitionCountryCode"), '') AS competition_country_code,
preferred_address.street_addr_1,
preferred_address.street_addr_2,
preferred_address.city,
preferred_address.state_code,
preferred_address.zip,
preferred_phone.phone_number
FROM members.member m
LEFT JOIN identity.country c
ON c.iso_alpha3_code = m."competitionCountryCode"
LEFT JOIN lookups."Country" AS home_lookup_code
ON UPPER(home_lookup_code."countryCode") = UPPER(BTRIM(m."homeCountryCode"))
LEFT JOIN lookups."Country" AS home_lookup_id
ON UPPER(home_lookup_id.id) = UPPER(BTRIM(m."homeCountryCode"))
LEFT JOIN identity.country AS home_identity_alpha3
ON UPPER(home_identity_alpha3.iso_alpha3_code) = UPPER(BTRIM(m."homeCountryCode"))
LEFT JOIN identity.country AS home_identity_code
ON UPPER(home_identity_code.country_code) = UPPER(BTRIM(m."homeCountryCode"))
LEFT JOIN identity.country AS home_identity_alpha2
ON UPPER(home_identity_alpha2.iso_alpha2_code) = UPPER(BTRIM(m."homeCountryCode"))
LEFT JOIN lookups."Country" AS comp_lookup_code
ON UPPER(comp_lookup_code."countryCode") = UPPER(BTRIM(m."competitionCountryCode"))
LEFT JOIN lookups."Country" AS comp_lookup_id
ON UPPER(comp_lookup_id.id) = UPPER(BTRIM(m."competitionCountryCode"))
LEFT JOIN identity.country AS comp_identity_alpha3
ON UPPER(comp_identity_alpha3.iso_alpha3_code) = UPPER(BTRIM(m."competitionCountryCode"))
LEFT JOIN identity.country AS comp_identity_code
ON UPPER(comp_identity_code.country_code) = UPPER(BTRIM(m."competitionCountryCode"))
LEFT JOIN identity.country AS comp_identity_alpha2
ON UPPER(comp_identity_alpha2.iso_alpha2_code) = UPPER(BTRIM(m."competitionCountryCode"))
LEFT JOIN LATERAL (
SELECT
NULLIF(BTRIM(a."streetAddr1"), '') AS street_addr_1,
Expand Down
10 changes: 6 additions & 4 deletions src/reports/member/dto/member-search.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,12 +77,14 @@ export class MemberSearchBodyDto {

@ApiPropertyOptional({
description:
"Filter by country name or code as stored in the member location (case-insensitive).",
example: "Australia",
"Filter by multiple country names or country codes (case-insensitive).",
type: [String],
example: ["US", "Australia"],
})
@IsOptional()
@IsString()
country?: string;
@IsArray()
@IsString({ each: true })
countries?: string[];

@ApiPropertyOptional({
description:
Expand Down
2 changes: 1 addition & 1 deletion src/reports/member/member-search.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ describe("MemberSearchController", () => {

it("delegates search requests to the service and returns response", async () => {
const body = {
country: "Australia",
countries: ["Australia"],
sortBy: "handle" as const,
sortOrder: "asc" as const,
page: 2,
Expand Down
41 changes: 38 additions & 3 deletions src/reports/member/member-search.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ describe("MemberSearchService", () => {
.mockResolvedValueOnce([{ total: 0 }]);

await service.search({
country: "us",
countries: ["us"],
page: 2,
limit: 5,
sortBy: "handle",
Expand All @@ -106,8 +106,43 @@ describe("MemberSearchService", () => {
expect(dataSql).toContain(
'ORDER BY m.handle ASC, "matchIndex" DESC NULLS LAST',
);
expect(dataParams).toEqual(["us", 5, 5]);
expect(countParams).toEqual(["us"]);
expect(dataSql).toContain('LOWER(m."homeCountryCode") = ANY($1::text[])');
expect(dataParams).toEqual([["us"], 5, 5]);
expect(countParams).toEqual([["us"]]);
});

it("treats empty countries as no country filter", async () => {
mockDbService.query
.mockResolvedValueOnce([])
.mockResolvedValueOnce([{ total: 0 }]);

await service.search({ countries: [] });

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(countParams).toEqual([]);
});

it("does not apply boolean filters when explicitly false", async () => {
mockDbService.query
.mockResolvedValueOnce([])
.mockResolvedValueOnce([{ total: 0 }]);

await service.search({
openToWork: false,
recentlyActive: false,
verifiedProfile: false,
});

const dataSql = mockDbService.query.mock.calls[0][0] as string;

expect(dataSql).not.toContain('m."availableForGigs" = true');
expect(dataSql).not.toContain(
'EXISTS (SELECT 1 FROM recently_active ra WHERE ra.user_id = m."userId")',
);
expect(dataSql).not.toContain("COALESCE(m.verified, false) = true");
});

it("deduplicates skills and keeps last wins value when building skill query", async () => {
Expand Down
51 changes: 43 additions & 8 deletions src/reports/member/member-search.service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { alpha3ToCountryName } from "../../common/country.util";
import { DbService } from "../../db/db.service";
import { MemberSearchBodyDto } from "./dto/member-search.dto";
import {
Expand All @@ -20,6 +21,26 @@ type RawMemberRow = {
matchIndex: number;
};

function formatLocation(location: string): string {
const normalizedLocation = String(location || "").trim();
if (!normalizedLocation) {
return "";
}

const parts = normalizedLocation.split(/\s+/);
const lastPart = parts[parts.length - 1];
const mappedCountryName = alpha3ToCountryName(lastPart);
if (!mappedCountryName) {
return normalizedLocation;
}

if (parts.length === 1) {
return mappedCountryName;
}

return `${parts.slice(0, -1).join(" ")}, ${mappedCountryName}`;
}

@Injectable()
export class MemberSearchService {
constructor(private readonly db: DbService) {}
Expand All @@ -31,7 +52,7 @@ export class MemberSearchService {
openToWork,
recentlyActive,
verifiedProfile,
country,
countries,
sortBy = "matchIndex",
sortOrder = "desc",
page = 1,
Expand Down Expand Up @@ -185,26 +206,40 @@ member_address AS (
// ------------------------------------------------- dynamic WHERE
const where: string[] = [`m.status = 'ACTIVE'`];

if (typeof openToWork === "boolean") {
if (openToWork === true) {
where.push(`m."availableForGigs" = true`);
}

if (typeof recentlyActive === "boolean") {
if (recentlyActive === true) {
where.push(
`EXISTS (SELECT 1 FROM recently_active ra WHERE ra.user_id = m."userId")`,
);
}

if (typeof verifiedProfile === "boolean") {
if (verifiedProfile === true) {
where.push(
`(COALESCE(m.verified, false) = true OR EXISTS (SELECT 1 FROM verified_via_trolley vt WHERE vt.user_id = m."userId"))`,
);
}

if (country) {
const pCountry = p(country);
const normalizedCountries = Array.isArray(countries)
? [
...new Set(
countries
.map((value) => String(value).trim().toLowerCase())
.filter(Boolean),
),
]
: [];

if (normalizedCountries.length > 0) {
const pCountries = p(normalizedCountries);
where.push(
`(LOWER(m."homeCountryCode") = LOWER(${pCountry}) OR LOWER(m."competitionCountryCode") = LOWER(${pCountry}) OR LOWER(m.country) = LOWER(${pCountry}))`,
`(
LOWER(m."homeCountryCode") = ANY(${pCountries}::text[])
OR LOWER(m."competitionCountryCode") = ANY(${pCountries}::text[])
OR LOWER(m.country) = ANY(${pCountries}::text[])
)`,
);
}

Expand Down Expand Up @@ -274,7 +309,7 @@ WHERE ${whereClause}`;
isRecentlyActive: row.isRecentlyActive ?? false,
isVerified: row.isVerified ?? false,
openToWork: row.openToWork ?? false,
location: row.location || "",
location: formatLocation(row.location),
matchedSkills: row.matchedSkills ?? [],
matchIndex: row.matchIndex ?? 0,
}));
Expand Down
Loading
Loading