From 00615d563f4b81e313449cbaa7339b3e9e18282c Mon Sep 17 00:00:00 2001 From: jmgasper Date: Fri, 31 Jul 2026 01:14:07 +1000 Subject: [PATCH 1/9] PM-5719: Add member payment dashboard data What was broken The Reports Portal API returned only the original dashboards, so it could not supply monthly member payment values by type or by customer. Root cause There were no dashboard SQL queries, DTOs, service mappings, or export paths for the two member payment datasets. What was changed Added monthly payment totals by canonical payment type and by the top five clients plus Other Customers, including zero-filled ranges, aggregate and detail responses, CSV exports, Swagger metadata, and API documentation. Any added/updated tests Added and updated dashboard controller, service, and SQL tests for amount mapping, payment categories, customer ranking, zero filling, and CSV output. --- README.md | 15 +- .../dashboard/member-payment-by-customer.sql | 147 +++++++++++++++ .../dashboard/member-payment-by-month.sql | 87 +++++++++ .../dashboard-reports.controller.spec.ts | 80 +++++++-- .../dashboard/dashboard-reports.controller.ts | 12 +- .../dashboard/dashboard-reports.dto.ts | 129 ++++++++++++- .../dashboard-reports.service.spec.ts | 169 +++++++++++++++++- .../dashboard/dashboard-reports.service.ts | 147 ++++++++++++++- .../dashboard/dashboard-reports.sql.spec.ts | 56 ++++++ 9 files changed, 807 insertions(+), 35 deletions(-) create mode 100644 sql/reports/dashboard/member-payment-by-customer.sql create mode 100644 sql/reports/dashboard/member-payment-by-month.sql diff --git a/README.md b/README.md index 60749df..3b8dc07 100644 --- a/README.md +++ b/README.md @@ -12,9 +12,11 @@ The Reports Portal dashboard API is available under `/v6/reports/dashboard`: - `GET /v6/reports/dashboard` returns all dashboards, keyed as - `newSignups`, `membersPaid`, and `challengeParticipation`. + `newSignups`, `membersPaid`, `challengeParticipation`, + `memberPaymentByMonth`, and `memberPaymentByCustomer`. - `GET /v6/reports/dashboard/:dashboard` returns one dashboard. Supported - slugs are `new-signups`, `members-paid`, and `challenge-participation`. + slugs are `new-signups`, `members-paid`, `challenge-participation`, + `member-payment-by-month`, and `member-payment-by-customer`. - `GET /v6/reports/dashboard/export` downloads all monthly dashboard rows as a flat CSV. - `GET /v6/reports/dashboard/:dashboard/export` downloads one dashboard as a @@ -24,8 +26,9 @@ All endpoints accept optional ISO-8601 `startDate` (inclusive) and `endDate` (exclusive) query parameters. With neither bound, the monthly series covers the latest six UTC calendar months, including the current month. With one bound, the other is derived six calendar months away. The response always -includes the resolved timestamps, zero-filled calendar months, and an -all-time summary. +includes the resolved timestamps and zero-filled calendar months. The signup, +members-paid, and challenge-participation dashboards also include an all-time +summary. Dashboard figures use these shared definitions: @@ -34,6 +37,10 @@ Dashboard figures use these shared definitions: - Paid-member activity requires a `PAID` finance payment for a `PAYMENT` winning. Its event timestamp is `date_paid`, falling back to `created_at`. Members are deduplicated within each payment bucket and month. +- Member-payment values use the latest payment version and sum `gross_amount`, + falling back to `total_amount`. The payment-by-customer dashboard ranks the + top five billing-account clients across the selected range and groups all + unnamed or remaining clients under `Other Customers`. - Registrations are Submitter resource creation events. Submissions are non-deleted review submission events, using `submittedDate` and falling back to `createdAt`. Each category is deduplicated independently by member diff --git a/sql/reports/dashboard/member-payment-by-customer.sql b/sql/reports/dashboard/member-payment-by-customer.sql new file mode 100644 index 0000000..d506491 --- /dev/null +++ b/sql/reports/dashboard/member-payment-by-customer.sql @@ -0,0 +1,147 @@ +-- Monthly paid-member values split by the selected range's top five clients. +-- +-- Parameters: +-- $1 timestamptz - inclusive reporting range start +-- $2 timestamptz - exclusive reporting range end +-- +-- The same ranked customer series is used for every month. Payments for +-- unranked or unnamed clients are grouped under Other Customers. +-- Billing-account ids are normalized and compared as text so the historical +-- zero sentinel falls back to challenge billing without unsafe integer casts. +WITH bounds AS ( + SELECT + $1::timestamptz AT TIME ZONE 'UTC' AS start_at, + $2::timestamptz AT TIME ZONE 'UTC' AS end_at +), +months AS ( + SELECT GENERATE_SERIES( + DATE_TRUNC('month', b.start_at), + DATE_TRUNC('month', b.end_at - INTERVAL '1 microsecond'), + INTERVAL '1 month' + ) AS month_start + FROM bounds b +), +latest_payment_versions AS MATERIALIZED ( + SELECT + p.winnings_id, + MAX(p.version) AS max_version + FROM finance.payment p + GROUP BY p.winnings_id +), +paid_events AS MATERIALIZED ( + SELECT + COALESCE(p.date_paid, p.created_at) AS paid_at, + COALESCE(p.gross_amount, p.total_amount, 0) AS amount, + NULLIF(TRIM(cl.id), '') AS customer_id, + NULLIF(TRIM(cl.name), '') AS customer_label + FROM finance.payment p + JOIN latest_payment_versions lpv + ON lpv.winnings_id = p.winnings_id + AND lpv.max_version = p.version + JOIN finance.winnings w + ON w.winning_id = p.winnings_id + LEFT JOIN challenges."Challenge" c + ON c.id = w.external_id + LEFT JOIN challenges."ChallengeBilling" cb + ON cb."challengeId" = c.id + LEFT JOIN "billing-accounts"."BillingAccount" payment_ba + ON payment_ba.id::text = NULLIF( + TRIM(LEADING '0' FROM TRIM(p.billing_account)), + '' + ) + LEFT JOIN "billing-accounts"."BillingAccount" challenge_ba + ON challenge_ba.id::text = NULLIF( + TRIM(LEADING '0' FROM TRIM(cb."billingAccountId")), + '' + ) + LEFT JOIN "billing-accounts"."Client" cl + ON cl.id = COALESCE(payment_ba."clientId", challenge_ba."clientId") + WHERE p.payment_status = 'PAID' + AND w.type = 'PAYMENT' + AND COALESCE(p.date_paid, p.created_at) IS NOT NULL + AND NULLIF(TRIM(w.winner_id), '') IS NOT NULL + AND w.category::text IS DISTINCT FROM 'TOPGEAR_PAYMENT' +), +selected_events AS ( + SELECT pe.* + FROM paid_events pe + CROSS JOIN bounds b + WHERE pe.paid_at >= b.start_at + AND pe.paid_at < b.end_at +), +customer_totals AS ( + SELECT + se.customer_id, + se.customer_label, + SUM(se.amount) AS total_amount + FROM selected_events se + WHERE se.customer_id IS NOT NULL + AND se.customer_label IS NOT NULL + GROUP BY se.customer_id, se.customer_label +), +ranked_customers AS ( + SELECT + ct.customer_id, + ct.customer_label, + ROW_NUMBER() OVER ( + ORDER BY + ct.total_amount DESC, + LOWER(ct.customer_label), + ct.customer_label, + ct.customer_id + ) AS series_order + FROM customer_totals ct +), +top_customers AS ( + SELECT + rc.customer_id, + rc.customer_label, + rc.series_order + FROM ranked_customers rc + WHERE rc.series_order <= 5 +), +series AS ( + SELECT + 'customer-' || tc.customer_id AS series_key, + tc.customer_id, + tc.customer_label, + tc.series_order + FROM top_customers tc + + UNION ALL + + SELECT + 'other-customers' AS series_key, + NULL::text AS customer_id, + 'Other Customers' AS customer_label, + 6 AS series_order +), +monthly_amounts AS ( + SELECT + DATE_TRUNC('month', se.paid_at) AS month_start, + COALESCE( + 'customer-' || tc.customer_id, + 'other-customers' + ) AS series_key, + SUM(se.amount) AS amount + FROM selected_events se + LEFT JOIN top_customers tc + ON tc.customer_id = se.customer_id + AND tc.customer_label = se.customer_label + GROUP BY + DATE_TRUNC('month', se.paid_at), + COALESCE('customer-' || tc.customer_id, 'other-customers') +) +SELECT + TO_CHAR(m.month_start, 'YYYY-MM-01') AS month, + s.series_key, + s.customer_id, + s.customer_label, + s.series_order, + COALESCE(ma.amount, 0) AS amount +FROM months m +CROSS JOIN series s +LEFT JOIN monthly_amounts ma + ON ma.month_start = m.month_start + AND ma.series_key = s.series_key +ORDER BY m.month_start, s.series_order; diff --git a/sql/reports/dashboard/member-payment-by-month.sql b/sql/reports/dashboard/member-payment-by-month.sql new file mode 100644 index 0000000..6623be8 --- /dev/null +++ b/sql/reports/dashboard/member-payment-by-month.sql @@ -0,0 +1,87 @@ +-- Monthly paid-member values split by canonical payment bucket. +-- +-- Parameters: +-- $1 timestamptz - inclusive reporting range start +-- $2 timestamptz - exclusive reporting range end +-- +-- Only the latest version of each payment is considered. Gross amount is the +-- preferred member-payment value, with total amount used as a fallback. +WITH bounds AS ( + SELECT + $1::timestamptz AT TIME ZONE 'UTC' AS start_at, + $2::timestamptz AT TIME ZONE 'UTC' AS end_at +), +months AS ( + SELECT GENERATE_SERIES( + DATE_TRUNC('month', b.start_at), + DATE_TRUNC('month', b.end_at - INTERVAL '1 microsecond'), + INTERVAL '1 month' + ) AS month_start + FROM bounds b +), +latest_payment_versions AS MATERIALIZED ( + SELECT + p.winnings_id, + MAX(p.version) AS max_version + FROM finance.payment p + GROUP BY p.winnings_id +), +paid_events AS MATERIALIZED ( + SELECT + COALESCE(p.date_paid, p.created_at) AS paid_at, + COALESCE(p.gross_amount, p.total_amount, 0) AS amount, + CASE + WHEN w.category::text = 'TAAS_PAYMENT' THEN 'taas' + WHEN w.category::text = 'ENGAGEMENT_PAYMENT' THEN 'engagement' + WHEN w.category::text IN ( + 'TASK_PAYMENT', + 'TASK_REVIEW_PAYMENT', + 'TASK_COPILOT_PAYMENT', + 'DEPLOYMENT_TASK_PAYMENT', + 'PROJECT_DEPLOYMENT_TASK_PAYMENT' + ) THEN 'task' + ELSE 'challenge' + END AS payment_type + FROM finance.payment p + JOIN latest_payment_versions lpv + ON lpv.winnings_id = p.winnings_id + AND lpv.max_version = p.version + JOIN finance.winnings w + ON w.winning_id = p.winnings_id + WHERE p.payment_status = 'PAID' + AND w.type = 'PAYMENT' + AND COALESCE(p.date_paid, p.created_at) IS NOT NULL + AND NULLIF(TRIM(w.winner_id), '') IS NOT NULL + AND w.category::text IS DISTINCT FROM 'TOPGEAR_PAYMENT' +), +selected_months AS ( + SELECT + DATE_TRUNC('month', pe.paid_at) AS month_start, + COALESCE(SUM(pe.amount) FILTER ( + WHERE pe.payment_type = 'taas' + ), 0) AS taas, + COALESCE(SUM(pe.amount) FILTER ( + WHERE pe.payment_type = 'task' + ), 0) AS task, + COALESCE(SUM(pe.amount) FILTER ( + WHERE pe.payment_type = 'challenge' + ), 0) AS challenge, + COALESCE(SUM(pe.amount) FILTER ( + WHERE pe.payment_type = 'engagement' + ), 0) AS engagement + FROM paid_events pe + CROSS JOIN bounds b + WHERE pe.paid_at >= b.start_at + AND pe.paid_at < b.end_at + GROUP BY DATE_TRUNC('month', pe.paid_at) +) +SELECT + TO_CHAR(m.month_start, 'YYYY-MM-01') AS month, + COALESCE(sm.taas, 0) AS taas, + COALESCE(sm.task, 0) AS task, + COALESCE(sm.challenge, 0) AS challenge, + COALESCE(sm.engagement, 0) AS engagement +FROM months m +LEFT JOIN selected_months sm + ON sm.month_start = m.month_start +ORDER BY m.month_start; diff --git a/src/reports/dashboard/dashboard-reports.controller.spec.ts b/src/reports/dashboard/dashboard-reports.controller.spec.ts index 1831e7a..b634edc 100644 --- a/src/reports/dashboard/dashboard-reports.controller.spec.ts +++ b/src/reports/dashboard/dashboard-reports.controller.spec.ts @@ -2,6 +2,7 @@ import { CsvSerializer } from "../../common/csv/csv-serializer"; import { DashboardExportRowDto, DashboardSlug, + MemberPaymentByCustomerDashboardDto, NewSignupsDashboardDto, } from "./dashboard-reports.dto"; import { DashboardReportsController } from "./dashboard-reports.controller"; @@ -26,6 +27,32 @@ const newSignupsDashboard: NewSignupsDashboardDto = { }, }; +const memberPaymentByCustomerDashboard: MemberPaymentByCustomerDashboardDto = { + dashboard: DashboardSlug.MemberPaymentByCustomer, + ...query, + series: [ + { + key: "customer-client-a", + label: "Customer A", + customerId: "client-a", + }, + { + key: "other-customers", + label: "Other Customers", + customerId: null, + }, + ], + months: [ + { + month: "2026-02-01", + values: { + "customer-client-a": 5000, + "other-customers": 1000, + }, + }, + ], +}; + describe("DashboardReportsController", () => { let controller: DashboardReportsController; let service: { @@ -53,6 +80,8 @@ describe("DashboardReportsController", () => { newSignups: newSignupsDashboard, membersPaid: {}, challengeParticipation: {}, + memberPaymentByMonth: {}, + memberPaymentByCustomer: memberPaymentByCustomerDashboard, }; service.getAllDashboards.mockResolvedValue(response); @@ -61,13 +90,13 @@ describe("DashboardReportsController", () => { }); it("delegates a selected detail dashboard", async () => { - service.getDashboard.mockResolvedValue(newSignupsDashboard); + service.getDashboard.mockResolvedValue(memberPaymentByCustomerDashboard); await expect( - controller.getDashboard(DashboardSlug.NewSignups, query), - ).resolves.toBe(newSignupsDashboard); + controller.getDashboard(DashboardSlug.MemberPaymentByCustomer, query), + ).resolves.toBe(memberPaymentByCustomerDashboard); expect(service.getDashboard).toHaveBeenCalledWith( - DashboardSlug.NewSignups, + DashboardSlug.MemberPaymentByCustomer, query, ); }); @@ -88,14 +117,30 @@ describe("DashboardReportsController", () => { challenge: 5, engagement: 2, }, + { + dashboard: DashboardSlug.MemberPaymentByMonth, + month: "2026-02-01", + taas: 1000, + task: 2000, + challenge: 3000, + engagement: 4000, + }, + { + dashboard: DashboardSlug.MemberPaymentByCustomer, + month: "2026-02-01", + customer: "Customer A", + amount: 5000, + }, ]; service.exportAllDashboards.mockResolvedValue(rows); await expect(controller.exportAllDashboards(query)).resolves.toBe( [ - "dashboard,month,activated,notActivated,taas,task,challenge,engagement", - "new-signups,2026-02-01,10,2,,,,", - "members-paid,2026-02-01,,,3,4,5,2", + "dashboard,month,activated,notActivated,taas,task,challenge,engagement,customer,amount", + "new-signups,2026-02-01,10,2,,,,,,", + "members-paid,2026-02-01,,,3,4,5,2,,", + "member-payment-by-month,2026-02-01,,,1000,2000,3000,4000,,", + "member-payment-by-customer,2026-02-01,,,,,,,Customer A,5000", ].join("\n"), ); expect(service.exportAllDashboards).toHaveBeenCalledWith(query); @@ -104,21 +149,28 @@ describe("DashboardReportsController", () => { it("serializes one selected dashboard as CSV", async () => { service.exportDashboard.mockResolvedValue([ { - dashboard: DashboardSlug.ChallengeParticipation, + dashboard: DashboardSlug.MemberPaymentByCustomer, + month: "2026-02-01", + customer: "Customer A", + amount: 5000, + }, + { + dashboard: DashboardSlug.MemberPaymentByCustomer, month: "2026-02-01", - registrants: 12, - submitters: 9, + customer: "Other Customers", + amount: 1000, }, ]); await expect( - controller.exportDashboard(DashboardSlug.ChallengeParticipation, query), + controller.exportDashboard(DashboardSlug.MemberPaymentByCustomer, query), ).resolves.toBe( - "dashboard,month,registrants,submitters\n" + - "challenge-participation,2026-02-01,12,9", + "dashboard,month,customer,amount\n" + + "member-payment-by-customer,2026-02-01,Customer A,5000\n" + + "member-payment-by-customer,2026-02-01,Other Customers,1000", ); expect(service.exportDashboard).toHaveBeenCalledWith( - DashboardSlug.ChallengeParticipation, + DashboardSlug.MemberPaymentByCustomer, query, ); }); diff --git a/src/reports/dashboard/dashboard-reports.controller.ts b/src/reports/dashboard/dashboard-reports.controller.ts index db042a2..f5223d3 100644 --- a/src/reports/dashboard/dashboard-reports.controller.ts +++ b/src/reports/dashboard/dashboard-reports.controller.ts @@ -27,6 +27,8 @@ import { DashboardQueryDto, DashboardResponse, DashboardSlug, + MemberPaymentByCustomerDashboardDto, + MemberPaymentByMonthDashboardDto, MembersPaidDashboardDto, NewSignupsDashboardDto, } from "./dashboard-reports.dto"; @@ -42,6 +44,8 @@ import { DashboardReportsGuard } from "./guards/dashboard-reports.guard"; NewSignupsDashboardDto, MembersPaidDashboardDto, ChallengeParticipationDashboardDto, + MemberPaymentByMonthDashboardDto, + MemberPaymentByCustomerDashboardDto, ) @ApiUnauthorizedResponse({ description: "Unauthenticated." }) @ApiForbiddenResponse({ @@ -66,14 +70,14 @@ export class DashboardReportsController { * Retrieves all dashboards for the landing page. * * @param query Optional half-open reporting range. - * @returns All three complete dashboard responses. + * @returns All five complete dashboard responses. * @throws BadRequestException when the range is invalid. */ @Get() @ApiOperation({ summary: "Get all Reports Portal dashboards", description: - "Returns monthly data for new signups, unique paid members, and challenge participation. The default range is the latest six UTC calendar months; summary metrics are all-time.", + "Returns monthly data for new signups, unique paid members, challenge participation, member-payment values by type, and member-payment values by customer. The default range is the latest six UTC calendar months; summary metrics on the original dashboards are all-time.", }) @ApiOkResponse({ type: AllDashboardsDto }) @ApiBadRequestResponse({ description: "Invalid reporting date range." }) @@ -94,7 +98,7 @@ export class DashboardReportsController { @ApiOperation({ summary: "Export all dashboard month data as CSV" }) @ApiProduces("text/csv") @ApiOkResponse({ - description: "Flat CSV rows for all three dashboards.", + description: "Flat CSV rows for all five dashboards.", type: String, }) @ApiBadRequestResponse({ description: "Invalid reporting date range." }) @@ -154,6 +158,8 @@ export class DashboardReportsController { { $ref: getSchemaPath(NewSignupsDashboardDto) }, { $ref: getSchemaPath(MembersPaidDashboardDto) }, { $ref: getSchemaPath(ChallengeParticipationDashboardDto) }, + { $ref: getSchemaPath(MemberPaymentByMonthDashboardDto) }, + { $ref: getSchemaPath(MemberPaymentByCustomerDashboardDto) }, ], }, }) diff --git a/src/reports/dashboard/dashboard-reports.dto.ts b/src/reports/dashboard/dashboard-reports.dto.ts index 4870101..c8b1969 100644 --- a/src/reports/dashboard/dashboard-reports.dto.ts +++ b/src/reports/dashboard/dashboard-reports.dto.ts @@ -8,6 +8,8 @@ export enum DashboardSlug { NewSignups = "new-signups", MembersPaid = "members-paid", ChallengeParticipation = "challenge-participation", + MemberPaymentByMonth = "member-payment-by-month", + MemberPaymentByCustomer = "member-payment-by-customer", } /** @@ -242,7 +244,116 @@ export class ChallengeParticipationDashboardDto { } /** - * Aggregate landing-page response containing all three full dashboards. + * One month of member-payment values split by canonical payment type. + */ +export class MemberPaymentMonthDto { + @ApiProperty({ example: "2026-02-01" }) + month: string; + + @ApiProperty({ example: 185250.5 }) + taas: number; + + @ApiProperty({ example: 92340 }) + task: number; + + @ApiProperty({ example: 246100.75 }) + challenge: number; + + @ApiProperty({ example: 63400 }) + engagement: number; +} + +/** + * Monthly member-payment values split by canonical payment type. + */ +export class MemberPaymentByMonthDashboardDto { + @ApiProperty({ + enum: [DashboardSlug.MemberPaymentByMonth], + example: DashboardSlug.MemberPaymentByMonth, + }) + dashboard: DashboardSlug.MemberPaymentByMonth; + + @ApiProperty({ example: "2026-02-01T00:00:00.000Z" }) + startDate: string; + + @ApiProperty({ + description: "Exclusive reporting range end.", + example: "2026-08-01T00:00:00.000Z", + }) + endDate: string; + + @ApiProperty({ type: [MemberPaymentMonthDto] }) + months: MemberPaymentMonthDto[]; +} + +/** + * One customer series used by the monthly member-payment chart. + */ +export class MemberPaymentCustomerSeriesDto { + @ApiProperty({ + description: "Stable API-generated key used in each month's values map.", + example: "customer-client-123", + }) + key: string; + + @ApiProperty({ example: "Example Customer" }) + label: string; + + @ApiProperty({ + nullable: true, + description: + "Billing-account client identifier, or null for Other Customers.", + example: "client-123", + }) + customerId: string | null; +} + +/** + * One month of member-payment values keyed by customer-series key. + */ +export class MemberPaymentByCustomerMonthDto { + @ApiProperty({ example: "2026-02-01" }) + month: string; + + @ApiProperty({ + type: "object", + additionalProperties: { type: "number" }, + example: { + "customer-client-123": 185250.5, + "other-customers": 92340, + }, + }) + values: Record; +} + +/** + * Monthly member-payment values split by the range's top customers. + */ +export class MemberPaymentByCustomerDashboardDto { + @ApiProperty({ + enum: [DashboardSlug.MemberPaymentByCustomer], + example: DashboardSlug.MemberPaymentByCustomer, + }) + dashboard: DashboardSlug.MemberPaymentByCustomer; + + @ApiProperty({ example: "2026-02-01T00:00:00.000Z" }) + startDate: string; + + @ApiProperty({ + description: "Exclusive reporting range end.", + example: "2026-08-01T00:00:00.000Z", + }) + endDate: string; + + @ApiProperty({ type: [MemberPaymentCustomerSeriesDto] }) + series: MemberPaymentCustomerSeriesDto[]; + + @ApiProperty({ type: [MemberPaymentByCustomerMonthDto] }) + months: MemberPaymentByCustomerMonthDto[]; +} + +/** + * Aggregate landing-page response containing all five full dashboards. */ export class AllDashboardsDto { @ApiProperty({ type: NewSignupsDashboardDto }) @@ -253,6 +364,12 @@ export class AllDashboardsDto { @ApiProperty({ type: ChallengeParticipationDashboardDto }) challengeParticipation: ChallengeParticipationDashboardDto; + + @ApiProperty({ type: MemberPaymentByMonthDashboardDto }) + memberPaymentByMonth: MemberPaymentByMonthDashboardDto; + + @ApiProperty({ type: MemberPaymentByCustomerDashboardDto }) + memberPaymentByCustomer: MemberPaymentByCustomerDashboardDto; } /** @@ -291,6 +408,12 @@ export class DashboardExportRowDto { @ApiPropertyOptional() submitters?: number; + + @ApiPropertyOptional() + customer?: string; + + @ApiPropertyOptional() + amount?: number; } /** @@ -299,4 +422,6 @@ export class DashboardExportRowDto { export type DashboardResponse = | NewSignupsDashboardDto | MembersPaidDashboardDto - | ChallengeParticipationDashboardDto; + | ChallengeParticipationDashboardDto + | MemberPaymentByMonthDashboardDto + | MemberPaymentByCustomerDashboardDto; diff --git a/src/reports/dashboard/dashboard-reports.service.spec.ts b/src/reports/dashboard/dashboard-reports.service.spec.ts index 1a93220..9ce747b 100644 --- a/src/reports/dashboard/dashboard-reports.service.spec.ts +++ b/src/reports/dashboard/dashboard-reports.service.spec.ts @@ -4,6 +4,8 @@ import { DbService } from "../../db/db.service"; import { ChallengeParticipationDashboardDto, DashboardSlug, + MemberPaymentByCustomerDashboardDto, + MemberPaymentByMonthDashboardDto, MembersPaidDashboardDto, NewSignupsDashboardDto, } from "./dashboard-reports.dto"; @@ -72,6 +74,47 @@ const challengeParticipationRows = [ }, ]; +const memberPaymentByMonthRows = [ + { + month: "2026-02-01", + taas: "1250.50", + task: "2400", + challenge: "3750.25", + engagement: "900", + }, + { + month: "2026-03-01", + taas: "1500", + task: "2100.75", + challenge: "4200", + engagement: "1100", + }, +]; + +const memberPaymentByCustomerRows = [ + { + month: "2026-02-01", + series_key: "customer-client-a", + customer_id: "client-a", + customer_label: "Customer A", + amount: "5000.25", + }, + { + month: "2026-02-01", + series_key: "other-customers", + customer_id: null, + customer_label: "Other Customers", + amount: "1200", + }, + { + month: "2026-03-01", + series_key: "customer-client-a", + customer_id: "client-a", + customer_label: "Customer A", + amount: "6200", + }, +]; + describe("resolveDashboardDateRange", () => { it("defaults to the latest six UTC calendar months", () => { expect( @@ -145,6 +188,10 @@ describe("DashboardReportsService", () => { return Promise.resolve(membersPaidRows); case "reports/dashboard/challenge-participation.sql": return Promise.resolve(challengeParticipationRows); + case "reports/dashboard/member-payment-by-month.sql": + return Promise.resolve(memberPaymentByMonthRows); + case "reports/dashboard/member-payment-by-customer.sql": + return Promise.resolve(memberPaymentByCustomerRows); default: return Promise.resolve([]); } @@ -222,14 +269,68 @@ describe("DashboardReportsService", () => { peakMonthRegistrants: 18, }, }, + memberPaymentByMonth: { + dashboard: DashboardSlug.MemberPaymentByMonth, + ...rangeQuery, + months: [ + { + month: "2026-02-01", + taas: 1250.5, + task: 2400, + challenge: 3750.25, + engagement: 900, + }, + { + month: "2026-03-01", + taas: 1500, + task: 2100.75, + challenge: 4200, + engagement: 1100, + }, + ], + }, + memberPaymentByCustomer: { + dashboard: DashboardSlug.MemberPaymentByCustomer, + ...rangeQuery, + series: [ + { + key: "customer-client-a", + label: "Customer A", + customerId: "client-a", + }, + { + key: "other-customers", + label: "Other Customers", + customerId: null, + }, + ], + months: [ + { + month: "2026-02-01", + values: { + "customer-client-a": 5000.25, + "other-customers": 1200, + }, + }, + { + month: "2026-03-01", + values: { + "customer-client-a": 6200, + "other-customers": 0, + }, + }, + ], + }, }); expect(sql.load.mock.calls).toEqual([ ["reports/dashboard/new-signups.sql"], ["reports/dashboard/members-paid.sql"], ["reports/dashboard/challenge-participation.sql"], + ["reports/dashboard/member-payment-by-month.sql"], + ["reports/dashboard/member-payment-by-customer.sql"], ]); - expect(db.query).toHaveBeenCalledTimes(3); + expect(db.query).toHaveBeenCalledTimes(5); expect(db.query).toHaveBeenCalledWith("reports/dashboard/new-signups.sql", [ rangeQuery.startDate, rangeQuery.endDate, @@ -238,13 +339,15 @@ describe("DashboardReportsService", () => { it("loads only the requested detail dashboard", async () => { const result = await service.getDashboard( - DashboardSlug.MembersPaid, + DashboardSlug.MemberPaymentByCustomer, rangeQuery, ); - expect(result.dashboard).toBe(DashboardSlug.MembersPaid); + expect(result.dashboard).toBe(DashboardSlug.MemberPaymentByCustomer); expect(sql.load).toHaveBeenCalledTimes(1); - expect(sql.load).toHaveBeenCalledWith("reports/dashboard/members-paid.sql"); + expect(sql.load).toHaveBeenCalledWith( + "reports/dashboard/member-payment-by-customer.sql", + ); }); it("rejects an unsupported dashboard discriminator", async () => { @@ -322,6 +425,44 @@ describe("DashboardReportsService", () => { peakMonthRegistrants: 12, }, } satisfies ChallengeParticipationDashboardDto, + memberPaymentByMonth: { + dashboard: DashboardSlug.MemberPaymentByMonth, + ...rangeQuery, + months: [ + { + month: "2026-02-01", + taas: 1000, + task: 2000, + challenge: 3000, + engagement: 4000, + }, + ], + } satisfies MemberPaymentByMonthDashboardDto, + memberPaymentByCustomer: { + dashboard: DashboardSlug.MemberPaymentByCustomer, + ...rangeQuery, + series: [ + { + key: "customer-client-a", + label: "Customer A", + customerId: "client-a", + }, + { + key: "other-customers", + label: "Other Customers", + customerId: null, + }, + ], + months: [ + { + month: "2026-02-01", + values: { + "customer-client-a": 6500, + "other-customers": 3500, + }, + }, + ], + } satisfies MemberPaymentByCustomerDashboardDto, }; jest.spyOn(service, "getAllDashboards").mockResolvedValue(dashboards); @@ -346,6 +487,26 @@ describe("DashboardReportsService", () => { registrants: 12, submitters: 9, }, + { + dashboard: DashboardSlug.MemberPaymentByMonth, + month: "2026-02-01", + taas: 1000, + task: 2000, + challenge: 3000, + engagement: 4000, + }, + { + dashboard: DashboardSlug.MemberPaymentByCustomer, + month: "2026-02-01", + customer: "Customer A", + amount: 6500, + }, + { + dashboard: DashboardSlug.MemberPaymentByCustomer, + month: "2026-02-01", + customer: "Other Customers", + amount: 3500, + }, ]); }); }); diff --git a/src/reports/dashboard/dashboard-reports.service.ts b/src/reports/dashboard/dashboard-reports.service.ts index 3df4b40..8932c60 100644 --- a/src/reports/dashboard/dashboard-reports.service.ts +++ b/src/reports/dashboard/dashboard-reports.service.ts @@ -8,6 +8,8 @@ import { DashboardQueryDto, DashboardResponse, DashboardSlug, + MemberPaymentByCustomerDashboardDto, + MemberPaymentByMonthDashboardDto, MembersPaidDashboardDto, NewSignupsDashboardDto, } from "./dashboard-reports.dto"; @@ -52,6 +54,22 @@ type ChallengeParticipationRow = { peak_month_registrants: QueryValue; }; +type MemberPaymentByMonthRow = { + month: string; + taas: QueryValue; + task: QueryValue; + challenge: QueryValue; + engagement: QueryValue; +}; + +type MemberPaymentByCustomerRow = { + month: string; + series_key: string; + customer_id: string | null; + customer_label: string; + amount: QueryValue; +}; + /** * Resolved half-open date range used by all dashboard SQL queries. */ @@ -185,7 +203,7 @@ export class DashboardReportsService { ) {} /** - * Loads all three dashboards for one shared reporting range. + * Loads all five dashboards for one shared reporting range. * * @param query Optional date range. * @returns Full dashboard objects keyed for the landing page. @@ -193,18 +211,26 @@ export class DashboardReportsService { */ async getAllDashboards(query: DashboardQueryDto): Promise { const range = resolveDashboardDateRange(query); - const [newSignups, membersPaid, challengeParticipation] = await Promise.all( - [ - this.loadNewSignups(range), - this.loadMembersPaid(range), - this.loadChallengeParticipation(range), - ], - ); + const [ + newSignups, + membersPaid, + challengeParticipation, + memberPaymentByMonth, + memberPaymentByCustomer, + ] = await Promise.all([ + this.loadNewSignups(range), + this.loadMembersPaid(range), + this.loadChallengeParticipation(range), + this.loadMemberPaymentByMonth(range), + this.loadMemberPaymentByCustomer(range), + ]); return { newSignups, membersPaid, challengeParticipation, + memberPaymentByMonth, + memberPaymentByCustomer, }; } @@ -229,6 +255,10 @@ export class DashboardReportsService { return this.loadMembersPaid(range); case DashboardSlug.ChallengeParticipation: return this.loadChallengeParticipation(range); + case DashboardSlug.MemberPaymentByMonth: + return this.loadMemberPaymentByMonth(range); + case DashboardSlug.MemberPaymentByCustomer: + return this.loadMemberPaymentByCustomer(range); default: throw new BadRequestException("Unsupported dashboard."); } @@ -249,6 +279,8 @@ export class DashboardReportsService { ...this.toExportRows(dashboards.newSignups), ...this.toExportRows(dashboards.membersPaid), ...this.toExportRows(dashboards.challengeParticipation), + ...this.toExportRows(dashboards.memberPaymentByMonth), + ...this.toExportRows(dashboards.memberPaymentByCustomer), ]; } @@ -377,6 +409,87 @@ export class DashboardReportsService { }; } + /** + * Executes and maps the monthly member-payment value SQL. + * + * @param range Explicit half-open query range. + * @returns Monthly member-payment values split by canonical payment type. + */ + private async loadMemberPaymentByMonth( + range: DashboardDateRange, + ): Promise { + const query = this.sql.load( + "reports/dashboard/member-payment-by-month.sql", + ); + const rows = await this.db.query(query, [ + range.startDate, + range.endDate, + ]); + + return { + dashboard: DashboardSlug.MemberPaymentByMonth, + ...range, + months: rows.map((row) => ({ + month: row.month, + taas: toNumber(row.taas), + task: toNumber(row.task), + challenge: toNumber(row.challenge), + engagement: toNumber(row.engagement), + })), + }; + } + + /** + * Executes and maps the monthly member-payment-by-customer SQL. + * + * @param range Explicit half-open query range. + * @returns Stable customer series and zero-filled monthly value maps. + */ + private async loadMemberPaymentByCustomer( + range: DashboardDateRange, + ): Promise { + const query = this.sql.load( + "reports/dashboard/member-payment-by-customer.sql", + ); + const rows = await this.db.query(query, [ + range.startDate, + range.endDate, + ]); + const seriesByKey = new Map< + string, + MemberPaymentByCustomerDashboardDto["series"][number] + >(); + const monthValues = new Map>(); + + for (const row of rows) { + if (!seriesByKey.has(row.series_key)) { + seriesByKey.set(row.series_key, { + key: row.series_key, + label: row.customer_label, + customerId: row.customer_id, + }); + } + + const values = monthValues.get(row.month) ?? new Map(); + values.set(row.series_key, toNumber(row.amount)); + monthValues.set(row.month, values); + } + + const series = [...seriesByKey.values()]; + + return { + dashboard: DashboardSlug.MemberPaymentByCustomer, + ...range, + series, + months: [...monthValues].map(([month, values]) => ({ + month, + values: Object.fromEntries( + series.map(({ key }) => [key, values.get(key) ?? 0]), + ), + })), + }; + } + /** * Flattens a dashboard response into monthly CSV rows. * @@ -408,6 +521,24 @@ export class DashboardReportsService { registrants: month.registrants, submitters: month.submitters, })); + case DashboardSlug.MemberPaymentByMonth: + return dashboard.months.map((month) => ({ + dashboard: dashboard.dashboard, + month: month.month, + taas: month.taas, + task: month.task, + challenge: month.challenge, + engagement: month.engagement, + })); + case DashboardSlug.MemberPaymentByCustomer: + return dashboard.months.flatMap((month) => + dashboard.series.map((series) => ({ + dashboard: dashboard.dashboard, + month: month.month, + customer: series.label, + amount: month.values[series.key] ?? 0, + })), + ); } } } diff --git a/src/reports/dashboard/dashboard-reports.sql.spec.ts b/src/reports/dashboard/dashboard-reports.sql.spec.ts index d8f31fa..daffcb7 100644 --- a/src/reports/dashboard/dashboard-reports.sql.spec.ts +++ b/src/reports/dashboard/dashboard-reports.sql.spec.ts @@ -7,6 +7,8 @@ describe("Dashboard report SQL", () => { "new-signups.sql", "members-paid.sql", "challenge-participation.sql", + "member-payment-by-month.sql", + "member-payment-by-customer.sql", ])( "uses a half-open range and emits zero-filled calendar months: %s", (file) => { @@ -48,6 +50,60 @@ describe("Dashboard report SQL", () => { expect(sql).toMatch(/COUNT\(DISTINCT pe\.member_id\) FILTER/g); }); + it("sums latest paid-member values by canonical payment bucket", () => { + const sql = sqlLoader.load("reports/dashboard/member-payment-by-month.sql"); + + expect(sql).toContain("MAX(p.version) AS max_version"); + expect(sql).toContain("lpv.max_version = p.version"); + expect(sql).toContain("p.payment_status = 'PAID'"); + expect(sql).toContain("w.type = 'PAYMENT'"); + expect(sql).toContain("COALESCE(p.date_paid, p.created_at)"); + expect(sql).toContain( + "COALESCE(p.gross_amount, p.total_amount, 0) AS amount", + ); + expect(sql).toContain("w.category::text = 'TAAS_PAYMENT'"); + expect(sql).toContain("w.category::text = 'ENGAGEMENT_PAYMENT'"); + expect(sql).toContain("'TASK_REVIEW_PAYMENT'"); + expect(sql).toContain( + "w.category::text IS DISTINCT FROM 'TOPGEAR_PAYMENT'", + ); + expect(sql).toMatch(/SUM\(pe\.amount\) FILTER/g); + }); + + it("ranks five clients once and zero-fills an Other Customers series", () => { + const sql = sqlLoader.load( + "reports/dashboard/member-payment-by-customer.sql", + ); + + expect(sql).toContain("MAX(p.version) AS max_version"); + expect(sql).toContain("p.payment_status = 'PAID'"); + expect(sql).toContain( + "COALESCE(p.gross_amount, p.total_amount, 0) AS amount", + ); + expect(sql).toContain('LEFT JOIN challenges."ChallengeBilling" cb'); + expect(sql).toContain( + 'LEFT JOIN "billing-accounts"."BillingAccount" payment_ba', + ); + expect(sql).toContain( + 'LEFT JOIN "billing-accounts"."BillingAccount" challenge_ba', + ); + expect(sql).toContain('LEFT JOIN "billing-accounts"."Client" cl'); + expect(sql).toContain("payment_ba.id::text"); + expect(sql).toContain("challenge_ba.id::text"); + expect(sql).toContain("TRIM(LEADING '0'"); + expect(sql).toContain( + 'COALESCE(payment_ba."clientId", challenge_ba."clientId")', + ); + expect(sql).not.toContain("::integer"); + expect(sql).toContain("ROW_NUMBER() OVER"); + expect(sql).toContain("ct.total_amount DESC"); + expect(sql).toContain("WHERE rc.series_order <= 5"); + expect(sql).toContain("'other-customers' AS series_key"); + expect(sql).toContain("'Other Customers' AS customer_label"); + expect(sql).toContain("CROSS JOIN series s"); + expect(sql).toContain("COALESCE(ma.amount, 0) AS amount"); + }); + it("counts registration and submission activity independently", () => { const sql = sqlLoader.load("reports/dashboard/challenge-participation.sql"); From c3b3248aa749511fc3008b6b0be23e7b540006bf Mon Sep 17 00:00:00 2001 From: jmgasper Date: Fri, 31 Jul 2026 16:48:41 +1000 Subject: [PATCH 2/9] PM-5703: correct dashboard payment and participation data What was broken Unique Members Paid omitted projected TaaS, task, challenge, and engagement payments until they reached PAID status, making monthly and all-time metrics disagree with Wallet Admin. Challenge participation counted legacy resource imports in the import month, producing an April 2026 registrant spike. Root cause The payment query filtered to PAID and grouped by payout date. The participation query grouped resource and submission creation timestamps independently, although imported resources do not preserve their historical registration dates. What was changed Count the latest non-cancelled payment record in its creation month while retaining the existing payment buckets and Topgear exclusion. Cohort supported challenges by their latest actual phase completion date, then count unique submitter resources and same-challenge/member non-deleted submissions in that cohort. Any added/updated tests Updated dashboard SQL regression coverage for projected payment status/version/date semantics, supported challenge completion cohorts, linked submission membership, and removal of import/submission timestamp grouping. All 35 dashboard tests, lint, and build pass; the repository-wide suite still has 21 pre-existing failures in unchanged SFDC and report-directory specs. --- README.md | 16 ++++--- .../dashboard/challenge-participation.sql | 47 +++++++++++++++---- sql/reports/dashboard/members-paid.sql | 45 +++++++++++------- .../dashboard/dashboard-reports.sql.spec.ts | 27 ++++++++--- 4 files changed, 94 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index 3b8dc07..7608a85 100644 --- a/README.md +++ b/README.md @@ -34,17 +34,19 @@ Dashboard figures use these shared definitions: - Signups come from `identity.user.create_date`. `status = 'A'` is activated; every other current status is not activated. -- Paid-member activity requires a `PAID` finance payment for a `PAYMENT` - winning. Its event timestamp is `date_paid`, falling back to `created_at`. - Members are deduplicated within each payment bucket and month. +- Paid-member activity uses the latest non-cancelled finance payment for a + `PAYMENT` winning. It is grouped by the payment creation month so projected + payments that are owed or on hold remain visible. Members are deduplicated + within each payment bucket and month. - Member-payment values use the latest payment version and sum `gross_amount`, falling back to `total_amount`. The payment-by-customer dashboard ranks the top five billing-account clients across the selected range and groups all unnamed or remaining clients under `Other Customers`. -- Registrations are Submitter resource creation events. Submissions are - non-deleted review submission events, using `submittedDate` and falling - back to `createdAt`. Each category is deduplicated independently by member - and month. +- Challenge participation uses the latest actual phase completion month for + Challenge, Marathon Match, and First2Finish cohorts. Registrants are + Submitter resources, and submitters have a non-deleted submission for the + same challenge and member. Each category is deduplicated by member and + cohort month. - Rates are percentages from 0 through 100. Human access is limited to Administrator and Talent Manager roles. Machine diff --git a/sql/reports/dashboard/challenge-participation.sql b/sql/reports/dashboard/challenge-participation.sql index b48a863..df26712 100644 --- a/sql/reports/dashboard/challenge-participation.sql +++ b/sql/reports/dashboard/challenge-participation.sql @@ -4,8 +4,10 @@ -- $1 timestamptz - inclusive reporting range start -- $2 timestamptz - exclusive reporting range end -- --- Registration and submission are independent activity events. A member is --- counted once in each category per month, regardless of challenge count. +-- The month is the challenge cohort's latest actual phase completion month, +-- matching the Challenge Registrants report. This avoids treating legacy +-- resource import timestamps as registration dates. A member is counted once +-- in each category per month, regardless of challenge count. WITH bounds AS ( SELECT $1::timestamptz AT TIME ZONE 'UTC' AS start_at, @@ -19,11 +21,31 @@ months AS ( ) AS month_start FROM bounds b ), -registration_events AS MATERIALIZED ( +eligible_challenges AS MATERIALIZED ( SELECT + c.id AS challenge_id, + lp."actualEndDate" AS activity_at + FROM challenges."Challenge" c + JOIN challenges."ChallengeType" ct + ON ct.id = c."typeId" + JOIN LATERAL ( + SELECT cp."actualEndDate" + FROM challenges."ChallengePhase" cp + WHERE cp."challengeId" = c.id + ORDER BY cp."scheduledEndDate" DESC + LIMIT 1 + ) lp + ON lp."actualEndDate" IS NOT NULL + WHERE ct.name IN ('Challenge', 'Marathon Match', 'First2Finish') +), +registration_events AS MATERIALIZED ( + SELECT DISTINCT + ec.challenge_id, NULLIF(TRIM(r."memberId"), '') AS member_id, - r."createdAt" AS activity_at - FROM resources."Resource" r + ec.activity_at + FROM eligible_challenges ec + JOIN resources."Resource" r + ON r."challengeId" = ec.challenge_id JOIN resources."ResourceRole" rr ON rr.id = r."roleId" WHERE COALESCE(NULLIF(TRIM(rr."nameLower"), ''), LOWER(rr.name)) = 'submitter' @@ -31,11 +53,16 @@ registration_events AS MATERIALIZED ( ), submission_events AS MATERIALIZED ( SELECT - NULLIF(TRIM(s."memberId"), '') AS member_id, - COALESCE(s."submittedDate", s."createdAt") AS activity_at - FROM reviews.submission s - WHERE s.status <> 'DELETED' - AND NULLIF(TRIM(s."memberId"), '') IS NOT NULL + re.member_id, + re.activity_at + FROM registration_events re + WHERE EXISTS ( + SELECT 1 + FROM reviews.submission s + WHERE s."challengeId" = re.challenge_id + AND s."memberId" = re.member_id + AND s.status <> 'DELETED' + ) ), selected_registrations AS ( SELECT diff --git a/sql/reports/dashboard/members-paid.sql b/sql/reports/dashboard/members-paid.sql index a37aef9..9ddcd20 100644 --- a/sql/reports/dashboard/members-paid.sql +++ b/sql/reports/dashboard/members-paid.sql @@ -4,11 +4,10 @@ -- $1 timestamptz - inclusive reporting range start -- $2 timestamptz - exclusive reporting range end -- --- A paid event is a PAYMENT winning whose current payment status is PAID. --- date_paid is authoritative when present; created_at supports migrated paid rows --- that do not have a paid timestamp. TOPGEAR_PAYMENT is intentionally excluded --- because it is a separate canonical accrual bucket and is not one of the four --- dashboard categories. +-- The report is a financial projection, so the latest non-cancelled payment +-- record is counted in its creation month even when payout is still on hold or +-- owed. TOPGEAR_PAYMENT is intentionally excluded because it is a separate +-- canonical accrual bucket and is not one of the four dashboard categories. WITH bounds AS ( SELECT $1::timestamptz AT TIME ZONE 'UTC' AS start_at, @@ -22,10 +21,17 @@ months AS ( ) AS month_start FROM bounds b ), -paid_events AS MATERIALIZED ( +latest_payment_versions AS MATERIALIZED ( + SELECT + p.winnings_id, + MAX(p.version) AS max_version + FROM finance.payment p + GROUP BY p.winnings_id +), +payment_events AS MATERIALIZED ( SELECT NULLIF(TRIM(w.winner_id), '') AS member_id, - COALESCE(p.date_paid, p.created_at) AS paid_at, + p.created_at AS activity_at, CASE WHEN w.category::text = 'TAAS_PAYMENT' THEN 'taas' WHEN w.category::text = 'ENGAGEMENT_PAYMENT' THEN 'engagement' @@ -39,17 +45,20 @@ paid_events AS MATERIALIZED ( ELSE 'challenge' END AS payment_type FROM finance.payment p + JOIN latest_payment_versions lpv + ON lpv.winnings_id = p.winnings_id + AND lpv.max_version = p.version JOIN finance.winnings w ON w.winning_id = p.winnings_id - WHERE p.payment_status = 'PAID' + WHERE p.payment_status IS DISTINCT FROM 'CANCELLED' AND w.type = 'PAYMENT' - AND COALESCE(p.date_paid, p.created_at) IS NOT NULL + AND p.created_at IS NOT NULL AND NULLIF(TRIM(w.winner_id), '') IS NOT NULL AND w.category::text IS DISTINCT FROM 'TOPGEAR_PAYMENT' ), selected_months AS ( SELECT - DATE_TRUNC('month', pe.paid_at) AS month_start, + DATE_TRUNC('month', pe.activity_at) AS month_start, COUNT(DISTINCT pe.member_id) FILTER ( WHERE pe.payment_type = 'taas' ) AS taas, @@ -62,18 +71,18 @@ selected_months AS ( COUNT(DISTINCT pe.member_id) FILTER ( WHERE pe.payment_type = 'engagement' ) AS engagement - FROM paid_events pe + FROM payment_events pe CROSS JOIN bounds b - WHERE pe.paid_at >= b.start_at - AND pe.paid_at < b.end_at - GROUP BY DATE_TRUNC('month', pe.paid_at) + WHERE pe.activity_at >= b.start_at + AND pe.activity_at < b.end_at + GROUP BY DATE_TRUNC('month', pe.activity_at) ), all_time_months AS ( SELECT - DATE_TRUNC('month', pe.paid_at) AS month_start, + DATE_TRUNC('month', pe.activity_at) AS month_start, COUNT(DISTINCT pe.member_id) AS unique_members - FROM paid_events pe - GROUP BY DATE_TRUNC('month', pe.paid_at) + FROM payment_events pe + GROUP BY DATE_TRUNC('month', pe.activity_at) ), peak_month AS ( SELECT @@ -98,7 +107,7 @@ all_time_summary AS ( COUNT(DISTINCT pe.member_id) FILTER ( WHERE pe.payment_type = 'engagement' ) AS engagement_unique_members - FROM paid_events pe + FROM payment_events pe ) SELECT TO_CHAR(m.month_start, 'YYYY-MM-01') AS month, diff --git a/src/reports/dashboard/dashboard-reports.sql.spec.ts b/src/reports/dashboard/dashboard-reports.sql.spec.ts index daffcb7..ec5891b 100644 --- a/src/reports/dashboard/dashboard-reports.sql.spec.ts +++ b/src/reports/dashboard/dashboard-reports.sql.spec.ts @@ -34,12 +34,16 @@ describe("Dashboard report SQL", () => { expect(sql).toContain("AS peak_month_signups"); }); - it("counts paid members once per canonical payment bucket and month", () => { + it("counts projected members once per canonical payment bucket and month", () => { const sql = sqlLoader.load("reports/dashboard/members-paid.sql"); - expect(sql).toContain("p.payment_status = 'PAID'"); + expect(sql).toContain("MAX(p.version) AS max_version"); + expect(sql).toContain("lpv.max_version = p.version"); + expect(sql).toContain("p.payment_status IS DISTINCT FROM 'CANCELLED'"); expect(sql).toContain("w.type = 'PAYMENT'"); - expect(sql).toContain("COALESCE(p.date_paid, p.created_at)"); + expect(sql).toContain("p.created_at AS activity_at"); + expect(sql).not.toContain("p.payment_status = 'PAID'"); + expect(sql).not.toContain("p.date_paid"); expect(sql).toContain("w.category::text = 'TAAS_PAYMENT'"); expect(sql).toContain("w.category::text = 'ENGAGEMENT_PAYMENT'"); expect(sql).toContain("'TASK_REVIEW_PAYMENT'"); @@ -104,15 +108,26 @@ describe("Dashboard report SQL", () => { expect(sql).toContain("COALESCE(ma.amount, 0) AS amount"); }); - it("counts registration and submission activity independently", () => { + it("counts registrants and linked submitters by challenge completion cohort", () => { const sql = sqlLoader.load("reports/dashboard/challenge-participation.sql"); - expect(sql).toContain('FROM resources."Resource" r'); + expect(sql).toContain('FROM challenges."Challenge" c'); + expect(sql).toContain('JOIN challenges."ChallengeType" ct'); + expect(sql).toContain( + "ct.name IN ('Challenge', 'Marathon Match', 'First2Finish')", + ); + expect(sql).toContain('FROM challenges."ChallengePhase" cp'); + expect(sql).toContain('lp."actualEndDate" AS activity_at'); + expect(sql).toContain('ORDER BY cp."scheduledEndDate" DESC'); + expect(sql).toContain('JOIN resources."Resource" r'); expect(sql).toContain('JOIN resources."ResourceRole" rr'); expect(sql).toContain("= 'submitter'"); expect(sql).toContain("FROM reviews.submission s"); + expect(sql).toContain('s."challengeId" = re.challenge_id'); + expect(sql).toContain('s."memberId" = re.member_id'); expect(sql).toContain("s.status <> 'DELETED'"); - expect(sql).toContain('COALESCE(s."submittedDate", s."createdAt")'); + expect(sql).not.toContain('r."createdAt" AS activity_at'); + expect(sql).not.toContain('COALESCE(s."submittedDate", s."createdAt")'); expect(sql).toContain("COUNT(DISTINCT re.member_id)"); expect(sql).toContain("COUNT(DISTINCT se.member_id)"); expect(sql).toContain("LEAST("); From d54a1185405600dcdbebb8c2c610f08355c21484 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Mon, 3 Aug 2026 09:36:16 +0300 Subject: [PATCH 3/9] PM-5653 - support leaderboard calculations --- sql/reports/topcoder/leaderboard-generic.sql | 218 +++++++++++++ sql/reports/topcoder/leaderboard-mm.sql | 97 ++++++ src/app-constants.ts | 1 + .../topcoder/dto/leaderboard-generic.dto.ts | 42 +++ .../topcoder/dto/leaderboard-mm.dto.ts | 51 +++ .../topcoder/topcoder-reports.controller.ts | 24 ++ .../topcoder/topcoder-reports.service.ts | 307 ++++++++++++++++++ 7 files changed, 740 insertions(+) create mode 100644 sql/reports/topcoder/leaderboard-generic.sql create mode 100644 sql/reports/topcoder/leaderboard-mm.sql create mode 100644 src/reports/topcoder/dto/leaderboard-generic.dto.ts create mode 100644 src/reports/topcoder/dto/leaderboard-mm.dto.ts diff --git a/sql/reports/topcoder/leaderboard-generic.sql b/sql/reports/topcoder/leaderboard-generic.sql new file mode 100644 index 0000000..b56a626 --- /dev/null +++ b/sql/reports/topcoder/leaderboard-generic.sql @@ -0,0 +1,218 @@ +WITH challenge_context AS ( + SELECT + c.id AS challenge_id, + c.name AS challenge_name, + COALESCE( + cp."actualStartDate", + cp."scheduledStartDate" + ) AS start_date, + COALESCE( + cp."actualEndDate", + cp."scheduledEndDate" + ) AS end_date, + GREATEST( + 1, + CEIL( + EXTRACT(EPOCH FROM COALESCE(cp."actualEndDate", cp."scheduledEndDate") - COALESCE(cp."actualStartDate", cp."scheduledStartDate")) + / 86400.0 + ) + ) AS duration_days + FROM challenges."Challenge" AS c + JOIN challenges."ChallengePhase" AS cp + ON cp."challengeId" = c.id + AND cp.name = 'Submission' + WHERE c.id = ANY($1::text[]) +), +member_submissions AS ( + SELECT + cc.challenge_id, + s.id AS submission_id, + s."memberId" AS user_id, + COALESCE( + NULLIF(TRIM(u.handle), ''), + NULLIF(TRIM(mem.handle), ''), + fallback.member_handle + ) AS handle, + COALESCE(NULLIF(TRIM(mem."firstName"), ''), NULLIF(TRIM(u.handle), ''), NULLIF(TRIM(mem.handle), '')) AS name, + COALESCE( + home_code.name, + home_id.name, + comp_code.name, + comp_id.name, + NULLIF(TRIM(mem."competitionCountryCode"), ''), + NULLIF(TRIM(mem."homeCountryCode"), '') + ) AS country, + COALESCE( + NULLIF(TRIM(mem."competitionCountryCode"), ''), + NULLIF(TRIM(mem."homeCountryCode"), '') + ) AS country_code, + mem."photoURL" AS photoURL, + mmr.rating AS rating, + mmr."ratingColor" AS ratingColor, + COALESCE( + CASE + WHEN challenge_reviewers.is_ai_only_challenge + THEN ai_decision."totalScore" + ELSE final_review."aggregateScore" + END, + s."finalScore"::double precision, + s."initialScore"::double precision + ) AS score, + COALESCE(s."submittedDate", s."createdAt") AS submitted_date, + cc.duration_days + FROM challenge_context AS cc + JOIN reviews."submission" AS s + ON s."challengeId" = cc.challenge_id + AND s."memberId" IS NOT NULL + LEFT JOIN LATERAL ( + SELECT rs."aggregateScore", rs."scorecardId" + FROM reviews."reviewSummation" AS rs + WHERE rs."submissionId" = s.id + AND COALESCE(rs."isFinal", TRUE) = TRUE + AND rs."isProvisional" IS DISTINCT FROM TRUE + ORDER BY COALESCE(rs."reviewedDate", rs."createdAt") DESC NULLS LAST, rs.id DESC + LIMIT 1 + ) AS final_review ON TRUE + LEFT JOIN LATERAL ( + SELECT + COUNT(*) > 0 AS has_reviewers, + BOOL_AND(cr."aiWorkflowId" IS NOT NULL AND cr."isMemberReview" = FALSE) AND COUNT(*) > 0 AS is_ai_only_challenge + FROM challenges."ChallengeReviewer" AS cr + WHERE cr."challengeId" = cc.challenge_id + ) AS challenge_reviewers ON TRUE + LEFT JOIN LATERAL ( + SELECT d."totalScore", d.status + FROM reviews."aiReviewDecision" AS d + WHERE d."submissionId" = s.id + AND UPPER(d.status::text) != 'PENDING' + ORDER BY d."updatedAt" DESC NULLS LAST + LIMIT 1 + ) AS ai_decision ON TRUE + LEFT JOIN reviews.scorecard AS sc + ON sc.id = final_review."scorecardId" + LEFT JOIN members."member" AS mem + ON mem."userId" = s."memberId"::bigint + LEFT JOIN LATERAL ( + SELECT DISTINCT ON (mmr."userId") + mmr.rating, + mmr."ratingColor" + FROM members."memberMaxRating" AS mmr + WHERE mmr."userId" = s."memberId"::bigint + ORDER BY mmr."userId", mmr.rating DESC + ) AS mmr ON TRUE + LEFT JOIN identity."user" AS u + ON s."memberId" ~ '^[0-9]+$' + AND u.user_id = s."memberId"::numeric + LEFT JOIN LATERAL ( + SELECT MAX(r."memberHandle") AS member_handle + FROM resources."Resource" AS r + WHERE r."challengeId" = cc.challenge_id + AND r."memberId" = s."memberId" + ) AS fallback ON TRUE + LEFT JOIN lookups."Country" AS home_code + ON UPPER(home_code."countryCode") = UPPER(mem."homeCountryCode") + LEFT JOIN lookups."Country" AS home_id + ON UPPER(home_id.id) = UPPER(mem."homeCountryCode") + LEFT JOIN lookups."Country" AS comp_code + ON UPPER(comp_code."countryCode") = UPPER(mem."competitionCountryCode") + LEFT JOIN lookups."Country" AS comp_id + ON UPPER(comp_id.id) = UPPER(mem."competitionCountryCode") + WHERE COALESCE( + CASE + WHEN challenge_reviewers.is_ai_only_challenge + THEN ai_decision."totalScore" + ELSE final_review."aggregateScore" + END, + s."finalScore"::double precision, + s."initialScore"::double precision + ) IS NOT NULL + AND ( + (challenge_reviewers.is_ai_only_challenge + AND UPPER(ai_decision.status::text) = 'PASSED') + OR ( + NOT challenge_reviewers.is_ai_only_challenge + AND COALESCE(final_review."aggregateScore", s."finalScore"::double precision, s."initialScore"::double precision) + >= COALESCE(sc."minimumPassingScore", sc."minScore", 0) + ) + ) +), +unique_member_submissions AS ( + SELECT DISTINCT ON (challenge_id, user_id) + ms.* + FROM member_submissions AS ms + ORDER BY + ms.challenge_id, + ms.user_id, + ms.score DESC NULLS LAST, + ms.submitted_date DESC NULLS LAST, + ms.submission_id DESC +), +challenge_prizes AS ( + SELECT + cps."challengeId" AS challenge_id, + ROW_NUMBER() OVER (PARTITION BY cps."challengeId" ORDER BY p.id) AS placement, + p.value + FROM challenges."ChallengePrizeSet" AS cps + JOIN challenges."Prize" AS p + ON p."prizeSetId" = cps.id + WHERE cps.type = 'PLACEMENT' +), +challenge_summary AS ( + SELECT + cc.challenge_id, + cc.challenge_name, + cc.duration_days, + COALESCE(SUM(pr.value), 0) AS prize_pool, + COUNT(DISTINCT ums.user_id) AS submissions_count, + JSONB_AGG( + JSONB_BUILD_OBJECT('placement', pr.placement, 'value', pr.value) ORDER BY pr.placement + ) FILTER (WHERE pr.value IS NOT NULL) AS placement_prizes + FROM challenge_context AS cc + LEFT JOIN unique_member_submissions AS ums + ON ums.challenge_id = cc.challenge_id + LEFT JOIN challenge_prizes AS pr + ON pr.challenge_id = cc.challenge_id + GROUP BY cc.challenge_id, cc.challenge_name, cc.duration_days +) +SELECT + ums.challenge_id AS "challengeId", + cs.challenge_name AS "challengeName", + cs.duration_days AS "durationDays", + cs.prize_pool AS "prizePool", + cs.submissions_count AS "submissionsCount", + cs.placement_prizes AS "placementPrizes", + ums.user_id AS "userId", + ums.handle AS handle, + ums.name AS name, + COALESCE( + home_code.name, + home_id.name, + comp_code.name, + comp_id.name, + NULLIF(TRIM(ums.country), '') + ) AS country, + COALESCE( + NULLIF(TRIM(ums.country_code), ''), + NULLIF(TRIM(ums.country_code), '') + ) AS "countryCode", + ums.photoURL AS "photoURL", + ums.rating AS rating, + ums.ratingColor AS "ratingColor", + ums.score AS score, + ums.submitted_date AS submitted_date, + ROW_NUMBER() OVER ( + PARTITION BY ums.challenge_id + ORDER BY ums.score DESC NULLS LAST, ums.submitted_date ASC NULLS LAST, ums.user_id ASC + ) AS placement +FROM unique_member_submissions AS ums +LEFT JOIN lookups."Country" AS home_code + ON UPPER(home_code."countryCode") = UPPER(ums.country_code) +LEFT JOIN lookups."Country" AS home_id + ON UPPER(home_id.id) = UPPER(ums.country_code) +LEFT JOIN lookups."Country" AS comp_code + ON UPPER(comp_code."countryCode") = UPPER(ums.country_code) +LEFT JOIN lookups."Country" AS comp_id + ON UPPER(comp_id.id) = UPPER(ums.country_code) +JOIN challenge_summary AS cs + ON cs.challenge_id = ums.challenge_id +ORDER BY ums.challenge_id, ums.score DESC NULLS LAST, ums.submitted_date ASC NULLS LAST, ums.user_id ASC; diff --git a/sql/reports/topcoder/leaderboard-mm.sql b/sql/reports/topcoder/leaderboard-mm.sql new file mode 100644 index 0000000..6f477af --- /dev/null +++ b/sql/reports/topcoder/leaderboard-mm.sql @@ -0,0 +1,97 @@ +WITH challenge_context AS ( + SELECT + c.id AS challenge_id, + c.name AS challenge_name, + LOWER(ct.name) = 'marathon match' AS is_marathon_match + FROM challenges."Challenge" AS c + JOIN challenges."ChallengeType" AS ct + ON ct.id = c."typeId" + WHERE c.id = ANY($1::text[]) +), +submission_metrics AS ( + SELECT + cc.challenge_id, + s.id AS submission_id, + s."memberId" AS user_id, + COALESCE( + NULLIF(TRIM(u.handle), ''), + NULLIF(TRIM(mem.handle), ''), + fallback.member_handle + ) AS handle, + COALESCE(final_review."aggregateScore", s."finalScore"::double precision) AS standard_score, + provisional_review.provisional_score, + COALESCE(final_review."aggregateScore", s."finalScore"::double precision) AS final_score_raw, + COALESCE(s."submittedDate", s."createdAt") AS submitted_date + FROM challenge_context AS cc + JOIN reviews."submission" AS s + ON s."challengeId" = cc.challenge_id + AND s."memberId" IS NOT NULL + LEFT JOIN LATERAL ( + SELECT rs."aggregateScore" + FROM reviews."reviewSummation" AS rs + WHERE rs."submissionId" = s.id + AND COALESCE(rs."isFinal", TRUE) = TRUE + AND rs."isProvisional" IS DISTINCT FROM TRUE + ORDER BY COALESCE(rs."reviewedDate", rs."createdAt") DESC NULLS LAST, rs.id DESC + LIMIT 1 + ) AS final_review ON TRUE + LEFT JOIN LATERAL ( + SELECT rs."aggregateScore" AS provisional_score + FROM reviews."reviewSummation" AS rs + WHERE rs."submissionId" = s.id + AND rs."isProvisional" IS TRUE + ORDER BY COALESCE(rs."reviewedDate", rs."createdAt") DESC NULLS LAST, rs.id DESC + LIMIT 1 + ) AS provisional_review ON TRUE + LEFT JOIN members."member" AS mem + ON mem."userId" = s."memberId"::bigint + LEFT JOIN identity."user" AS u + ON s."memberId" ~ '^[0-9]+$' + AND u.user_id = s."memberId"::numeric + LEFT JOIN LATERAL ( + SELECT MAX(r."memberHandle") AS member_handle + FROM resources."Resource" AS r + WHERE r."challengeId" = cc.challenge_id + AND r."memberId" = s."memberId" + ) AS fallback ON TRUE + WHERE COALESCE(final_review."aggregateScore", s."finalScore"::double precision, s."initialScore"::double precision) IS NOT NULL +), +unique_member_submissions AS ( + SELECT DISTINCT ON (challenge_id, user_id) + sm.* + FROM submission_metrics AS sm + ORDER BY + sm.challenge_id, + sm.user_id, + sm.submitted_date DESC NULLS LAST, + sm.submission_id DESC +), +ranked_submissions AS ( + SELECT + ums.challenge_id, + ums.user_id AS "userId", + ums.submission_id AS "submissionId", + ums.handle AS handle, + ROW_NUMBER() OVER ( + PARTITION BY ums.challenge_id + ORDER BY ums.final_score_raw DESC NULLS LAST, ums.submitted_date ASC NULLS LAST, ums.user_id ASC + ) AS placement, + ums.provisional_score AS "provisionalScore", + ums.final_score_raw AS "finalScore", + ums.standard_score AS score + FROM unique_member_submissions AS ums +) +SELECT + cc.challenge_id AS "challengeId", + cc.challenge_name AS "challengeName", + rs."userId", + rs."submissionId", + rs.handle, + rs.placement, + rs."provisionalScore", + rs."finalScore", + rs.score +FROM ranked_submissions AS rs +JOIN challenge_context AS cc + ON cc.challenge_id = rs.challenge_id +ORDER BY rs.challenge_id, rs.placement; diff --git a/src/app-constants.ts b/src/app-constants.ts index c1bab63..72ca01d 100644 --- a/src/app-constants.ts +++ b/src/app-constants.ts @@ -6,6 +6,7 @@ export const Scopes = { TopgearCancelledChallenge: "reports:topgear-cancelled-challenge", AllReports: "reports:all", TopcoderReports: "reports:topcoder", + TopcoderLeaderboardReports: "reports:topcoder-leaderboard", Member: { EngagementData: "reports:member-engagement-data", RecentMemberData: "reports:member-recent-member-data", diff --git a/src/reports/topcoder/dto/leaderboard-generic.dto.ts b/src/reports/topcoder/dto/leaderboard-generic.dto.ts new file mode 100644 index 0000000..90f5320 --- /dev/null +++ b/src/reports/topcoder/dto/leaderboard-generic.dto.ts @@ -0,0 +1,42 @@ +import { Transform } from "class-transformer"; +import { IsArray, IsBoolean, IsNotEmpty, IsNumber, IsOptional, IsString } from "class-validator"; + +export class LeaderboardGenericQueryDto { + @Transform(({ value }) => { + if (typeof value === "string") { + return value + .split(",") + .map((item) => item.trim()) + .filter(Boolean); + } + return value; + }) + @IsArray() + @IsString({ each: true }) + @IsNotEmpty({ each: true }) + challengeIds!: string[]; + + @Transform(({ value }) => (typeof value === "string" ? Number(value) : value)) + @IsOptional() + @IsNumber() + pointsPerDay?: number; + + @Transform(({ value }) => { + if (typeof value === "string") { + return value + .split(",") + .map((item) => item.trim()) + .filter(Boolean); + } + return value; + }) + @IsOptional() + @IsArray() + @IsString({ each: true }) + placementPrizeAmounts?: string[]; + + @Transform(({ value }) => value === "true" || value === true) + @IsOptional() + @IsBoolean() + showHeadingAndSubtitle?: boolean; +} diff --git a/src/reports/topcoder/dto/leaderboard-mm.dto.ts b/src/reports/topcoder/dto/leaderboard-mm.dto.ts new file mode 100644 index 0000000..5543bbc --- /dev/null +++ b/src/reports/topcoder/dto/leaderboard-mm.dto.ts @@ -0,0 +1,51 @@ +import { Transform } from "class-transformer"; +import { IsArray, IsNotEmpty, IsNumber, IsOptional, IsString } from "class-validator"; + +export class LeaderboardMmQueryDto { + @Transform(({ value }) => { + if (typeof value === "string") { + return value + .split(",") + .map((item) => item.trim()) + .filter(Boolean); + } + return value; + }) + @IsArray() + @IsString({ each: true }) + @IsNotEmpty({ each: true }) + challengeIds!: string[]; + + @Transform(({ value }) => { + if (typeof value === "string") { + return value + .split(",") + .map((item) => Number(item.trim())) + .filter((item) => Number.isFinite(item)); + } + return value; + }) + @IsOptional() + @IsArray() + @IsNumber({}, { each: true }) + placementPoints?: number[]; + + @Transform(({ value }) => (typeof value === "string" ? Number(value) : value)) + @IsOptional() + @IsNumber() + defaultPlacementPoints?: number; + + @Transform(({ value }) => { + if (typeof value === "string") { + return value + .split(",") + .map((item) => item.trim()) + .filter(Boolean); + } + return value; + }) + @IsOptional() + @IsArray() + @IsString({ each: true }) + writersAndTesters?: string[]; +} diff --git a/src/reports/topcoder/topcoder-reports.controller.ts b/src/reports/topcoder/topcoder-reports.controller.ts index 9c476d4..20ebfad 100644 --- a/src/reports/topcoder/topcoder-reports.controller.ts +++ b/src/reports/topcoder/topcoder-reports.controller.ts @@ -9,6 +9,8 @@ import { import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; import { TopcoderReportsService } from "./topcoder-reports.service"; import { ChallengeSubmitterDataQueryDto } from "./dto/challenge-submitter-data.dto"; +import { LeaderboardGenericQueryDto } from "./dto/leaderboard-generic.dto"; +import { LeaderboardMmQueryDto } from "./dto/leaderboard-mm.dto"; import { RegistrantCountriesQueryDto } from "./dto/registrant-countries.dto"; import { RecentMemberDataQueryDto } from "./dto/recent-member-data.dto"; import { WeeklyMemberParticipationQueryDto } from "./dto/weekly-member-participation.dto"; @@ -52,6 +54,28 @@ export class TopcoderReportsController { return this.reports.getChallengeSubmitterData(challengeId); } + @Get("/topcoder/leaderboard/generic") + @RequiredScopes( + AppScopes.AllReports, + AppScopes.TopcoderReports, + AppScopes.TopcoderLeaderboardReports, + ) + @ApiOperation({ summary: "Generic leaderboard report data" }) + getLeaderboardGeneric(@Query() query: LeaderboardGenericQueryDto) { + return this.reports.getLeaderboardGeneric(query); + } + + @Get("/topcoder/leaderboard/mm") + @RequiredScopes( + AppScopes.AllReports, + AppScopes.TopcoderReports, + AppScopes.TopcoderLeaderboardReports, + ) + @ApiOperation({ summary: "Marathon Match leaderboard report data" }) + getLeaderboardMm(@Query() query: LeaderboardMmQueryDto) { + return this.reports.getLeaderboardMm(query); + } + @Get("/topcoder/mm-stats/:handle") @ApiOperation({ summary: "Marathon match performance snapshot for a specific handle", diff --git a/src/reports/topcoder/topcoder-reports.service.ts b/src/reports/topcoder/topcoder-reports.service.ts index 8fc095a..5e485e7 100644 --- a/src/reports/topcoder/topcoder-reports.service.ts +++ b/src/reports/topcoder/topcoder-reports.service.ts @@ -100,6 +100,37 @@ type ChallengeSubmitterDataRow = { finalScore: string | number | null; }; +type LeaderboardGenericRow = { + challengeId: string; + challengeName: string; + durationDays: number; + prizePool: number; + submissionsCount: number; + placementPrizes: { placement: number; value: number }[] | null; + userId: string; + handle: string; + name: string | null; + country: string | null; + countryCode: string | null; + photoURL: string | null; + rating: string | number | null; + ratingColor: string | null; + score: number; + submittedDate: string; + placement: number; +}; + +type LeaderboardMmRow = { + challengeId: string; + challengeName: string; + userId: string; + handle: string; + placement: number; + provisionalScore: number | null; + finalScore: number | null; + score: number | null; +}; + type EngagementDataBaseRow = { member_id: string | null; fallback_handle: string | null; @@ -971,6 +1002,282 @@ export class TopcoderReportsService implements OnModuleDestroy { return projectNamesById; } + async getLeaderboardGeneric(filters: { + challengeIds: string[]; + pointsPerDay?: number; + placementPrizeAmounts?: string[]; + showHeadingAndSubtitle?: boolean; + }) { + const query = this.sql.load("reports/topcoder/leaderboard-generic.sql"); + const rows = await this.db.query(query, [ + filters.challengeIds, + ]); + + const useCmsPlacementPrizes = + Array.isArray(filters.placementPrizeAmounts) && + filters.placementPrizeAmounts.length > 0; + const cmsPlacementPrizeAmounts = (filters.placementPrizeAmounts ?? []) + .map((amount) => Number(amount)) + .filter((amount) => Number.isFinite(amount)); + + const challenges: Record< + string, + { id: string; name: string; submissions: { [memberId: string]: number } } + > = {}; + const entriesByUser: Record< + string, + { + userId: string; + handle: string; + name: string | null; + country: string | null; + countryCode: string | null; + wins: Record; + points: number; + prizes: number; + photoURL: string | null; + } + > = {}; + + rows.forEach((row) => { + challenges[row.challengeId] = challenges[row.challengeId] ?? { + id: row.challengeId, + name: row.challengeName, + submissions: {}, + }; + challenges[row.challengeId].submissions[row.userId] = row.placement; + + if (!entriesByUser[row.userId]) { + entriesByUser[row.userId] = { + userId: row.userId, + handle: row.handle, + name: row.name, + country: row.country, + countryCode: row.countryCode, + photoURL: row.photoURL, + wins: {}, + points: 0, + prizes: 0, + }; + } + + const log = + Math.log10(Math.max(1, row.prizePool)) + + row.durationDays * (filters.pointsPerDay ?? 0); + const relativeRank = row.placement / Math.max(1, row.submissionsCount); + const sqrt = Math.sqrt(relativeRank); + const pointsByWin = log / sqrt; + + entriesByUser[row.userId].points += pointsByWin; + entriesByUser[row.userId].wins[row.challengeId] = row.placement; + if (!useCmsPlacementPrizes) { + const prizeValue = + (row.placementPrizes ?? []).find((p) => p.placement === row.placement) + ?.value ?? 0; + entriesByUser[row.userId].prizes += prizeValue; + } + }); + + const placementData = Object.values(entriesByUser) + .filter((entry) => entry.points > 0) + .sort((a, b) => b.points - a.points) + .map((entry, index) => ({ + userId: entry.userId, + handle: entry.handle, + name: entry.name ?? "", + country: entry.country ?? "", + countryCode: entry.countryCode ?? "", + photoURL: entry.photoURL, + placement: index, + points: entry.points, + wins: entry.wins, + prizes: useCmsPlacementPrizes + ? (cmsPlacementPrizeAmounts[index] ?? 0) + : entry.prizes, + })); + + return { + placementData: filters.showHeadingAndSubtitle + ? placementData.slice(0, 10) + : placementData, + challenges, + }; + } + + private calculateWriterTesterBonuses( + placementData: Record, + writersAndTesters: string[] | undefined, + pointsMap: number[] = [], + baseScoringPoints = 1, + ) { + const contributions = (writersAndTesters ?? []) + .map((entry) => { + const [handle = "", challengeId = "", role = ""] = entry.split(":"); + if (!handle || !challengeId || !["writer", "tester"].includes(role)) { + return null; + } + return { handle, challengeId, role: role as "writer" | "tester" }; + }) + .filter(Boolean) as { + handle: string; + challengeId: string; + role: "writer" | "tester"; + }[]; + + const bonusPoints: Record = {}; + const eligibility: Record = {}; + const writerTesterRoles: Record< + string, + Record + > = {}; + + if (!contributions.length) { + return { bonusPoints, eligibility, writerTesterRoles }; + } + + const pointsFn = (winner: any) => + winner.score && winner.score <= 0 + ? 0 + : Number(pointsMap[winner.placement - 1] ?? baseScoringPoints); + + const challengeIds = Object.keys(placementData); + const competitionMatchesByHandle: Record> = {}; + const competitionPointsByHandle: Record = {}; + + challengeIds.forEach((challengeId) => { + placementData[challengeId].forEach((entry) => { + competitionMatchesByHandle[entry.handle] = + competitionMatchesByHandle[entry.handle] ?? new Set(); + competitionMatchesByHandle[entry.handle].add(challengeId); + competitionPointsByHandle[entry.handle] = + (competitionPointsByHandle[entry.handle] ?? 0) + pointsFn(entry); + }); + }); + + const matchCount = challengeIds.length; + const minCompetitions = Math.ceil(matchCount / 2); + const contributionByHandle: Record> = {}; + + contributions.forEach(({ handle, challengeId, role }) => { + if (!challengeIds.includes(challengeId)) { + return; + } + if (competitionMatchesByHandle[handle]?.has(challengeId)) { + return; + } + contributionByHandle[handle] = contributionByHandle[handle] ?? new Set(); + contributionByHandle[handle].add(challengeId); + writerTesterRoles[handle] = writerTesterRoles[handle] ?? {}; + writerTesterRoles[handle][challengeId] = role; + }); + + Object.entries(contributionByHandle).forEach( + ([handle, contributionsSet]) => { + const contributionMatches = contributionsSet.size; + const competitionMatches = + competitionMatchesByHandle[handle]?.size ?? 0; + const competitionPoints = competitionPointsByHandle[handle] ?? 0; + const averageCompetitionPoints = competitionMatches + ? competitionPoints / competitionMatches + : 0; + const eligibleForChampionship = + competitionMatches >= minCompetitions && + contributionMatches <= matchCount / 2; + const bonus = eligibleForChampionship + ? averageCompetitionPoints * contributionMatches + : 0; + + bonusPoints[handle] = Number(bonus.toFixed(2)); + eligibility[handle] = { + competitionMatches, + contributionMatches, + averageCompetitionPoints: Number(averageCompetitionPoints.toFixed(2)), + bonusPoints: bonusPoints[handle], + eligibleForChampionship, + }; + }, + ); + + return { bonusPoints, eligibility, writerTesterRoles }; + } + + async getLeaderboardMm(filters: { + challengeIds: string[]; + placementPoints?: number[]; + defaultPlacementPoints?: number; + writersAndTesters?: string[]; + }) { + const query = this.sql.load("reports/topcoder/leaderboard-mm.sql"); + const rows = await this.db.query(query, [ + filters.challengeIds, + ]); +console.log('here', rows); + + const placementData = rows.reduce( + (acc: Record, row) => { + acc[row.challengeId] = acc[row.challengeId] ?? []; + acc[row.challengeId].push({ + challengeId: row.challengeId, + challengeName: row.challengeName, + userId: row.userId, + handle: row.handle, + placement: row.placement, + provisionalScore: row.provisionalScore, + finalScore: row.finalScore, + score: row.score, + }); + return acc; + }, + {} as Record, + ); + + const memberHandles = new Set(); + Object.values(placementData).forEach((entries) => { + entries.forEach((entry) => memberHandles.add(entry.handle)); + }); + + const membersDetails: Record< + string, + { + userId: string; + handle: string; + name: string; + country: string; + countryCode: string; + } + > = {}; + memberHandles.forEach((handle) => { + membersDetails[handle] = { + userId: handle, + handle, + name: handle, + country: "", + countryCode: "", + }; + }); + + const writerTesterData = this.calculateWriterTesterBonuses( + placementData, + filters.writersAndTesters, + filters.placementPoints ?? [], + filters.defaultPlacementPoints ?? 1, + ); + + return { + membersDetails, + challengesDetails: Object.fromEntries( + Object.entries(placementData).map(([challengeId, entries]) => [ + challengeId, + { name: entries[0]?.challengeName ?? "" }, + ]), + ), + placementData, + writerTesterBonusPoints: writerTesterData.bonusPoints, + writerTesterRoles: writerTesterData.writerTesterRoles, + writerTesterEligibility: writerTesterData.eligibility, + }; + } + /** * Formats a preferred address row into the report output string. * From 5cab872a5ac4373b865fe7bf4635a5f1fff2a721 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Mon, 3 Aug 2026 09:37:17 +0300 Subject: [PATCH 4/9] deploy dev --- .circleci/config.yml | 2 ++ package.json | 1 + 2 files changed, 3 insertions(+) diff --git a/.circleci/config.yml b/.circleci/config.yml index c79c575..f909fc6 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -66,6 +66,8 @@ workflows: - develop - PM-4931 - improve-member-search-2 + tags: + only: /^dev-.*/ # Production builds are exectuted only on tagged commits to the # master branch. diff --git a/package.json b/package.json index c27abc0..02ed013 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "packageManager": "pnpm@11.15.0", "scripts": { "build": "nest build", + "deploy:dev": "BRANCH=$(git rev-parse --abbrev-ref HEAD) && TAG=\"dev-${BRANCH}\" && git tag -d \"$TAG\" 2>/dev/null; git push origin \":refs/tags/$TAG\" 2>/dev/null; git tag \"$TAG\" && git push origin \"$TAG\"", "start": "node dist/main.js", "start:dev": "nest start --watch", "start:prod": "node dist/main.js", From 27d344f5b6bc7e8e3d5082c9e6458f351b54a576 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Mon, 3 Aug 2026 10:47:33 +0300 Subject: [PATCH 5/9] PM-5653 - rating fix for leaderboard --- src/reports/topcoder/topcoder-reports.service.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/reports/topcoder/topcoder-reports.service.ts b/src/reports/topcoder/topcoder-reports.service.ts index 5e485e7..3e8dc49 100644 --- a/src/reports/topcoder/topcoder-reports.service.ts +++ b/src/reports/topcoder/topcoder-reports.service.ts @@ -1036,6 +1036,8 @@ export class TopcoderReportsService implements OnModuleDestroy { points: number; prizes: number; photoURL: string | null; + rating: number; + ratingColor: string | null; } > = {}; @@ -1058,6 +1060,8 @@ export class TopcoderReportsService implements OnModuleDestroy { wins: {}, points: 0, prizes: 0, + rating: row.rating as number, + ratingColor: row.ratingColor, }; } @@ -1091,6 +1095,8 @@ export class TopcoderReportsService implements OnModuleDestroy { placement: index, points: entry.points, wins: entry.wins, + rating: entry.rating, + ratingColor: entry.ratingColor, prizes: useCmsPlacementPrizes ? (cmsPlacementPrizeAmounts[index] ?? 0) : entry.prizes, @@ -1211,7 +1217,6 @@ export class TopcoderReportsService implements OnModuleDestroy { const rows = await this.db.query(query, [ filters.challengeIds, ]); -console.log('here', rows); const placementData = rows.reduce( (acc: Record, row) => { From a852b93fe6b2319b8fd92029f4c7c2ff5dd37d31 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Mon, 3 Aug 2026 10:47:42 +0300 Subject: [PATCH 6/9] Fix points by win --- src/reports/topcoder/topcoder-reports.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/reports/topcoder/topcoder-reports.service.ts b/src/reports/topcoder/topcoder-reports.service.ts index 3e8dc49..0853fff 100644 --- a/src/reports/topcoder/topcoder-reports.service.ts +++ b/src/reports/topcoder/topcoder-reports.service.ts @@ -1073,7 +1073,7 @@ export class TopcoderReportsService implements OnModuleDestroy { const pointsByWin = log / sqrt; entriesByUser[row.userId].points += pointsByWin; - entriesByUser[row.userId].wins[row.challengeId] = row.placement; + entriesByUser[row.userId].wins[row.challengeId] = pointsByWin; if (!useCmsPlacementPrizes) { const prizeValue = (row.placementPrizes ?? []).find((p) => p.placement === row.placement) From f1b02f399a9ac0d9a6bf03d5f75e81e9337ccab4 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Mon, 3 Aug 2026 11:14:21 +0300 Subject: [PATCH 7/9] PM-5653 - Count passing submissions --- sql/reports/topcoder/leaderboard-generic.sql | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/sql/reports/topcoder/leaderboard-generic.sql b/sql/reports/topcoder/leaderboard-generic.sql index b56a626..6563e27 100644 --- a/sql/reports/topcoder/leaderboard-generic.sql +++ b/sql/reports/topcoder/leaderboard-generic.sql @@ -163,13 +163,15 @@ challenge_summary AS ( cc.challenge_name, cc.duration_days, COALESCE(SUM(pr.value), 0) AS prize_pool, - COUNT(DISTINCT ums.user_id) AS submissions_count, + ( + SELECT COUNT(DISTINCT ums.user_id) + FROM unique_member_submissions AS ums + WHERE ums.challenge_id = cc.challenge_id + ) AS submissions_count, JSONB_AGG( JSONB_BUILD_OBJECT('placement', pr.placement, 'value', pr.value) ORDER BY pr.placement ) FILTER (WHERE pr.value IS NOT NULL) AS placement_prizes FROM challenge_context AS cc - LEFT JOIN unique_member_submissions AS ums - ON ums.challenge_id = cc.challenge_id LEFT JOIN challenge_prizes AS pr ON pr.challenge_id = cc.challenge_id GROUP BY cc.challenge_id, cc.challenge_name, cc.duration_days From bae871a763d109ab63f4dd5c599af8512891e5e4 Mon Sep 17 00:00:00 2001 From: jmgasper Date: Tue, 4 Aug 2026 06:18:40 +1000 Subject: [PATCH 8/9] PM-5719: Include projected member payments What was broken The member payment dashboards omitted owed and on-hold July payments, leaving only an incorrect $51 contest total and no TAAS or engagement values. Root cause Both dashboard queries required PAID status and grouped values by payout date, unlike the established dashboard and Wallet Admin convention of using current non-cancelled payments in their creation month. What was changed Updated both payment-value queries to include latest non-cancelled payment records and group gross member amounts by payment creation month. Updated the dashboard documentation to describe the corrected projection semantics. Any added/updated tests Updated dashboard SQL regression coverage to require creation-month and non-cancelled selection for both payment charts and to reject PAID/date-paid filtering. --- README.md | 9 ++++--- .../dashboard/member-payment-by-customer.sql | 26 ++++++++++--------- .../dashboard/member-payment-by-month.sql | 25 +++++++++--------- .../dashboard/dashboard-reports.sql.spec.ts | 13 +++++++--- 4 files changed, 41 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 7608a85..ac64014 100644 --- a/README.md +++ b/README.md @@ -38,10 +38,11 @@ Dashboard figures use these shared definitions: `PAYMENT` winning. It is grouped by the payment creation month so projected payments that are owed or on hold remain visible. Members are deduplicated within each payment bucket and month. -- Member-payment values use the latest payment version and sum `gross_amount`, - falling back to `total_amount`. The payment-by-customer dashboard ranks the - top five billing-account clients across the selected range and groups all - unnamed or remaining clients under `Other Customers`. +- Member-payment values use the latest non-cancelled finance payment and group + `gross_amount` by payment creation month, falling back to `total_amount`. + The payment-by-customer dashboard ranks the top five billing-account clients + across the selected range and groups all unnamed or remaining clients under + `Other Customers`. - Challenge participation uses the latest actual phase completion month for Challenge, Marathon Match, and First2Finish cohorts. Registrants are Submitter resources, and submitters have a non-deleted submission for the diff --git a/sql/reports/dashboard/member-payment-by-customer.sql b/sql/reports/dashboard/member-payment-by-customer.sql index d506491..d619b60 100644 --- a/sql/reports/dashboard/member-payment-by-customer.sql +++ b/sql/reports/dashboard/member-payment-by-customer.sql @@ -1,11 +1,13 @@ --- Monthly paid-member values split by the selected range's top five clients. +-- Monthly member-payment values split by the selected range's top five clients. -- -- Parameters: -- $1 timestamptz - inclusive reporting range start -- $2 timestamptz - exclusive reporting range end -- --- The same ranked customer series is used for every month. Payments for --- unranked or unnamed clients are grouped under Other Customers. +-- The same ranked customer series is used for every month. The latest +-- non-cancelled payment record is counted in its creation month so projected +-- payments remain visible. Payments for unranked or unnamed clients are +-- grouped under Other Customers. -- Billing-account ids are normalized and compared as text so the historical -- zero sentinel falls back to challenge billing without unsafe integer casts. WITH bounds AS ( @@ -28,9 +30,9 @@ latest_payment_versions AS MATERIALIZED ( FROM finance.payment p GROUP BY p.winnings_id ), -paid_events AS MATERIALIZED ( +payment_events AS MATERIALIZED ( SELECT - COALESCE(p.date_paid, p.created_at) AS paid_at, + p.created_at AS activity_at, COALESCE(p.gross_amount, p.total_amount, 0) AS amount, NULLIF(TRIM(cl.id), '') AS customer_id, NULLIF(TRIM(cl.name), '') AS customer_label @@ -56,18 +58,18 @@ paid_events AS MATERIALIZED ( ) LEFT JOIN "billing-accounts"."Client" cl ON cl.id = COALESCE(payment_ba."clientId", challenge_ba."clientId") - WHERE p.payment_status = 'PAID' + WHERE p.payment_status IS DISTINCT FROM 'CANCELLED' AND w.type = 'PAYMENT' - AND COALESCE(p.date_paid, p.created_at) IS NOT NULL + AND p.created_at IS NOT NULL AND NULLIF(TRIM(w.winner_id), '') IS NOT NULL AND w.category::text IS DISTINCT FROM 'TOPGEAR_PAYMENT' ), selected_events AS ( SELECT pe.* - FROM paid_events pe + FROM payment_events pe CROSS JOIN bounds b - WHERE pe.paid_at >= b.start_at - AND pe.paid_at < b.end_at + WHERE pe.activity_at >= b.start_at + AND pe.activity_at < b.end_at ), customer_totals AS ( SELECT @@ -118,7 +120,7 @@ series AS ( ), monthly_amounts AS ( SELECT - DATE_TRUNC('month', se.paid_at) AS month_start, + DATE_TRUNC('month', se.activity_at) AS month_start, COALESCE( 'customer-' || tc.customer_id, 'other-customers' @@ -129,7 +131,7 @@ monthly_amounts AS ( ON tc.customer_id = se.customer_id AND tc.customer_label = se.customer_label GROUP BY - DATE_TRUNC('month', se.paid_at), + DATE_TRUNC('month', se.activity_at), COALESCE('customer-' || tc.customer_id, 'other-customers') ) SELECT diff --git a/sql/reports/dashboard/member-payment-by-month.sql b/sql/reports/dashboard/member-payment-by-month.sql index 6623be8..97a4c3f 100644 --- a/sql/reports/dashboard/member-payment-by-month.sql +++ b/sql/reports/dashboard/member-payment-by-month.sql @@ -1,11 +1,12 @@ --- Monthly paid-member values split by canonical payment bucket. +-- Monthly member-payment values split by canonical payment bucket. -- -- Parameters: -- $1 timestamptz - inclusive reporting range start -- $2 timestamptz - exclusive reporting range end -- --- Only the latest version of each payment is considered. Gross amount is the --- preferred member-payment value, with total amount used as a fallback. +-- The latest non-cancelled payment record is counted in its creation month so +-- projected payments remain visible. Gross amount is the preferred +-- member-payment value, with total amount used as a fallback. WITH bounds AS ( SELECT $1::timestamptz AT TIME ZONE 'UTC' AS start_at, @@ -26,9 +27,9 @@ latest_payment_versions AS MATERIALIZED ( FROM finance.payment p GROUP BY p.winnings_id ), -paid_events AS MATERIALIZED ( +payment_events AS MATERIALIZED ( SELECT - COALESCE(p.date_paid, p.created_at) AS paid_at, + p.created_at AS activity_at, COALESCE(p.gross_amount, p.total_amount, 0) AS amount, CASE WHEN w.category::text = 'TAAS_PAYMENT' THEN 'taas' @@ -48,15 +49,15 @@ paid_events AS MATERIALIZED ( AND lpv.max_version = p.version JOIN finance.winnings w ON w.winning_id = p.winnings_id - WHERE p.payment_status = 'PAID' + WHERE p.payment_status IS DISTINCT FROM 'CANCELLED' AND w.type = 'PAYMENT' - AND COALESCE(p.date_paid, p.created_at) IS NOT NULL + AND p.created_at IS NOT NULL AND NULLIF(TRIM(w.winner_id), '') IS NOT NULL AND w.category::text IS DISTINCT FROM 'TOPGEAR_PAYMENT' ), selected_months AS ( SELECT - DATE_TRUNC('month', pe.paid_at) AS month_start, + DATE_TRUNC('month', pe.activity_at) AS month_start, COALESCE(SUM(pe.amount) FILTER ( WHERE pe.payment_type = 'taas' ), 0) AS taas, @@ -69,11 +70,11 @@ selected_months AS ( COALESCE(SUM(pe.amount) FILTER ( WHERE pe.payment_type = 'engagement' ), 0) AS engagement - FROM paid_events pe + FROM payment_events pe CROSS JOIN bounds b - WHERE pe.paid_at >= b.start_at - AND pe.paid_at < b.end_at - GROUP BY DATE_TRUNC('month', pe.paid_at) + WHERE pe.activity_at >= b.start_at + AND pe.activity_at < b.end_at + GROUP BY DATE_TRUNC('month', pe.activity_at) ) SELECT TO_CHAR(m.month_start, 'YYYY-MM-01') AS month, diff --git a/src/reports/dashboard/dashboard-reports.sql.spec.ts b/src/reports/dashboard/dashboard-reports.sql.spec.ts index ec5891b..a88c0ef 100644 --- a/src/reports/dashboard/dashboard-reports.sql.spec.ts +++ b/src/reports/dashboard/dashboard-reports.sql.spec.ts @@ -54,14 +54,16 @@ describe("Dashboard report SQL", () => { expect(sql).toMatch(/COUNT\(DISTINCT pe\.member_id\) FILTER/g); }); - it("sums latest paid-member values by canonical payment bucket", () => { + it("sums projected member-payment values by canonical payment bucket", () => { const sql = sqlLoader.load("reports/dashboard/member-payment-by-month.sql"); expect(sql).toContain("MAX(p.version) AS max_version"); expect(sql).toContain("lpv.max_version = p.version"); - expect(sql).toContain("p.payment_status = 'PAID'"); + expect(sql).toContain("p.payment_status IS DISTINCT FROM 'CANCELLED'"); expect(sql).toContain("w.type = 'PAYMENT'"); - expect(sql).toContain("COALESCE(p.date_paid, p.created_at)"); + expect(sql).toContain("p.created_at AS activity_at"); + expect(sql).not.toContain("p.payment_status = 'PAID'"); + expect(sql).not.toContain("p.date_paid"); expect(sql).toContain( "COALESCE(p.gross_amount, p.total_amount, 0) AS amount", ); @@ -80,7 +82,10 @@ describe("Dashboard report SQL", () => { ); expect(sql).toContain("MAX(p.version) AS max_version"); - expect(sql).toContain("p.payment_status = 'PAID'"); + expect(sql).toContain("p.payment_status IS DISTINCT FROM 'CANCELLED'"); + expect(sql).toContain("p.created_at AS activity_at"); + expect(sql).not.toContain("p.payment_status = 'PAID'"); + expect(sql).not.toContain("p.date_paid"); expect(sql).toContain( "COALESCE(p.gross_amount, p.total_amount, 0) AS amount", ); From ae1bea3c50738f428666b265a17929dad0721421 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Tue, 4 Aug 2026 13:20:03 +0300 Subject: [PATCH 9/9] Round leaderboard points --- src/reports/topcoder/topcoder-reports.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/reports/topcoder/topcoder-reports.service.ts b/src/reports/topcoder/topcoder-reports.service.ts index 0853fff..f0d659b 100644 --- a/src/reports/topcoder/topcoder-reports.service.ts +++ b/src/reports/topcoder/topcoder-reports.service.ts @@ -1070,7 +1070,7 @@ export class TopcoderReportsService implements OnModuleDestroy { row.durationDays * (filters.pointsPerDay ?? 0); const relativeRank = row.placement / Math.max(1, row.submissionsCount); const sqrt = Math.sqrt(relativeRank); - const pointsByWin = log / sqrt; + const pointsByWin = Math.round(log / sqrt); entriesByUser[row.userId].points += pointsByWin; entriesByUser[row.userId].wins[row.challengeId] = pointsByWin;