From 5e3d6566ea300578b5dbed94b1aed8226f128efe Mon Sep 17 00:00:00 2001 From: jmgasper Date: Wed, 22 Apr 2026 15:26:46 +1000 Subject: [PATCH 1/2] Script to backfill markup / challenge fee values. --- ...ackfill-2026-challenge-billing-markups.sql | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 src/scripts/backfill-2026-challenge-billing-markups.sql diff --git a/src/scripts/backfill-2026-challenge-billing-markups.sql b/src/scripts/backfill-2026-challenge-billing-markups.sql new file mode 100644 index 0000000..09bcdad --- /dev/null +++ b/src/scripts/backfill-2026-challenge-billing-markups.sql @@ -0,0 +1,83 @@ +-- Backfill challenge billing markup from the billing-accounts schema. +-- +-- Scope: +-- - Challenges created on or after 2026-01-01 00:00:00. +-- - Challenges created before 2027-01-01 00:00:00. +-- - Existing challenge billing records with a billingAccountId. +-- - Rows where the stored challenge markup differs from the billing account +-- markup. +-- +-- Run this against the PostgreSQL database that contains both schemas: +-- - "challenges" +-- - "billing-accounts" + +BEGIN; + +WITH candidates AS ( + SELECT + cb."id" AS "challengeBillingId", + cb."challengeId", + cb."billingAccountId", + cb."markup" AS "currentMarkup", + ba."markup"::double precision AS "billingAccountMarkup" + FROM "challenges"."ChallengeBilling" cb + INNER JOIN "challenges"."Challenge" c + ON c."id" = cb."challengeId" + INNER JOIN "billing-accounts"."BillingAccount" ba + ON ba."id"::text = cb."billingAccountId" + WHERE c."createdAt" >= TIMESTAMP '2026-01-01 00:00:00' + AND c."createdAt" < TIMESTAMP '2027-01-01 00:00:00' + AND cb."billingAccountId" IS NOT NULL + AND cb."markup" IS DISTINCT FROM ba."markup"::double precision +) +SELECT + COUNT(*) AS "rowsToUpdate", + COUNT(DISTINCT "billingAccountId") AS "billingAccountsAffected" +FROM candidates; + +WITH candidates AS ( + SELECT + cb."id" AS "challengeBillingId", + ba."markup"::double precision AS "billingAccountMarkup" + FROM "challenges"."ChallengeBilling" cb + INNER JOIN "challenges"."Challenge" c + ON c."id" = cb."challengeId" + INNER JOIN "billing-accounts"."BillingAccount" ba + ON ba."id"::text = cb."billingAccountId" + WHERE c."createdAt" >= TIMESTAMP '2026-01-01 00:00:00' + AND c."createdAt" < TIMESTAMP '2027-01-01 00:00:00' + AND cb."billingAccountId" IS NOT NULL + AND cb."markup" IS DISTINCT FROM ba."markup"::double precision +), +updated AS ( + UPDATE "challenges"."ChallengeBilling" cb + SET + "markup" = candidates."billingAccountMarkup", + "updatedAt" = CURRENT_TIMESTAMP, + "updatedBy" = 'billing-markup-backfill-2026' + FROM candidates + WHERE cb."id" = candidates."challengeBillingId" + RETURNING + cb."challengeId", + cb."billingAccountId", + cb."markup" +) +SELECT + COUNT(*) AS "rowsUpdated", + COUNT(DISTINCT "billingAccountId") AS "billingAccountsAffected" +FROM updated; + +-- Example spot-check for the challenge from the incident: +SELECT + c."id" AS "challengeId", + cb."billingAccountId", + cb."markup" AS "challengeMarkup", + ba."markup" AS "billingAccountMarkup" +FROM "challenges"."Challenge" c +INNER JOIN "challenges"."ChallengeBilling" cb + ON cb."challengeId" = c."id" +INNER JOIN "billing-accounts"."BillingAccount" ba + ON ba."id"::text = cb."billingAccountId" +WHERE c."id" = '57a1d424-1931-49a8-a180-d0b0f6cdf293'; + +COMMIT; From fd49359d8a1c7417aaccea206d2e52804f1f2599 Mon Sep 17 00:00:00 2001 From: jmgasper Date: Fri, 24 Apr 2026 09:31:57 +1000 Subject: [PATCH 2/2] Use the new "shouldSEndEmail" flag on the resources API, to avoid blasting emails to everyone for historically imported challenges. --- .../importHistoricalMarathonMatches/apply.js | 1 + .../resourceApi.js | 19 +++++++++- ...ortHistoricalMarathonMatches.apply.test.js | 27 +++++++++++++ ...toricalMarathonMatches.resourceApi.test.js | 38 +++++++++++++++++++ 4 files changed, 84 insertions(+), 1 deletion(-) diff --git a/data-migration/src/scripts/importHistoricalMarathonMatches/apply.js b/data-migration/src/scripts/importHistoricalMarathonMatches/apply.js index fefbc09..2961892 100644 --- a/data-migration/src/scripts/importHistoricalMarathonMatches/apply.js +++ b/data-migration/src/scripts/importHistoricalMarathonMatches/apply.js @@ -2381,6 +2381,7 @@ const reconcileSubmitterResourcesForRound = async ({ challengeId, memberId: String(identity.memberId), roleId: submitterRoleId, + sendEmail: false, }; try { diff --git a/data-migration/src/scripts/importHistoricalMarathonMatches/resourceApi.js b/data-migration/src/scripts/importHistoricalMarathonMatches/resourceApi.js index 331aa5f..603d474 100644 --- a/data-migration/src/scripts/importHistoricalMarathonMatches/resourceApi.js +++ b/data-migration/src/scripts/importHistoricalMarathonMatches/resourceApi.js @@ -147,7 +147,23 @@ const createResourceApiClient = ({ return results; }; - const createSubmitterResource = async ({ challengeId, memberId, roleId = submitterRoleId }) => { + /** + * Creates a submitter resource in the Resource API. + * Historical MM imports can set `sendEmail` to false so backfilled registrations + * do not emit registration notifications for already-completed challenges. + * @param {Object} params request payload + * @param {string} params.challengeId v6 challenge id + * @param {string|number} params.memberId target member id + * @param {string} [params.roleId] resource role id, defaults to the configured submitter role + * @param {boolean} [params.sendEmail] optional Resource API email toggle + * @returns {Promise} created resource payload when the API returns JSON + */ + const createSubmitterResource = async ({ + challengeId, + memberId, + roleId = submitterRoleId, + sendEmail, + }) => { const token = await getAccessToken(); const response = await fetchImpl(normalizedBaseUrl, { method: "POST", @@ -159,6 +175,7 @@ const createResourceApiClient = ({ challengeId, memberId, roleId, + ...(typeof sendEmail === "boolean" ? { sendEmail } : {}), }), }); diff --git a/data-migration/test/importHistoricalMarathonMatches.apply.test.js b/data-migration/test/importHistoricalMarathonMatches.apply.test.js index a3128a5..a77166f 100644 --- a/data-migration/test/importHistoricalMarathonMatches.apply.test.js +++ b/data-migration/test/importHistoricalMarathonMatches.apply.test.js @@ -2292,11 +2292,13 @@ describe("importHistoricalMarathonMatches apply create-path behavior", () => { challengeId: "challenge-1", memberId: "2", roleId: "submitter-role", + sendEmail: false, }); expect(resourceClient.createSubmitterResource).toHaveBeenCalledWith({ challengeId: "challenge-1", memberId: "3", roleId: "submitter-role", + sendEmail: false, }); expect(result.records).toEqual([ { @@ -2425,6 +2427,7 @@ describe("importHistoricalMarathonMatches apply create-path behavior", () => { challengeId: "challenge-1", memberId: "1", roleId: "submitter-role", + sendEmail: false, }); expect(result.records).toEqual([ { @@ -2486,6 +2489,18 @@ describe("importHistoricalMarathonMatches apply create-path behavior", () => { "COMPLETED" ); expect(resourceClient.createSubmitterResource).toHaveBeenCalledTimes(2); + expect(resourceClient.createSubmitterResource).toHaveBeenNthCalledWith(1, { + challengeId: "challenge-1", + memberId: "2", + roleId: "submitter-role", + sendEmail: false, + }); + expect(resourceClient.createSubmitterResource).toHaveBeenNthCalledWith(2, { + challengeId: "challenge-1", + memberId: "2", + roleId: "submitter-role", + sendEmail: false, + }); expect(result).toEqual({ targetEligibleRegistrants: 1, existingSubmitterResources: 0, @@ -2542,5 +2557,17 @@ describe("importHistoricalMarathonMatches apply create-path behavior", () => { "challenge-1", "COMPLETED" ); + expect(resourceClient.createSubmitterResource).toHaveBeenNthCalledWith(1, { + challengeId: "challenge-1", + memberId: "2", + roleId: "submitter-role", + sendEmail: false, + }); + expect(resourceClient.createSubmitterResource).toHaveBeenNthCalledWith(2, { + challengeId: "challenge-1", + memberId: "2", + roleId: "submitter-role", + sendEmail: false, + }); }); }); diff --git a/data-migration/test/importHistoricalMarathonMatches.resourceApi.test.js b/data-migration/test/importHistoricalMarathonMatches.resourceApi.test.js index 2c55cc7..81b8434 100644 --- a/data-migration/test/importHistoricalMarathonMatches.resourceApi.test.js +++ b/data-migration/test/importHistoricalMarathonMatches.resourceApi.test.js @@ -1,5 +1,6 @@ const { createAuth0TokenProvider, + createResourceApiClient, } = require("../src/scripts/importHistoricalMarathonMatches/resourceApi"); describe("importHistoricalMarathonMatches resource api auth provider", () => { @@ -25,4 +26,41 @@ describe("importHistoricalMarathonMatches resource api auth provider", () => { expect.objectContaining({ method: "POST" }) ); }); + + test("forwards sendEmail when creating submitter resources", async () => { + const fetchImpl = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + statusText: "OK", + text: async () => JSON.stringify({ id: "resource-1" }), + }); + + const client = createResourceApiClient({ + baseUrl: "https://api.topcoder-dev.com/v6/resources", + submitterRoleId: "submitter-role", + getAccessToken: async () => "token-1", + fetchImpl, + }); + + await expect( + client.createSubmitterResource({ + challengeId: "challenge-1", + memberId: "12345", + sendEmail: false, + }) + ).resolves.toEqual({ id: "resource-1" }); + + expect(fetchImpl).toHaveBeenCalledWith( + "https://api.topcoder-dev.com/v6/resources", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ + challengeId: "challenge-1", + memberId: "12345", + roleId: "submitter-role", + sendEmail: false, + }), + }) + ); + }); });