diff --git a/.factory/library/architecture.md b/.factory/library/architecture.md index dcc1e07..f773f64 100644 --- a/.factory/library/architecture.md +++ b/.factory/library/architecture.md @@ -17,12 +17,14 @@ The mission adds a reusable importer inside `challenge-api-v6/data-migration/` t - existing v6 challenge data through the challenge DB / challenge-api schema - existing v6 resource data through the Resource API - existing v6 submission and review-summation data through the review DB / review-api schema +- local env configuration from `.env.importer.local`, including `SUBMISSION_ARCHIVE_DIR` ### Write surfaces - Challenge and ChallengePhase records in the challenge DB - submitter Resource records through the Resource API - Submission and ReviewSummation records in the review DB +- local submission archive zip files under `SUBMISSION_ARCHIVE_DIR` ## Import Pipeline @@ -60,6 +62,17 @@ For each selected round: Created challenges must use `challenge.legacyId = round.id`. Reused challenges are not challenge-level rewrite candidates; they must already be matched unambiguously by the rule above or remain `unresolved`. +### Challenge description sourcing + +Challenge description content comes from the legacy `round -> round_component -> component -> problem` mapping: + +- when a selected round maps to a legacy `problem` row with non-empty `problem_text`, persist that raw HTML as the v6 challenge description +- when `problem_text` is absent or unusable but the mapped `component.component_text` exists, convert that XML into best-effort Markdown and persist the converted Markdown as the v6 challenge description +- the XML fallback should keep user-facing content and avoid storing raw XML wrappers or wholesale hidden/internal test cases; if test cases are rendered, keep public/example-style cases only +- when neither a usable `problem_text` nor a usable `component_text` conversion is available, retain the existing placeholder/fallback description behavior +- on standard reuse/backfill runs, preserve existing challenge-level fields other than the approved follow-up description patch +- on targeted rerun patch mode, description overwrite is allowed only when the caller provides an explicit existing challenge-id override + ### 3. Phase materialization Canonical MM history in v6 is represented by exactly three standard phases: @@ -122,6 +135,16 @@ Only non-example legacy submissions are imported. The importer must preserve the **Stable submission identity invariant:** imported `Submission.legacySubmissionId` must be a deterministic composite derived from legacy submission identity so round-wide and rerun validation can compare exact sets. The contract assumes `legacySubmissionId` is the stable external identity for imported submissions. +### Submission archive backfill + +Imported/reused submissions also participate in a deterministic archive backfill flow: + +- load legacy submission text from the same submission identity used for `legacySubmissionId`, preferring the main long-submission text field and only falling back to secondary legacy text fields when needed +- build a deterministic archive filename from stable submission identity so reruns converge on the same local file and the same `submission.url` +- write a zip file containing a single text file with the recovered legacy submission text under `SUBMISSION_ARCHIVE_DIR` +- set `submission.url` to the delayed-upload target format `https://s3.amazonaws.com/topcoder-submissions/` +- on reruns, treat archive generation plus URL update as reconciliation work: recreate/refresh only as needed without duplicating submission rows + ### 6. Score materialization Two score streams are imported: @@ -148,6 +171,7 @@ These are core safety invariants: - existing v6 marathon challenges are source of truth for challenge-level fields - backfill may add missing linked records only +- the approved follow-up patch mode may additionally overwrite challenge `description` and submission archive/url data, but nothing else - already-present standard phase rows on reused challenges are preserved - reruns must not duplicate challenges, phases, resources, submissions, or review summations - example submissions and example review summations are never imported @@ -165,6 +189,12 @@ The observable result of rerunning a partially imported round should be reconcil If a temporary status-transition workflow is used during participant backfill, reruns must still converge to the same final completed state. +Targeted rerun patch mode is deliberately narrow and explicit: + +- it requires an explicit existing challenge-id override +- it may patch only the challenge description plus submission archive/url data for the selected round +- it must not recreate submissions or mutate resource/review/phase state outside the approved patch surfaces + ## Data Ownership Invariants ### Challenge DB @@ -188,6 +218,14 @@ Owns: - imported submissions - provisional review summations per submission - final review summations attached to the latest imported non-example submission per member +- the `submission.url` field pointing at the deterministic archive path + +### Local filesystem (`SUBMISSION_ARCHIVE_DIR`) + +Owns: + +- generated zip archives for legacy submission text +- deterministic archive filenames used to derive `submission.url` ## Validation-Oriented Invariants @@ -197,5 +235,7 @@ The validation contract relies on these high-level invariants being preserved: - a score-rich Marathon Match fixture is selected during score-feature work for final-ranking validation - round `14272` is the second selected round for multi-round blast-radius checks - imported submission identity is externally testable via `legacySubmissionId` +- imported description sourcing is externally testable via raw HTML challenge description reads +- imported archive backfill is externally testable via `submission.url` plus local zip inspection - reused-round verification depends on comparing both identity sets and externally visible field snapshots - for member-owned surfaces, validation now reconciles `imported subset + skipped missing-member subset = legacy total` diff --git a/.factory/library/environment.md b/.factory/library/environment.md index 4aa5b13..3228be2 100644 --- a/.factory/library/environment.md +++ b/.factory/library/environment.md @@ -18,6 +18,7 @@ Required values: - `MEMBER_DB_SCHEMA` — schema used for member lookup tables (default behavior is code-defined; validators should set it explicitly when member data is not reachable through the challenge schema) - `REVIEW_DB_URL` — review DB used for submissions and review summations - `RESOURCES_API_URL` — base URL for Resource API writes and reads +- `SUBMISSION_ARCHIVE_DIR` — local directory where submission archive zip files are created during submission URL backfill / targeted reruns - `AUTH0_URL` - `AUTH0_AUDIENCE` - `AUTH0_CLIENT_ID` @@ -28,6 +29,8 @@ Optional / useful values: - `DATA_DIRECTORY=/mnt/Informix` - importer-scoped attribution values such as `CREATED_BY` / `UPDATED_BY` +`SUBMISSION_ARCHIVE_DIR` must point at a writable local folder. Generated archives are local-only for this mission; workers must not upload them or assume the S3 path in `submission.url` is live. + ## Canonical API Endpoints For Validation - Challenge API base URL: `https://api.topcoder-dev.com/v6/challenges` @@ -39,6 +42,7 @@ Workers and validators should use these canonical endpoints rather than probing - `/mnt/Informix` is a read-only legacy data source. - Existing v6 marathon matches are backfill-only at the challenge level. +- Follow-up targeted rerun mode may overwrite only challenge descriptions plus submission archive/url data, and only when explicitly invoked with an existing challenge-id override. - Do not commit secrets from `.env.importer.local`. - The validation target is the existing dev environment referenced by the env file; workers should not assume they are allowed to start replacement local services. @@ -61,5 +65,8 @@ These are informational boundaries for worker safety: - Marathon matches come from legacy `round` rows with `round_type_id='13'`. - Primary join path: `round -> long_component_state -> long_submission -> long_comp_result`. +- Challenge description backfill uses the legacy `round -> round_component -> component -> problem` mapping. +- Description-source precedence is: raw `problem.problem_text` HTML first, then best-effort Markdown converted from `component.component_text` XML, then placeholder/preserve behavior only when neither source is usable. - `round_registration_*.json` is the source of submitter resources. +- Submission archive content comes from legacy submission text fields associated with the imported non-example submissions. - `user_*.json` resolves `coder_id` identities. diff --git a/.factory/library/legacy-data.md b/.factory/library/legacy-data.md index f946a46..7849503 100644 --- a/.factory/library/legacy-data.md +++ b/.factory/library/legacy-data.md @@ -10,7 +10,10 @@ Legacy source facts that workers should reuse instead of rediscovering. ## Primary Files - `round_1.json` +- `round_component_1.json` - `round_registration_*.json` +- `component_1.json` +- `problem_1.json` - `long_component_state_1.json` - `long_submission_*.json` - `long_comp_result_*.json` @@ -27,6 +30,10 @@ Use this legacy relationship when deriving participant/submission/final-score da - `round -> long_component_state -> long_submission -> long_comp_result` +Use this legacy relationship when deriving challenge descriptions: + +- `round -> round_component -> component -> problem` + ## Resource Source - submitter resources come from `round_registration_*.json` @@ -38,6 +45,16 @@ Use this legacy relationship when deriving participant/submission/final-score da - import full **non-example** history only - example submissions are excluded from imported submissions and imported score history - imported `Submission.legacySubmissionId` must be deterministic and stable across reruns +- submission archive text should prefer the primary legacy submission body field from `long_submission`; only fall back to secondary legacy submission text when that preferred body is absent +- generated archive filenames and derived `submission.url` values must remain deterministic across reruns + +## Description Rules + +- when the mapped `problem.problem_text` is non-empty, use that raw HTML as the challenge description +- when `problem_text` is empty/unusable but `component.component_text` exists, convert that XML into best-effort Markdown for the challenge description +- do not store raw XML wrappers in the description, and do not dump hidden/internal test cases wholesale; if test cases are rendered from XML fallback, keep public/example-style content only +- when neither source is usable or the round does not map cleanly, fall back to the importer's placeholder/preserve behavior +- component-level description lookup must stay round-scoped through `round_component`; `component_id` values can be reused across multiple rounds ### Named participant fixture @@ -65,7 +82,9 @@ Use this legacy relationship when deriving participant/submission/final-score da ## Fixture Rounds - `10815`: `836` eligible registrations, `1445` non-example submissions, `2424` example submissions, `267` submitters with non-example history, and fallback-heavy final-score behavior; in the current target-member snapshot this round plans `283` final candidates split into `266` importable finals, `2` missing-member final skips, and `15` explicit `finalist-without-attachable-submission` skips. Treat this as the selected unattachable-finalists fixture for score validation. +- `10758`: Marathon Match round with `round_component.component_id=6775`, `problem_id=7542`, empty `problem.problem_text`, and populated `component.component_text` for `RobotRouting`. Use this as the primary create-path fixture for XML-to-Markdown description fallback validation. The component XML is large and includes many hidden/internal test cases, so conversion rules must stay user-facing. - `17948`: selected score-rich Marathon Match fixture for final-score validation. Current planning/apply evidence for this round yields `81` legacy final candidates with `45` importable finals, `36` `missing-member` final skips, and `0` explicit `finalist-without-attachable-submission` skips. Imported finals on this fixture are `system_point_total`-backed and preserve legacy placement order when sorted by aggregate score descending after excluding missing-member finalists. +- `10015`: already-imported Marathon Match fixture observed with a placeholder v6 description despite having legacy problem text available; use this fixture for description overwrite and targeted rerun validation when it remains available in the shared dev environment. - `13897`: remains a useful large MM backfill fixture, but it is **not** the selected score-rich placement fixture because it currently includes `33` explicit `finalist-without-attachable-submission` skips. - `14272`: second selected-round filter fixture; current validation guidance treats it as an unresolved/non-Marathon-Match round rather than an importable Marathon Match target - `10089` and `10722` remain non-Marathon in current planning and should not be used as Marathon Match score fixtures. diff --git a/.factory/library/user-testing.md b/.factory/library/user-testing.md index f86b584..b1b048b 100644 --- a/.factory/library/user-testing.md +++ b/.factory/library/user-testing.md @@ -25,9 +25,10 @@ There is no browser or TUI surface for this mission. 2. Capture per-round decision records and deltas. 3. Run apply for the same selected round set. 4. Verify challenge/resource/submission/review state through API responses. -5. Inspect the skipped-file artifact for any missing-member records reported by the run. -6. Compare imported data plus skipped-member reporting to legacy data using read-only Python scripts. -7. Re-run apply or dry-run to prove idempotency. +5. For follow-up patch validation, inspect local archive files under `SUBMISSION_ARCHIVE_DIR` and compare their contents to legacy submission text. +6. Inspect the skipped-file artifact for any missing-member records reported by the run. +7. Compare imported data plus skipped-member reporting to legacy data using read-only Python scripts. +8. Re-run apply or dry-run to prove idempotency / patch-only behavior. Canonical live validation endpoints: @@ -41,6 +42,8 @@ If the approved missing-member policy is exercised, validators should reconcile - `10815` — primary create-path round during planning-challenge; in the shared dev environment it is now a post-create/backfill fixture - one score-rich Marathon Match round selected during score-feature work for final-ranking validation +- `10015` when available — already-imported description-backfill / targeted-rerun fixture +- `10758` — create-path XML-to-Markdown description-fallback fixture (`problem_text` empty, `component_text` present) - `14272` — second round for multi-round filter checks - one existing-v6 round chosen from dry-run output in the validation environment - one Marathon Match round with unattachable finalists selected during score-feature work for explicit skip/report validation @@ -57,6 +60,14 @@ If the approved missing-member policy is exercised, validators should reconcile - Validation uses the existing dev environment referenced by `.env.importer.local`. - `.env.importer.local` is populated, so live end-to-end apply-mode validation can proceed on the selected dev environment. +- `SUBMISSION_ARCHIVE_DIR` must point at a writable local directory before follow-up targeted rerun validation can pass. +- If `SUBMISSION_ARCHIVE_DIR` is missing from `.env.importer.local`, validators may export a writable override in the same shell command for live targeted-rerun validation instead of editing the env file. +- Follow-up user-testing round 2 on `2026-04-15` confirmed that apply-mode importer connectivity to the configured challenge/review databases is restored: positive targeted reruns succeeded for round `17948`, create-path XML-fallback validation succeeded for round `10758`, and targeted-rerun XML-fallback validation succeeded for round `13897`. +- Round `10015` is not currently imported in the shared dev environment, so successful raw-HTML targeted-rerun validation now uses round `17948` as the live fallback fixture. +- Follow-up user-testing round 3 on `2026-04-15` resolved the no-source preserve gap by importing round `17391` as challenge `b983de6f-cc7f-463e-867c-87e54f3b72f1`, then proving targeted rerun preserved the existing description exactly while writing `248` local archives and updating `248` submission URLs. +- Round `17391` / challenge `b983de6f-cc7f-463e-867c-87e54f3b72f1` is now the shared-environment preserve/no-source fixture for `VAL-FOLLOWUP-005`; current legacy lookup resolves neither usable `problem.problem_text` nor usable converted `component.component_text` Markdown there. +- The initial create apply for round `17391` surfaced a non-blocking data issue after challenge creation: legacy submission `-403380560001` (coder `22836077`) is missing numeric `submission_points`. +- Misc-importer-hardening user-testing on `2026-04-15` confirmed the hardening fix on the shared round `17391` fixture: standard apply reruns now exit `0`, the malformed row is recorded with `reasonCode=malformed-provisional-score`, and the current provisional partition is `152` imported/reconciled, `96` malformed-provisional skips, and `189` missing-member skips. - Pre-existing repo-wide `standard-lint` noise in `challenge-api-v6` should not be mistaken for importer regressions; validators should focus on mission-owned surfaces. - The shared dev environment does not necessarily contain every historical legacy member id, so member-owned validation must account for approved `missing-member` skips rather than assuming full one-to-one import coverage. - If dry-run/apply returns `target-member-resolution-unavailable`, the validation environment still lacks reachable member lookup configuration. Provide `MEMBER_DB_URL` (or a `DATABASE_URL` that can resolve members) plus a valid `MEMBER_DB_SCHEMA` before expecting populated missing-member partitions or skipped-file records from live runs. @@ -66,12 +77,21 @@ If the approved missing-member policy is exercised, validators should reconcile - Treat `legacyId=13897` / challenge `a15cbb04-a0d3-4647-85bd-23d8d11e9f3f` as an already-imported shared-environment fixture. Use it for reuse/rerun and post-import property checks only; do not attempt destructive cleanup or concurrent apply-mode validation against it. - Round `10815` was imported during planning-challenge user-testing round 2 as challenge `5fa76bd9-da55-422d-8d4c-4f0155dc62c5`. In the shared dev environment it is now a post-create fixture rather than a pristine missing-historical round, so future validators should not expect pre-apply create-path evidence there unless they use a clean/reset environment. - Immediate rerun dry-run on `10815` now reports `reuse/backfill-only` with `phases.toCreate=0`, but still classifies the round as `partial-backfill` because resources/submissions/finalScores/provisionalScores remain pending later-milestone work. Use it to verify challenge/phase reuse only, not full-surface no-op reruns. -- In the current shared dev environment, `13897` is a partial-backfill fixture: the challenge and standard phases already exist, while linked resources/submissions/review-summation surfaces still read as empty. That means no-op rerun assertions for a fully imported round cannot be proven here without a separately completed fixture. +- In the current shared dev environment, `13897` is now a fully imported rerun fixture whose description was updated to converted `component_text` Markdown during follow-up user-testing round 2. Use it for rerun/idempotency/archive checks, not as a placeholder-description candidate. - `GET https://api.topcoder-dev.com/v6/challenges` and `GET https://api.topcoder-dev.com/v6/challenges/` work without auth in this environment. `GET https://api.topcoder-dev.com/v6/resources?challengeId=` and `GET https://api.topcoder-dev.com/v6/submissions?challengeId=` are also readable without auth. +- Follow-up description validation should read `GET /v6/challenges/` before and after the targeted rerun and compare the raw HTML `description` field directly. - `GET https://api.topcoder-dev.com/v6/reviewSummations?challengeId=` requires an M2M bearer token. Source `.env.importer.local`, run `node get_token.js`, and use the final stdout line as the token value. - Response shapes are mixed: challenge/resource lookups return arrays directly, while `submissions` and authenticated `reviewSummations` return paginated objects with `data` and `meta`. Validators should count rows from the `data` array and set a large `perPage` value (for example `1000` or higher) before reconciling totals. +- Follow-up submission-archive validation should read submissions before and after the rerun, record URL deltas, and inspect at least one generated zip file locally to confirm it contains the expected legacy submission text. In this environment the submissions API does not reliably expose the persisted `url`, so URL-specific assertions should use review DB snapshots or equivalent read-only DB evidence. +- XML-fallback description validation should use round `10758` only when a clean create-path fixture is available. In the current shared environment, round `10758` has already been imported as challenge `324a7cf2-f967-4578-9012-55be2730e2b0` with converted Markdown, so future create-path checks must use saved evidence or another clean fixture. +- Round `13897` should no longer be treated as a preserve-with-no-source follow-up fixture and no longer has the placeholder description in the shared environment; round-2 targeted rerun validation updated it to usable converted `component_text` Markdown. +- Current export-backed already-imported XML-fallback placeholder candidate is `9874`; `9892` is `round_type_id=15` and should not be used as a Marathon Match follow-up fixture. +- Round `17391` / challenge `b983de6f-cc7f-463e-867c-87e54f3b72f1` is now the preserve/no-source fixture for `VAL-FOLLOWUP-005`; targeted rerun preserves `Imported historical Marathon Match from legacy round 17391` because legacy lookup resolves neither usable `problem.problem_text` nor usable converted `component.component_text` Markdown. +- Round `17391` is now also the shared malformed-provisional-score smoke fixture for the misc hardening milestone. Its initial create apply originally exited after challenge creation because submission `-403380560001` / coder `22836077` lacked numeric `submission_points`, but current standard apply reruns exit `0`, report that row with `reasonCode=malformed-provisional-score`, and keep second-run challenge/phase/resource/submission/review identity sets stable. This validation exercises the existing/reconciliation path rather than a pristine create path. +- Patch-only rerun validation must capture resource / submission-count / review-count snapshots before and after the rerun and show that only description plus submission URL/archive surfaces changed. - When participant backfill encounters legacy members absent from the dev environment, validators should expect a skipped-file artifact and should confirm that the skipped member ids plus the imported member-owned records reconcile back to the legacy totals for the round. - Round `14272` currently dry-runs as `decision=unresolved` with reason `selected-round-round-type-is-not-marathon-match`; it remains useful for exact-filter and unresolved-path validation but should not be treated as an importable Marathon Match fixture. +- The approved follow-up rerun mode must fail closed without an explicit challenge-id override; validators should include one negative-path run that omits the override and confirm no writes occur. - Previously considered score candidates such as `10089` and `10722` should not be assumed valid Marathon Match fixtures in the current validation environment unless a later score-feature investigation reconfirms them. - Dry-run planning against `/mnt/Informix` can take several minutes; use generous timeouts (roughly 360-480s) for evidence-capture runs to avoid false timeout failures. - Do not run apply-mode validators concurrently on the same round or shared dev database. Read-only dry-run/API checks may run concurrently only when they avoid rounds being mutated by another validator. diff --git a/.factory/skills/migration-worker/SKILL.md b/.factory/skills/migration-worker/SKILL.md index b0b0205..57abf37 100644 --- a/.factory/skills/migration-worker/SKILL.md +++ b/.factory/skills/migration-worker/SKILL.md @@ -55,6 +55,8 @@ None. - use dry-run for planning features - use apply-mode only when the env file and target round selection are ready +- for targeted-rerun archive validation, confirm `SUBMISSION_ARCHIVE_DIR` is set first; if the env file lacks it, export a writable override in the same shell command instead of editing or committing the env file +- if a live validation needs persisted submission `url` evidence and the submissions API does not expose it in this environment, use the approved read-only review DB snapshot path or other authoritative read-only evidence instead of guessing from incomplete API payloads - verify the exact API-visible data that corresponds to the feature's `fulfills` assertions 11. End with a precise handoff. Be explicit about what was implemented, which assertions became testable, what commands ran, what manual checks were performed, whether this was a resume-validation case, and any tech debt or unresolved ambiguity. diff --git a/.factory/validation/followup-content-archives/scrutiny/reviews/mm-importer-component-text-markdown-fallback.json b/.factory/validation/followup-content-archives/scrutiny/reviews/mm-importer-component-text-markdown-fallback.json new file mode 100644 index 0000000..4b3e0a3 --- /dev/null +++ b/.factory/validation/followup-content-archives/scrutiny/reviews/mm-importer-component-text-markdown-fallback.json @@ -0,0 +1,22 @@ +{ + "featureId": "mm-importer-component-text-markdown-fallback", + "reviewedAt": "2026-04-14T04:16:01Z", + "commitId": "b383a4da4de96b6eeafe86d622884a8ec5beeaec", + "transcriptSkeletonReviewed": true, + "diffReviewed": true, + "status": "fail", + "codeReview": { + "summary": "The XML-to-Markdown conversion and planning/apply wiring are solid, but the new targeted-rerun fallback branch duplicates the same non-idempotent description-write behavior as the raw problem-text branch.", + "issues": [ + { + "file": "data-migration/src/scripts/importHistoricalMarathonMatches/apply.js", + "line": 938, + "severity": "blocking", + "description": "The component-markdown branch of `runTargetedRerunMode` also unconditionally calls `prisma.challenge.update` whenever usable converted Markdown exists. Once a fallback description has already been backfilled, rerunning targeted rerun will still mutate challenge audit fields/timestamps even when the stored description already equals the generated Markdown. That copies the same patch-only violation found in the raw `problem_text` branch. Add the same current-description equality guard before writing the component-markdown update." + } + ] + }, + "sharedStateObservations": [], + "addressesFailureFrom": null, + "summary": "Reviewed the handoff, transcript skeleton, commit `b383a4da4de96b6eeafe86d622884a8ec5beeaec`, and current fallback logic for `mm-importer-component-text-markdown-fallback`. Result: fail. The Markdown fallback behavior itself is correct, but its targeted-rerun update path is not idempotent once the description has already been patched." +} diff --git a/.factory/validation/followup-content-archives/scrutiny/reviews/mm-importer-followup-live-dev-validation.json b/.factory/validation/followup-content-archives/scrutiny/reviews/mm-importer-followup-live-dev-validation.json new file mode 100644 index 0000000..169054d --- /dev/null +++ b/.factory/validation/followup-content-archives/scrutiny/reviews/mm-importer-followup-live-dev-validation.json @@ -0,0 +1,15 @@ +{ + "featureId": "mm-importer-followup-live-dev-validation", + "reviewedAt": "2026-04-14T04:16:01Z", + "commitId": "d1c4bfd0f55d6999b8f3d99c8f19e22fec386316", + "transcriptSkeletonReviewed": true, + "diffReviewed": true, + "status": "pass", + "codeReview": { + "summary": "The validation feature captured substantial live evidence under the mission `evidence/` tree: before/after snapshots, review-DB URL coverage, archive existence checks, and sampled archive payload verification for both targeted rounds.", + "issues": [] + }, + "sharedStateObservations": [], + "addressesFailureFrom": null, + "summary": "Reviewed the handoff, transcript skeleton, and saved evidence for `mm-importer-followup-live-dev-validation`. Result: pass. The artifacts comprehensively cover archive/url reconciliation and patch-only resource/submission/review behavior, and they were useful for spotting the separate description-rerun bug called out in the description-feature reviews." +} diff --git a/.factory/validation/followup-content-archives/scrutiny/reviews/mm-importer-problem-text-description-backfill.json b/.factory/validation/followup-content-archives/scrutiny/reviews/mm-importer-problem-text-description-backfill.json new file mode 100644 index 0000000..3bd5480 --- /dev/null +++ b/.factory/validation/followup-content-archives/scrutiny/reviews/mm-importer-problem-text-description-backfill.json @@ -0,0 +1,22 @@ +{ + "featureId": "mm-importer-problem-text-description-backfill", + "reviewedAt": "2026-04-14T04:16:01Z", + "commitId": "5122aae05124f7e3c946984501b88dd9df29eb25", + "transcriptSkeletonReviewed": true, + "diffReviewed": true, + "status": "fail", + "codeReview": { + "summary": "The feature correctly resolves legacy `problem.problem_text` and uses it for description sourcing, but the targeted-rerun write path is not idempotent on challenge metadata.", + "issues": [ + { + "file": "data-migration/src/scripts/importHistoricalMarathonMatches/apply.js", + "line": 885, + "severity": "blocking", + "description": "When usable legacy `problem.problem_text` exists, `runTargetedRerunMode` always calls `prisma.challenge.update` without first checking whether the current challenge description already matches that HTML. The live validation evidence for round `17948` shows `descriptionChangedPreToPost=false` while `updatedTimestampChangedPreToPost=true`, so a second targeted rerun mutates non-description challenge fields even though there is no description delta. That violates the patch-only contract for reruns. Read the current description first (or otherwise compare before writing) and skip the update when the stored description already matches the legacy HTML." + } + ] + }, + "sharedStateObservations": [], + "addressesFailureFrom": null, + "summary": "Reviewed the handoff, transcript skeleton, current `apply.js` logic, and live validation artifacts for `mm-importer-problem-text-description-backfill` at commit `5122aae05124f7e3c946984501b88dd9df29eb25`. Result: fail. Raw HTML sourcing works, but repeated targeted reruns still rewrite the challenge row when the description is already correct." +} diff --git a/.factory/validation/followup-content-archives/scrutiny/reviews/mm-importer-submission-archive-url-backfill.json b/.factory/validation/followup-content-archives/scrutiny/reviews/mm-importer-submission-archive-url-backfill.json new file mode 100644 index 0000000..e0e3ad1 --- /dev/null +++ b/.factory/validation/followup-content-archives/scrutiny/reviews/mm-importer-submission-archive-url-backfill.json @@ -0,0 +1,15 @@ +{ + "featureId": "mm-importer-submission-archive-url-backfill", + "reviewedAt": "2026-04-14T04:16:01Z", + "commitId": "d1c4bfd0f55d6999b8f3d99c8f19e22fec386316", + "transcriptSkeletonReviewed": true, + "diffReviewed": true, + "status": "pass", + "codeReview": { + "summary": "The archive/url backfill feature deterministically maps `legacySubmissionId` values to unique local zip archives and S3-style URLs, updates only mismatched submission URLs, and has matching automated plus live evidence for one-to-one archive coverage.", + "issues": [] + }, + "sharedStateObservations": [], + "addressesFailureFrom": null, + "summary": "Reviewed the handoff, transcript skeleton, evidence-backed live validation, and commit `d1c4bfd0f55d6999b8f3d99c8f19e22fec386316` for `mm-importer-submission-archive-url-backfill`. Result: pass. The reconciliation logic and evidence both support deterministic archive/url backfill without unrelated submission mutations." +} diff --git a/.factory/validation/followup-content-archives/scrutiny/reviews/mm-importer-targeted-rerun-description-idempotency-fix.json b/.factory/validation/followup-content-archives/scrutiny/reviews/mm-importer-targeted-rerun-description-idempotency-fix.json new file mode 100644 index 0000000..4efc593 --- /dev/null +++ b/.factory/validation/followup-content-archives/scrutiny/reviews/mm-importer-targeted-rerun-description-idempotency-fix.json @@ -0,0 +1,18 @@ +{ + "featureId": "mm-importer-targeted-rerun-description-idempotency-fix", + "reviewedAt": "2026-04-14T04:30:45Z", + "commitId": "179714c75ba9b2fe19a7f2e4765ee1406c0676ba", + "transcriptSkeletonReviewed": true, + "diffReviewed": true, + "status": "pass", + "codeReview": { + "summary": "The fix closes the prior targeted-rerun idempotency blockers by reading the current challenge description before patching and skipping `prisma.challenge.update` when the stored value already matches either the legacy problem HTML or the converted component Markdown.", + "issues": [] + }, + "sharedStateObservations": [], + "addressesFailureFrom": [ + "mm-importer-problem-text-description-backfill", + "mm-importer-component-text-markdown-fallback" + ], + "summary": "Reviewed the fix handoff, transcript skeleton, prior failed reviews, commit `179714c75ba9b2fe19a7f2e4765ee1406c0676ba`, current `runTargetedRerunMode` logic, and the new regression tests. Result: pass. The targeted-rerun flow now fetches the current description, skips challenge updates when the computed raw HTML or component-markdown text already matches, and preserves archive/URL reconciliation behavior while covering both no-op branches in tests." +} diff --git a/.factory/validation/followup-content-archives/scrutiny/reviews/mm-importer-targeted-rerun-guardrails.json b/.factory/validation/followup-content-archives/scrutiny/reviews/mm-importer-targeted-rerun-guardrails.json new file mode 100644 index 0000000..7f3c2ae --- /dev/null +++ b/.factory/validation/followup-content-archives/scrutiny/reviews/mm-importer-targeted-rerun-guardrails.json @@ -0,0 +1,15 @@ +{ + "featureId": "mm-importer-targeted-rerun-guardrails", + "reviewedAt": "2026-04-14T04:16:01Z", + "commitId": "ee7db41a1219fbfe4a5591f427a9eeb20c681504", + "transcriptSkeletonReviewed": true, + "diffReviewed": true, + "status": "pass", + "codeReview": { + "summary": "The guardrail feature cleanly adds an explicit targeted-rerun mode, requires an explicit challenge-id override, and rejects mismatched or non-reuse selections before any apply-path writes run.", + "issues": [] + }, + "sharedStateObservations": [], + "addressesFailureFrom": null, + "summary": "Reviewed the handoff, transcript skeleton, and commit `ee7db41a1219fbfe4a5591f427a9eeb20c681504` for `mm-importer-targeted-rerun-guardrails`. Result: pass. The CLI/parser/apply changes consistently fail closed for missing or mismatched overrides and stay separate from the standard backfill flow." +} diff --git a/.factory/validation/followup-content-archives/scrutiny/synthesis.json b/.factory/validation/followup-content-archives/scrutiny/synthesis.json new file mode 100644 index 0000000..24d9d24 --- /dev/null +++ b/.factory/validation/followup-content-archives/scrutiny/synthesis.json @@ -0,0 +1,33 @@ +{ + "milestone": "followup-content-archives", + "round": 2, + "status": "pass", + "validatorsRun": { + "test": { + "passed": true, + "command": "source \"$HOME/.config/nvm/nvm.sh\" && (cd \"/home/jmgasper/Documents/Git/v6/challenge-api-v6/data-migration\" && nvm use 18.19.0 >/dev/null && pnpm test --maxWorkers=16 --testPathIgnorePatterns test/challenge-migration.test.js)", + "exitCode": 0 + }, + "typecheck": { + "passed": true, + "command": "echo \"No dedicated typecheck command for this JavaScript importer surface\"", + "exitCode": 0 + }, + "lint": { + "passed": true, + "command": "source \"$HOME/.config/nvm/nvm.sh\" && (cd \"/home/jmgasper/Documents/Git/v6/challenge-api-v6/data-migration\" && nvm use 18.19.0 >/dev/null && pnpm lint)", + "exitCode": 0 + } + }, + "reviewsSummary": { + "total": 1, + "passed": 1, + "failed": 0, + "failedFeatures": [] + }, + "blockingIssues": [], + "appliedUpdates": [], + "suggestedGuidanceUpdates": [], + "rejectedObservations": [], + "previousRound": ".factory/validation/followup-content-archives/scrutiny/synthesis.round1.json" +} diff --git a/.factory/validation/followup-content-archives/scrutiny/synthesis.round1.json b/.factory/validation/followup-content-archives/scrutiny/synthesis.round1.json new file mode 100644 index 0000000..64050a3 --- /dev/null +++ b/.factory/validation/followup-content-archives/scrutiny/synthesis.round1.json @@ -0,0 +1,43 @@ +{ + "milestone": "followup-content-archives", + "round": 1, + "status": "fail", + "validatorsRun": { + "test": { + "passed": true, + "command": "source \"$HOME/.config/nvm/nvm.sh\" && (cd \"/home/jmgasper/Documents/Git/v6/challenge-api-v6/data-migration\" && nvm use 18.19.0 >/dev/null && pnpm test --maxWorkers=16 --testPathIgnorePatterns test/challenge-migration.test.js)", + "exitCode": 0 + }, + "typecheck": { + "passed": true, + "command": "echo \"No dedicated typecheck command for this JavaScript importer surface\"", + "exitCode": 0 + }, + "lint": { + "passed": true, + "command": "source \"$HOME/.config/nvm/nvm.sh\" && (cd \"/home/jmgasper/Documents/Git/v6/challenge-api-v6/data-migration\" && nvm use 18.19.0 >/dev/null && pnpm lint)", + "exitCode": 0 + } + }, + "reviewsSummary": { + "total": 5, + "passed": 3, + "failed": 2, + "failedFeatures": [ + "mm-importer-problem-text-description-backfill", + "mm-importer-component-text-markdown-fallback" + ] + }, + "blockingIssues": [ + { + "featureId": "mm-importer-problem-text-description-backfill", + "severity": "blocking", + "description": "Targeted-rerun description patches are not idempotent. `runTargetedRerunMode` rewrites the challenge row whenever usable legacy description content exists, even when the stored description already matches; live evidence for round 17948 shows no description delta but a changed challenge updated timestamp. The same unconditional write pattern is duplicated in the component_text fallback branch.", + "suggestedFix": "Load the current challenge description (or add an equivalent equality check) before calling `prisma.challenge.update`, and skip the write when the computed legacy problem HTML or component Markdown already matches the stored description." + } + ], + "appliedUpdates": [], + "suggestedGuidanceUpdates": [], + "rejectedObservations": [], + "previousRound": null +} diff --git a/.factory/validation/followup-content-archives/user-testing/flows/raw-problem-html.json b/.factory/validation/followup-content-archives/user-testing/flows/raw-problem-html.json new file mode 100644 index 0000000..d3fd1c3 --- /dev/null +++ b/.factory/validation/followup-content-archives/user-testing/flows/raw-problem-html.json @@ -0,0 +1,118 @@ +{ + "groupId": "raw-problem-html", + "surface": "importer CLI + API verification", + "assertions": [ + { + "id": "VAL-FOLLOWUP-001", + "status": "blocked", + "reason": "The current persisted challenge description already exactly matches legacy problem.problem_text HTML for round 17948, but both valid targeted-rerun apply attempts failed before any importer apply logic because the session could not reach topcoder-services.ci8xwsszszsw.us-east-1.rds.amazonaws.com:5432. This session therefore could not prove the positive rerun path itself.", + "evidence": [ + "legacy-problem-mapping-17948.json", + "legacy-problem-17948.html", + "pre-snapshot.json", + "positive-first-rerun.stderr.txt", + "positive-second-rerun.stderr.txt", + "analysis.json" + ] + }, + { + "id": "VAL-FOLLOWUP-002", + "status": "blocked", + "reason": "Archive/url backfill could not be exercised because both valid targeted-rerun apply attempts failed before any writes. The assigned archive directory stayed empty, so no one-to-one zip/url mapping could be proven in this session. Legacy round 17948 submission text rows also show 0 non-empty texts, so the expected sample payload for this fixture would have been an empty text file if rerun execution were available.", + "evidence": [ + "pre-snapshot.json", + "legacy-submission-sample-17948.json", + "positive-first-rerun.stderr.txt", + "positive-second-rerun.stderr.txt", + "post-positive-attempts-snapshot.json", + "analysis.json" + ] + }, + { + "id": "VAL-FOLLOWUP-003", + "status": "pass", + "reason": "The missing --challenge-id invocation failed immediately with the explicit guardrail error, and the mismatched challenge-id invocation also exited non-zero. Pre vs post-negative snapshots matched exactly for target/control challenge, resource, submission, review, and archive surfaces, so the invalid targeted-rerun invocations failed closed with no writes.", + "evidence": [ + "negative-missing-challenge-id.stderr.txt", + "negative-mismatched-challenge-id.stderr.txt", + "pre-snapshot.json", + "post-negative-snapshot.json", + "analysis.json" + ] + }, + { + "id": "VAL-FOLLOWUP-004", + "status": "blocked", + "reason": "Patch-only positive-path behavior could not be proven because both valid targeted-rerun apply attempts were blocked by direct database connectivity failures before any importer writes could execute. The post-attempt snapshot shows no target/control side effects, but successful patch-only mutation semantics were not exercisable in this session.", + "evidence": [ + "pre-snapshot.json", + "post-positive-attempts-snapshot.json", + "positive-first-rerun.stderr.txt", + "positive-second-rerun.stderr.txt", + "analysis.json" + ] + } + ], + "toolsUsed": [ + "node", + "curl", + "python" + ], + "commands": [ + { + "command": "python3 /tmp/capture_raw_problem_html_snapshot.py --output .../pre-snapshot.json --archive-dir .../submission-archives --target-round-id 17948 --target-challenge-id 8d6775ad-2235-4a4d-9daa-062bd4417978 --control-round-id 10815 --control-challenge-id 5fa76bd9-da55-422d-8d4c-4f0155dc62c5", + "exitCode": 0, + "observation": "Captured pre-run API snapshots and empty archive-dir evidence (target: resources=47, submissions=370, reviewSummations=415; control: resources=828, submissions=1444, reviewSummations=1710)." + }, + { + "command": "python3 legacy mapping extractor for round 17948 -> legacy-problem-mapping-17948.json + legacy-problem-17948.html", + "exitCode": 0, + "observation": "Resolved round 17948 to componentId 122638 / problemId 16094 with usable raw problem_text length 10267 (sha256 2e4120aea036844fc0a1cb03731a7d922e928c54576a822017af74b0243263e5)." + }, + { + "command": "node loadNonExampleLegacySubmissionRowsByRoundId for round 17948 -> legacy-submission-sample-17948.json", + "exitCode": 0, + "observation": "Loaded 700 legacy submission rows for round 17948 and found 0 non-empty legacy submission texts; sample legacySubmissionId 108839490001 has expected empty payload length 0." + }, + { + "command": "node src/scripts/importHistoricalMarathonMatches.js --apply --targeted-rerun --round-id 17948", + "exitCode": 1, + "observation": "Failed fast with --targeted-rerun requires --challenge-id ." + }, + { + "command": "node src/scripts/importHistoricalMarathonMatches.js --apply --targeted-rerun --round-id 17948 --challenge-id 5fa76bd9-da55-422d-8d4c-4f0155dc62c5", + "exitCode": 1, + "observation": "Exited non-zero with no writes; current environment hit a direct challenge DB connectivity timeout before a mismatch-specific guardrail message could be emitted." + }, + { + "command": "python3 /tmp/capture_raw_problem_html_snapshot.py --output .../post-negative-snapshot.json ...", + "exitCode": 0, + "observation": "Pre vs post-negative snapshots matched exactly for target/control challenge, resource, submission, review, and archive surfaces." + }, + { + "command": "SUBMISSION_ARCHIVE_DIR=... node src/scripts/importHistoricalMarathonMatches.js --apply --targeted-rerun --round-id 17948 --challenge-id 8d6775ad-2235-4a4d-9daa-062bd4417978", + "exitCode": 1, + "observation": "Blocked before apply logic: prisma.challengeType.findMany could not reach topcoder-services.ci8xwsszszsw.us-east-1.rds.amazonaws.com:5432." + }, + { + "command": "repeat positive targeted-rerun command with identical SUBMISSION_ARCHIVE_DIR override", + "exitCode": 1, + "observation": "Retry hit the same direct database connectivity timeout; no archives or API-visible writes occurred." + }, + { + "command": "python3 /tmp/capture_raw_problem_html_snapshot.py --output .../post-positive-attempts-snapshot.json ...", + "exitCode": 0, + "observation": "Pre vs post-positive-attempt snapshots matched exactly, confirming that the blocked apply attempts produced no target/control or archive side effects." + }, + { + "command": "python3 compare legacy-problem-17948.html vs pre-snapshot target challenge description", + "exitCode": 0, + "observation": "The current stored target challenge description already exactly matches the raw legacy HTML (length 10267; identical sha256)." + } + ], + "frictions": [], + "blockers": [ + "Direct database access needed by apply-mode targeted reruns is unavailable from this session: challenge/importer Prisma calls to topcoder-services.ci8xwsszszsw.us-east-1.rds.amazonaws.com:5432 timed out, blocking successful positive rerun execution for VAL-FOLLOWUP-001/002/004.", + "The submissions API still does not expose persisted submission.url values in this environment, so URL/backfill validation depends on direct DB access or another equivalent surface; with the database timeout above, VAL-FOLLOWUP-002 could not be proven." + ] +} diff --git a/.factory/validation/followup-content-archives/user-testing/flows/round2-no-source-preserve.json b/.factory/validation/followup-content-archives/user-testing/flows/round2-no-source-preserve.json new file mode 100644 index 0000000..32a97b5 --- /dev/null +++ b/.factory/validation/followup-content-archives/user-testing/flows/round2-no-source-preserve.json @@ -0,0 +1,52 @@ +{ + "groupId": "round2-no-source-preserve", + "surface": "importer CLI + API verification", + "testedAt": "2026-04-15T00:02:48.625204+00:00", + "assertions": [ + { + "id": "VAL-FOLLOWUP-005", + "status": "blocked", + "reason": "No already-imported exported Marathon Match challenge currently satisfies the no-source preserve precondition in the shared environment/export set. Search across 23 imported MM/Data Science challenges found only five export-backed historical fixtures: 9874, 9892, 10815, 13897, and 17948. Rounds 9874, 10815, and 13897 all resolve usable converted component_text Markdown; 17948 resolves usable raw problem.problem_text HTML; and 9892 is round_type_id=15 rather than Marathon Match. Export-wide search still found 237 no-source Marathon Match rounds in /mnt/Informix, but zero of them are already imported in the shared environment, so there is no valid fixture to run the required targeted rerun against.", + "evidence": [ + "followup-content-archives/round2/no-source-preserve/imported-mm-no-source-search.json", + "followup-content-archives/round2/no-source-preserve/export-mm-no-source-rounds.json", + "followup-content-archives/round2/no-source-preserve/search-analysis.json", + "followup-content-archives/round2/no-source-preserve/candidate-challenge-api-snapshots.json" + ] + } + ], + "toolsUsed": [ + "node", + "python", + "challenge API" + ], + "commands": [ + { + "command": "node legacy/source + challenge/review DB search -> imported-mm-no-source-search.json", + "exitCode": 0, + "observation": "Scanned 23 imported MM/Data Science challenges with legacyId values. Only five map to current /mnt/Informix historical rounds (9874, 9892, 10815, 13897, 17948), and none meet the no-source preserve precondition." + }, + { + "command": "node export-wide MM no-source scan -> export-mm-no-source-rounds.json", + "exitCode": 0, + "observation": "Scanned 1107 exported Marathon Match rounds. Found 237 rounds with no usable problem_text and no usable converted component_text Markdown, but 0 of those rounds are already imported as MM/Data Science challenges in the shared environment." + }, + { + "command": "python derive search-analysis.json from the two search artifacts", + "exitCode": 0, + "observation": "Summarized the export-backed imported historical fixtures: 9874/10815/13897 are XML-fallback candidates, 17948 is raw-problem HTML, and 9892 is not a Marathon Match round." + }, + { + "command": "python fetch challenge API snapshots for legacyIds 9874, 9892, 10815, 13897, 17948 -> candidate-challenge-api-snapshots.json", + "exitCode": 0, + "observation": "Captured current public challenge API responses for the five export-backed imported fixtures to pair user-surface state with the legacy-source search evidence." + } + ], + "frictions": [ + "Most imported MM/Data Science challenges in the shared environment use synthetic 30096xxx legacyIds that are absent from /mnt/Informix, so the search had to intersect imported challenges with export-backed rounds before evaluating description-source eligibility." + ], + "blockers": [ + "Fixture availability blocker: the current shared environment/export set contains no already-imported exported Marathon Match round whose legacy description lookup produces neither usable problem.problem_text nor usable converted component.component_text Markdown. Without such a fixture, targeted rerun execution would not test VAL-FOLLOWUP-005 and was intentionally not run." + ], + "summary": "VAL-FOLLOWUP-005 is blocked because the current shared environment has no already-imported exported Marathon Match fixture that satisfies the preserve/no-source precondition." +} diff --git a/.factory/validation/followup-content-archives/user-testing/flows/round2-raw-problem-html.json b/.factory/validation/followup-content-archives/user-testing/flows/round2-raw-problem-html.json new file mode 100644 index 0000000..e6046c8 --- /dev/null +++ b/.factory/validation/followup-content-archives/user-testing/flows/round2-raw-problem-html.json @@ -0,0 +1,87 @@ +{ + "groupId": "round2-raw-problem-html", + "surface": "importer CLI + API verification", + "testedAt": "2026-04-15T00:27:26.127Z", + "assertions": [ + { + "id": "VAL-FOLLOWUP-001", + "status": "pass", + "reason": "Round 10015 is unavailable in this environment and candidate search found no imported raw-problem-html mismatch fixture, so round 17948 was used as the allowed fallback. Its legacy problem.problem_text is usable raw HTML, the pre-rerun challenge description already matches that HTML exactly, and both successful targeted reruns reported descriptionSource=legacy-problem-text with reason targeted-rerun-description-already-matched-legacy-problem-text, proving raw HTML kept precedence over any XML fallback on the positive rerun path.", + "evidence": [ + "challenge-query-10015.json", + "candidate-search.json", + "legacy-problem-mapping-17948.json", + "legacy-problem-17948.html", + "pre-snapshot.json", + "positive-first-rerun.stdout.txt", + "positive-second-rerun.stdout.txt", + "analysis.json" + ] + }, + { + "id": "VAL-FOLLOWUP-002", + "status": "pass", + "reason": "The submissions API omits a usable persisted url surface here, so read-only review DB snapshots were used. Across both successful targeted reruns, the target challenge kept the same 370 legacySubmissionId values and the same 370 review-DB submission rows; every row matched the deterministic https://s3.amazonaws.com/topcoder-submissions/.zip pattern, the assigned archive dir contained exactly one matching zip per submission with no filename/url collisions, and the inspected sample archive 108839490001-546ad4972d0e.zip contained the expected single empty text file matching the legacy submission text.", + "evidence": [ + "pre-snapshot.json", + "post-first-snapshot.json", + "post-second-snapshot.json", + "legacy-submission-summary-17948.json", + "sample-archive-inspection.json", + "submission-url-mapping-post-second.json", + "positive-first-rerun.stdout.txt", + "positive-second-rerun.stdout.txt", + "analysis.json" + ] + }, + { + "id": "VAL-FOLLOWUP-004", + "status": "pass", + "reason": "Pre vs post-first vs post-second snapshots show the target challenge id and every non-description challenge field, phase, resource, submission (non-url), and review surface unchanged, while the unselected control round 10815 also remained unchanged. The only observed writable side effect was the dedicated local archive directory changing from 0 to 370 deterministic zip files on the first rerun and then preserving the same archive filename/content set on the second rerun.", + "evidence": [ + "pre-snapshot.json", + "post-first-snapshot.json", + "post-second-snapshot.json", + "positive-first-rerun.stdout.txt", + "positive-second-rerun.stdout.txt", + "analysis.json" + ] + } + ], + "toolsUsed": [ + "node", + "curl", + "python" + ], + "commands": [ + "curl -sS \"https://api.topcoder-dev.com/v6/challenges?legacyId=10015\" > challenge-query-10015.json", + "curl -sS \"https://api.topcoder-dev.com/v6/challenges?legacyId=17948\" > challenge-query-17948.json", + "node data-migration/src/scripts/importHistoricalMarathonMatches.js --dry-run --round-id 17948 --skipped-file /dry-run-skipped-17948.json", + "REVIEW_TOKEN=$(node get_token.js | tail -n 1) node capture_snapshot.js --output pre-snapshot.json --archive-dir --target-round-id 17948 --target-challenge-id 8d6775ad-2235-4a4d-9daa-062bd4417978 --control-round-id 10815 --control-challenge-id 5fa76bd9-da55-422d-8d4c-4f0155dc62c5", + "SUBMISSION_ARCHIVE_DIR= node data-migration/src/scripts/importHistoricalMarathonMatches.js --apply --targeted-rerun --round-id 17948 --challenge-id 8d6775ad-2235-4a4d-9daa-062bd4417978", + "REVIEW_TOKEN=$(node get_token.js | tail -n 1) node capture_snapshot.js --output post-first-snapshot.json --archive-dir --target-round-id 17948 --target-challenge-id 8d6775ad-2235-4a4d-9daa-062bd4417978 --control-round-id 10815 --control-challenge-id 5fa76bd9-da55-422d-8d4c-4f0155dc62c5", + "SUBMISSION_ARCHIVE_DIR= node data-migration/src/scripts/importHistoricalMarathonMatches.js --apply --targeted-rerun --round-id 17948 --challenge-id 8d6775ad-2235-4a4d-9daa-062bd4417978", + "REVIEW_TOKEN=$(node get_token.js | tail -n 1) node capture_snapshot.js --output post-second-snapshot.json --archive-dir --target-round-id 17948 --target-challenge-id 8d6775ad-2235-4a4d-9daa-062bd4417978 --control-round-id 10815 --control-challenge-id 5fa76bd9-da55-422d-8d4c-4f0155dc62c5" + ], + "frictions": [ + { + "description": "Fixture round 10015 is not imported in the shared dev environment and candidate search found no already-imported raw-problem-html mismatch fixture, so the allowed fallback fixture 17948 had to be used for the positive rerun path.", + "resolved": true, + "resolution": "Used round 17948 / challenge 8d6775ad-2235-4a4d-9daa-062bd4417978 and documented that the evidence is an already-matched positive path rather than a visible description-delta fixture.", + "affectedAssertions": [ + "VAL-FOLLOWUP-001" + ] + }, + { + "description": "The submissions API does not expose usable persisted submission.url values in this environment.", + "resolved": true, + "resolution": "Captured read-only review DB submission-url snapshots alongside the API snapshots to prove the full legacySubmissionId -> filename -> url mapping.", + "affectedAssertions": [ + "VAL-FOLLOWUP-002", + "VAL-FOLLOWUP-004" + ] + } + ], + "blockers": [], + "summary": "Validated round 17948 with control round 10815. VAL-FOLLOWUP-001 passed via the only available already-matched raw-HTML positive rerun path, VAL-FOLLOWUP-002 passed with full 370-row deterministic archive/url mapping plus sample zip inspection, and VAL-FOLLOWUP-004 passed with patch-only stability across target/control API surfaces and second-rerun idempotency." +} \ No newline at end of file diff --git a/.factory/validation/followup-content-archives/user-testing/flows/round2-xml-fallback.json b/.factory/validation/followup-content-archives/user-testing/flows/round2-xml-fallback.json new file mode 100644 index 0000000..3618e14 --- /dev/null +++ b/.factory/validation/followup-content-archives/user-testing/flows/round2-xml-fallback.json @@ -0,0 +1,96 @@ +{ + "groupId": "round2-xml-fallback", + "surface": "importer CLI + API verification", + "testedAt": "2026-04-15T00:25:04.890257+00:00", + "assertions": [ + { + "id": "VAL-FOLLOWUP-006", + "status": "pass", + "reason": "Round 10758 was absent before apply, the standard importer created challenge 324a7cf2-f967-4578-9012-55be2730e2b0, and the persisted description exactly matched Markdown derived from component 6775 instead of the fallback placeholder.", + "evidence": [ + "/home/jmgasper/.factory/missions/6a38fff2-4c64-45c7-b2ae-b705da0ac3d1/evidence/followup-content-archives/round2/xml-rerun/preprobe.json", + "/home/jmgasper/.factory/missions/6a38fff2-4c64-45c7-b2ae-b705da0ac3d1/evidence/followup-content-archives/round2/xml-rerun/10758-pre-challenges.json", + "/home/jmgasper/.factory/missions/6a38fff2-4c64-45c7-b2ae-b705da0ac3d1/evidence/followup-content-archives/round2/xml-rerun/10758-apply.log", + "/home/jmgasper/.factory/missions/6a38fff2-4c64-45c7-b2ae-b705da0ac3d1/evidence/followup-content-archives/round2/xml-rerun/10758-post-create-challenge-list.json", + "/home/jmgasper/.factory/missions/6a38fff2-4c64-45c7-b2ae-b705da0ac3d1/evidence/followup-content-archives/round2/xml-rerun/10758-post-create-challenge.json", + "/home/jmgasper/.factory/missions/6a38fff2-4c64-45c7-b2ae-b705da0ac3d1/evidence/followup-content-archives/round2/xml-rerun/10758-expected-description.md", + "/home/jmgasper/.factory/missions/6a38fff2-4c64-45c7-b2ae-b705da0ac3d1/evidence/followup-content-archives/round2/xml-rerun/xml-fallback-analysis.json" + ] + }, + { + "id": "VAL-FOLLOWUP-007", + "status": "pass", + "reason": "Targeted rerun on already-imported round 13897 with challenge override a15cbb04-a0d3-4647-85bd-23d8d11e9f3f updated the placeholder description to the exact Markdown derived from component 9378 while leaving non-description challenge fields and linked-record identity sets stable.", + "evidence": [ + "/home/jmgasper/.factory/missions/6a38fff2-4c64-45c7-b2ae-b705da0ac3d1/evidence/followup-content-archives/round2/xml-rerun/preprobe.json", + "/home/jmgasper/.factory/missions/6a38fff2-4c64-45c7-b2ae-b705da0ac3d1/evidence/followup-content-archives/round2/xml-rerun/13897-pre-challenge.json", + "/home/jmgasper/.factory/missions/6a38fff2-4c64-45c7-b2ae-b705da0ac3d1/evidence/followup-content-archives/round2/xml-rerun/13897-targeted-rerun.log", + "/home/jmgasper/.factory/missions/6a38fff2-4c64-45c7-b2ae-b705da0ac3d1/evidence/followup-content-archives/round2/xml-rerun/13897-post-challenge.json", + "/home/jmgasper/.factory/missions/6a38fff2-4c64-45c7-b2ae-b705da0ac3d1/evidence/followup-content-archives/round2/xml-rerun/13897-expected-description.md", + "/home/jmgasper/.factory/missions/6a38fff2-4c64-45c7-b2ae-b705da0ac3d1/evidence/followup-content-archives/round2/xml-rerun/13897-pre-resources.json", + "/home/jmgasper/.factory/missions/6a38fff2-4c64-45c7-b2ae-b705da0ac3d1/evidence/followup-content-archives/round2/xml-rerun/13897-post-resources.json", + "/home/jmgasper/.factory/missions/6a38fff2-4c64-45c7-b2ae-b705da0ac3d1/evidence/followup-content-archives/round2/xml-rerun/13897-pre-submissions.json", + "/home/jmgasper/.factory/missions/6a38fff2-4c64-45c7-b2ae-b705da0ac3d1/evidence/followup-content-archives/round2/xml-rerun/13897-post-submissions.json", + "/home/jmgasper/.factory/missions/6a38fff2-4c64-45c7-b2ae-b705da0ac3d1/evidence/followup-content-archives/round2/xml-rerun/13897-pre-reviewSummations.json", + "/home/jmgasper/.factory/missions/6a38fff2-4c64-45c7-b2ae-b705da0ac3d1/evidence/followup-content-archives/round2/xml-rerun/13897-post-reviewSummations.json", + "/home/jmgasper/.factory/missions/6a38fff2-4c64-45c7-b2ae-b705da0ac3d1/evidence/followup-content-archives/round2/xml-rerun/9874-pre-control-challenge.json", + "/home/jmgasper/.factory/missions/6a38fff2-4c64-45c7-b2ae-b705da0ac3d1/evidence/followup-content-archives/round2/xml-rerun/9874-post-control-challenge.json", + "/home/jmgasper/.factory/missions/6a38fff2-4c64-45c7-b2ae-b705da0ac3d1/evidence/followup-content-archives/round2/xml-rerun/xml-fallback-analysis.json" + ] + }, + { + "id": "VAL-FOLLOWUP-008", + "status": "pass", + "reason": "Both persisted XML-fallback descriptions (create-path 10758 and rerun 13897) were user-facing Markdown that matched the importer conversion output and contained no XML wrappers, serialized tags, CDATA markers, or hidden/internal test-case markers.", + "evidence": [ + "/home/jmgasper/.factory/missions/6a38fff2-4c64-45c7-b2ae-b705da0ac3d1/evidence/followup-content-archives/round2/xml-rerun/10758-source-component-preview.xml", + "/home/jmgasper/.factory/missions/6a38fff2-4c64-45c7-b2ae-b705da0ac3d1/evidence/followup-content-archives/round2/xml-rerun/13897-source-component-preview.xml", + "/home/jmgasper/.factory/missions/6a38fff2-4c64-45c7-b2ae-b705da0ac3d1/evidence/followup-content-archives/round2/xml-rerun/10758-post-create-challenge.json", + "/home/jmgasper/.factory/missions/6a38fff2-4c64-45c7-b2ae-b705da0ac3d1/evidence/followup-content-archives/round2/xml-rerun/13897-post-challenge.json", + "/home/jmgasper/.factory/missions/6a38fff2-4c64-45c7-b2ae-b705da0ac3d1/evidence/followup-content-archives/round2/xml-rerun/10758-expected-description.md", + "/home/jmgasper/.factory/missions/6a38fff2-4c64-45c7-b2ae-b705da0ac3d1/evidence/followup-content-archives/round2/xml-rerun/13897-expected-description.md", + "/home/jmgasper/.factory/missions/6a38fff2-4c64-45c7-b2ae-b705da0ac3d1/evidence/followup-content-archives/round2/xml-rerun/xml-fallback-analysis.json" + ] + } + ], + "toolsUsed": [ + "node", + "python", + "Challenge API" + ], + "commands": [ + { + "command": "python3 -> preprobe.json", + "exitCode": 0, + "observation": "Confirmed round 10758 as the preferred absent create-path XML-fallback fixture and round 13897 / challenge a15cbb04-a0d3-4647-85bd-23d8d11e9f3f as the preferred already-imported rerun fixture with a placeholder description." + }, + { + "command": "python3 -> 10758-pre-*.json, 13897-pre-*.json, 9874-pre-control-*.json, pre-archive-dir.json", + "exitCode": 0, + "observation": "Captured 10758 absent before apply, 13897 placeholder description plus pre-run resource/submission/review counts, 9874 control challenge state, and an empty archive directory." + }, + { + "command": "/home/jmgasper/.config/nvm/versions/node/v18.19.0/bin/node /home/jmgasper/Documents/Git/v6/challenge-api-v6/data-migration/src/scripts/importHistoricalMarathonMatches.js --apply --round-id 10758 --skipped-file /home/jmgasper/.factory/missions/6a38fff2-4c64-45c7-b2ae-b705da0ac3d1/evidence/followup-content-archives/round2/xml-rerun/10758-apply-skipped.json", + "exitCode": 0, + "observation": "Importer created challenge 324a7cf2-f967-4578-9012-55be2730e2b0 for round 10758 and reported created challenge status in 10758-apply.log." + }, + { + "command": "python3 -> 10758-post-create-*.json", + "exitCode": 0, + "observation": "Verified the newly created 10758 challenge description matched converted component_text Markdown, with 310 resources, 119 submissions, and 148 review summations visible after create-path apply." + }, + { + "command": "/home/jmgasper/.config/nvm/versions/node/v18.19.0/bin/node /home/jmgasper/Documents/Git/v6/challenge-api-v6/data-migration/src/scripts/importHistoricalMarathonMatches.js --apply --targeted-rerun --round-id 13897 --challenge-id a15cbb04-a0d3-4647-85bd-23d8d11e9f3f", + "exitCode": 0, + "observation": "Targeted rerun updated the 13897 challenge description from the placeholder to legacy component_text Markdown and wrote 1784 local archives while reporting no submission URL changes were needed." + }, + { + "command": "python3 -> 13897-post-*.json, 9874-post-control-*.json, post-archive-dir.json, xml-fallback-analysis.json", + "exitCode": 0, + "observation": "Verified 13897 non-description challenge fields, resource tuple set, submission legacySubmissionId set, review ID set, and the 9874 control challenge remained unchanged while both XML-fallback descriptions passed wrapper/hidden-marker checks." + } + ], + "frictions": [], + "blockers": [], + "summary": "Validated rounds 10758 (create path) and 13897 (targeted rerun). VAL-FOLLOWUP-006, VAL-FOLLOWUP-007, and VAL-FOLLOWUP-008 all passed; 13897 patch-only checks and 9874 control snapshots stayed stable while 1784 rerun archive files were generated under the assigned archive directory." +} \ No newline at end of file diff --git a/.factory/validation/followup-content-archives/user-testing/flows/round3-no-source-preserve.json b/.factory/validation/followup-content-archives/user-testing/flows/round3-no-source-preserve.json new file mode 100644 index 0000000..f78c621 --- /dev/null +++ b/.factory/validation/followup-content-archives/user-testing/flows/round3-no-source-preserve.json @@ -0,0 +1,111 @@ +{ + "groupId": "round3-no-source-preserve", + "testedAt": "2026-04-15T04:31:11.346150+00:00", + "isolation": { + "missionDir": "/home/jmgasper/.factory/missions/6a38fff2-4c64-45c7-b2ae-b705da0ac3d1", + "repoRoot": "/home/jmgasper/Documents/Git/v6/challenge-api-v6", + "flowReportPath": "/home/jmgasper/Documents/Git/v6/challenge-api-v6/.factory/validation/followup-content-archives/user-testing/flows/round3-no-source-preserve.json", + "evidenceDir": "/home/jmgasper/.factory/missions/6a38fff2-4c64-45c7-b2ae-b705da0ac3d1/evidence/followup-content-archives/round3/no-source-preserve", + "archiveDir": "/home/jmgasper/.factory/missions/6a38fff2-4c64-45c7-b2ae-b705da0ac3d1/evidence/followup-content-archives/round3/no-source-preserve/archive-dir", + "tmpDir": "/home/jmgasper/.factory/missions/6a38fff2-4c64-45c7-b2ae-b705da0ac3d1/evidence/followup-content-archives/round3/no-source-preserve/tmp", + "legacyRoundId": "17391", + "challengeId": "b983de6f-cc7f-463e-867c-87e54f3b72f1" + }, + "toolsUsed": [ + "node", + "python3", + "challenge-api" + ], + "commandsRun": [ + "python3 availability check over 237 exported no-source Marathon Match rounds -> followup-content-archives/round3/no-source-preserve/current-fixture-availability.json", + "source \"$HOME/.config/nvm/nvm.sh\" && source .env.importer.local && nvm use 18.19.0 >/dev/null && node data-migration/src/scripts/importHistoricalMarathonMatches.js --dry-run --round-id 17391", + "source \"$HOME/.config/nvm/nvm.sh\" && source .env.importer.local && nvm use 18.19.0 >/dev/null && node data-migration/src/scripts/importHistoricalMarathonMatches.js --apply --round-id 17391", + "python3 challenge API lookup for legacyId=17391 before/after create and before/after targeted rerun", + "source \"$HOME/.config/nvm/nvm.sh\" && source .env.importer.local && nvm use 18.19.0 >/dev/null && export SUBMISSION_ARCHIVE_DIR= && node data-migration/src/scripts/importHistoricalMarathonMatches.js --apply --targeted-rerun --round-id 17391 --challenge-id b983de6f-cc7f-463e-867c-87e54f3b72f1" + ], + "assertions": [ + { + "id": "VAL-FOLLOWUP-005", + "title": "When neither usable problem text nor usable component_text Markdown exists, targeted rerun preserves the placeholder or fallback description", + "status": "pass", + "steps": [ + { + "action": "Check whether any exported no-source Marathon Match round is already imported in the shared dev environment.", + "expected": "Reuse an existing imported no-source fixture if one exists.", + "observed": "Queried 237 exported no-source Marathon Match rounds; imported count was 0." + }, + { + "action": "Select a create-path no-source round and run dry-run planning.", + "expected": "Find a currently unimported exported Marathon Match round whose dry-run decision is create.", + "observed": "Round 17391 was selected because it was the only export-backed no-source round with mapped component ids; dry-run returned decision=create with no matching existing challenge." + }, + { + "action": "Inspect legacy round/component/problem source data for round 17391.", + "expected": "No usable problem.problem_text and no usable component.component_text Markdown source.", + "observed": "round_type_id=13; component_id=-8 mapped to problem_id=-1; problem text usable=False; component Markdown usable=False; resolved description source=None." + }, + { + "action": "Run the standard importer apply for round 17391 to create the fixture.", + "expected": "Create/import the round in the shared dev environment.", + "observed": "The apply run created challenge b983de6f-cc7f-463e-867c-87e54f3b72f1 with placeholder description but then exited with `Legacy provisional score for round 17391 submission -403380560001 (coder 22836077) is missing numeric submission_points.`" + }, + { + "action": "Fetch the created challenge before targeted rerun.", + "expected": "Challenge detail is readable and shows the pre-rerun placeholder/fallback description.", + "observed": "GET /v6/challenges/b983de6f-cc7f-463e-867c-87e54f3b72f1 returned description `Imported historical Marathon Match from legacy round 17391`." + }, + { + "action": "Run targeted rerun with explicit challenge-id override and isolated SUBMISSION_ARCHIVE_DIR.", + "expected": "Description is preserved because no usable legacy source exists; archive/url backfill may still occur.", + "observed": "Targeted rerun returned status `targeted-rerun-preserved`; descriptionUpdated=false; descriptionSource=`existing-description-preserved-no-usable-legacy-problem-text`; archivesWritten=248; urlsUpdated=248." + }, + { + "action": "Fetch the challenge again after targeted rerun and compare descriptions.", + "expected": "Post-rerun description exactly matches the pre-rerun placeholder/fallback description.", + "observed": "Pre/post description SHA256 matched (31f0618c74a55037e564bd06f128fac04c92233d61118d71bef7c46d0f27ea46); description remained `Imported historical Marathon Match from legacy round 17391`." + } + ], + "evidence": { + "availabilityCheck": "followup-content-archives/round3/no-source-preserve/current-fixture-availability.json", + "candidateAnalysis": "followup-content-archives/round3/no-source-preserve/candidate-analysis.json", + "legacyLookup": "followup-content-archives/round3/no-source-preserve/legacy-source-lookup-round-17391.json", + "preCreateChallengeLookup": "followup-content-archives/round3/no-source-preserve/challenge-api-pre-create-round-17391.json", + "postApplyChallengeLookup": "followup-content-archives/round3/no-source-preserve/challenge-api-post-apply-round-17391.json", + "preRerunChallengeDetail": "followup-content-archives/round3/no-source-preserve/challenge-api-pre-rerun-detail-round-17391.json", + "postRerunChallengeDetail": "followup-content-archives/round3/no-source-preserve/challenge-api-post-rerun-detail-round-17391.json", + "verificationSummary": "followup-content-archives/round3/no-source-preserve/verification-summary-round-17391.json", + "createApplyStdout": "followup-content-archives/round3/no-source-preserve/create-apply.stdout.log", + "createApplyStderr": "followup-content-archives/round3/no-source-preserve/create-apply.stderr.log", + "targetedRerunStdout": "followup-content-archives/round3/no-source-preserve/targeted-rerun.stdout.log", + "targetedRerunStderr": "followup-content-archives/round3/no-source-preserve/targeted-rerun.stderr.log", + "network": [ + "GET https://api.topcoder-dev.com/v6/challenges?legacyId=17391&perPage=100 -> 200 [] before create", + "GET https://api.topcoder-dev.com/v6/challenges?legacyId=17391&perPage=100 -> 200 [b983de6f-cc7f-463e-867c-87e54f3b72f1] after apply attempt", + "GET https://api.topcoder-dev.com/v6/challenges/b983de6f-cc7f-463e-867c-87e54f3b72f1 -> 200 before targeted rerun", + "GET https://api.topcoder-dev.com/v6/challenges/b983de6f-cc7f-463e-867c-87e54f3b72f1 -> 200 after targeted rerun" + ] + }, + "issues": null + } + ], + "frictions": [ + { + "description": "No already-imported exported Marathon Match no-source fixture existed in the shared dev environment.", + "resolved": true, + "resolution": "Queried all 237 exported no-source Marathon Match rounds, then used round 17391 because it was the only export-backed no-source round with mapped component ids and a create-path dry-run.", + "affectedAssertions": [ + "VAL-FOLLOWUP-005" + ] + }, + { + "description": "The standard create apply for round 17391 exited on a legacy provisional-score data issue after the challenge row had already been created.", + "resolved": true, + "resolution": "Confirmed the apply created challenge b983de6f-cc7f-463e-867c-87e54f3b72f1 with the placeholder description, then validated the targeted rerun against that created challenge because the assertion depends on preserving an existing description when no usable legacy source exists.", + "affectedAssertions": [ + "VAL-FOLLOWUP-005" + ] + } + ], + "blockers": [], + "summary": "VAL-FOLLOWUP-005 passed using new fixture round 17391 / challenge b983de6f-cc7f-463e-867c-87e54f3b72f1. The targeted rerun preserved the placeholder description exactly and also wrote 248 local archives / updated 248 submission URLs; the initial create apply had a non-blocking provisional-score error after creating the challenge." +} diff --git a/.factory/validation/followup-content-archives/user-testing/flows/xml-fallback.json b/.factory/validation/followup-content-archives/user-testing/flows/xml-fallback.json new file mode 100644 index 0000000..2053277 --- /dev/null +++ b/.factory/validation/followup-content-archives/user-testing/flows/xml-fallback.json @@ -0,0 +1,104 @@ +{ + "groupId": "xml-fallback", + "surface": "importer CLI + API verification", + "testedAt": "2026-04-14T04:49:55.945595+00:00", + "assertions": [ + { + "id": "VAL-FOLLOWUP-005", + "status": "blocked", + "reason": "Assigned preserve fixture 13897 no longer meets the assertion precondition: current legacy lookup resolves usable component_text Markdown for component 9378 while problem.problem_text remains empty, and the targeted rerun could not be exercised because importer apply-mode could not reach the configured challenge database.", + "evidence": [ + "/home/jmgasper/.factory/missions/6a38fff2-4c64-45c7-b2ae-b705da0ac3d1/evidence/followup-content-archives/xml-fallback/preprobe.json", + "/home/jmgasper/.factory/missions/6a38fff2-4c64-45c7-b2ae-b705da0ac3d1/evidence/followup-content-archives/xml-fallback/13897-pre-challenge.json", + "/home/jmgasper/.factory/missions/6a38fff2-4c64-45c7-b2ae-b705da0ac3d1/evidence/followup-content-archives/xml-fallback/13897-post-challenge.json", + "/home/jmgasper/.factory/missions/6a38fff2-4c64-45c7-b2ae-b705da0ac3d1/evidence/followup-content-archives/xml-fallback/13897-targeted-rerun.log", + "/home/jmgasper/.factory/missions/6a38fff2-4c64-45c7-b2ae-b705da0ac3d1/evidence/followup-content-archives/xml-fallback/comparison-summary.json" + ] + }, + { + "id": "VAL-FOLLOWUP-006", + "status": "blocked", + "reason": "Round 10758 remained absent before and after the validation attempts. Legacy evidence confirms empty problem_text plus usable component_text Markdown fallback, but both create-path apply attempts failed before writes because the importer could not reach the configured challenge database.", + "evidence": [ + "/home/jmgasper/.factory/missions/6a38fff2-4c64-45c7-b2ae-b705da0ac3d1/evidence/followup-content-archives/xml-fallback/preprobe.json", + "/home/jmgasper/.factory/missions/6a38fff2-4c64-45c7-b2ae-b705da0ac3d1/evidence/followup-content-archives/xml-fallback/10758-pre-challenges.json", + "/home/jmgasper/.factory/missions/6a38fff2-4c64-45c7-b2ae-b705da0ac3d1/evidence/followup-content-archives/xml-fallback/10758-post-challenges.json", + "/home/jmgasper/.factory/missions/6a38fff2-4c64-45c7-b2ae-b705da0ac3d1/evidence/followup-content-archives/xml-fallback/10758-apply.log", + "/home/jmgasper/.factory/missions/6a38fff2-4c64-45c7-b2ae-b705da0ac3d1/evidence/followup-content-archives/xml-fallback/10758-apply-retry.log", + "/home/jmgasper/.factory/missions/6a38fff2-4c64-45c7-b2ae-b705da0ac3d1/evidence/followup-content-archives/xml-fallback/comparison-summary.json" + ] + }, + { + "id": "VAL-FOLLOWUP-007", + "status": "blocked", + "reason": "A valid already-imported XML-fallback candidate did exist in the current environment (13897, plus 9874 and 9892), with a placeholder description instead of converted Markdown. However, the targeted rerun could not be executed because importer apply-mode could not reach the configured challenge database, so no description backfill could be observed.", + "evidence": [ + "/home/jmgasper/.factory/missions/6a38fff2-4c64-45c7-b2ae-b705da0ac3d1/evidence/followup-content-archives/xml-fallback/preprobe.json", + "/home/jmgasper/.factory/missions/6a38fff2-4c64-45c7-b2ae-b705da0ac3d1/evidence/followup-content-archives/xml-fallback/13897-pre-challenge.json", + "/home/jmgasper/.factory/missions/6a38fff2-4c64-45c7-b2ae-b705da0ac3d1/evidence/followup-content-archives/xml-fallback/13897-post-challenge.json", + "/home/jmgasper/.factory/missions/6a38fff2-4c64-45c7-b2ae-b705da0ac3d1/evidence/followup-content-archives/xml-fallback/13897-targeted-rerun.log", + "/home/jmgasper/.factory/missions/6a38fff2-4c64-45c7-b2ae-b705da0ac3d1/evidence/followup-content-archives/xml-fallback/comparison-summary.json" + ] + }, + { + "id": "VAL-FOLLOWUP-008", + "status": "blocked", + "reason": "Current legacy-to-Markdown conversion evidence for 10758 and 13897 is user-facing and strips XML wrappers plus hidden/internal indicators, but persisted XML-fallback descriptions could not be validated end-to-end because both create-path and targeted-rerun apply-mode were blocked by challenge database connectivity.", + "evidence": [ + "/home/jmgasper/.factory/missions/6a38fff2-4c64-45c7-b2ae-b705da0ac3d1/evidence/followup-content-archives/xml-fallback/preprobe.json", + "/home/jmgasper/.factory/missions/6a38fff2-4c64-45c7-b2ae-b705da0ac3d1/evidence/followup-content-archives/xml-fallback/10758-apply.log", + "/home/jmgasper/.factory/missions/6a38fff2-4c64-45c7-b2ae-b705da0ac3d1/evidence/followup-content-archives/xml-fallback/13897-targeted-rerun.log", + "/home/jmgasper/.factory/missions/6a38fff2-4c64-45c7-b2ae-b705da0ac3d1/evidence/followup-content-archives/xml-fallback/comparison-summary.json" + ] + } + ], + "toolsUsed": [ + "node", + "curl", + "python" + ], + "commands": [ + { + "command": "curl -sS \"https://api.topcoder-dev.com/v6/challenges?legacyId=10758\" && curl -sS \"https://api.topcoder-dev.com/v6/challenges/a15cbb04-a0d3-4647-85bd-23d8d11e9f3f\"", + "exitCode": 0, + "observation": "Captured pre-run challenge API snapshots: round 10758 had no existing v6 challenge and challenge a15cbb04-a0d3-4647-85bd-23d8d11e9f3f still had the placeholder description." + }, + { + "command": "node ", + "exitCode": 0, + "observation": "Verified 10758 resolves to component_text Markdown fallback, 13897 also currently resolves to component_text Markdown (not preserve-with-no-source), and found three already-imported mismatch candidates: 9874, 9892, and 13897." + }, + { + "command": "node data-migration/src/scripts/importHistoricalMarathonMatches.js --apply --round-id 10758", + "exitCode": 1, + "observation": "Create-path apply failed before writes: Prisma could not reach topcoder-services.ci8xwsszszsw.us-east-1.rds.amazonaws.com:5432 while resolving challenge type metadata." + }, + { + "command": "node data-migration/src/scripts/importHistoricalMarathonMatches.js --apply --round-id 10758 # retry", + "exitCode": 1, + "observation": "Single non-disruptive retry failed with the same unreachable challenge database error; 10758 remained absent afterward." + }, + { + "command": "node data-migration/src/scripts/importHistoricalMarathonMatches.js --apply --targeted-rerun --round-id 13897 --challenge-id a15cbb04-a0d3-4647-85bd-23d8d11e9f3f", + "exitCode": 1, + "observation": "Targeted rerun failed before any patch/archive work for the same unreachable challenge database reason; 13897 description remained unchanged." + }, + { + "command": "curl -sS \"https://api.topcoder-dev.com/v6/challenges?legacyId=10758\" && curl -sS \"https://api.topcoder-dev.com/v6/challenges/a15cbb04-a0d3-4647-85bd-23d8d11e9f3f\"", + "exitCode": 0, + "observation": "Post-attempt snapshots confirmed no challenge was created for 10758 and 13897 still exposed the same placeholder description." + }, + { + "command": "python3 ", + "exitCode": 0, + "observation": "Wrote comparison-summary.json showing 10758 absent before/after, 13897 unchanged before/after, archive directory still empty, and the same database-connectivity blocker on all apply-mode attempts." + } + ], + "frictions": [ + "The assigned preserve fixture assumption for round 13897 was stale in the current export set: component 9378 now converts to usable Markdown, so 13897 behaves like a fallback-update candidate rather than a preserve-no-source fixture.", + "Public challenge listing only surfaced the imported legacy fixtures once the search was constrained to status=COMPLETED; the default list response did not expose the needed historical legacyId rows." + ], + "blockers": [ + "Importer apply-mode could not reach the configured challenge database at topcoder-services.ci8xwsszszsw.us-east-1.rds.amazonaws.com:5432. This blocked both the 10758 create-path apply and the 13897 targeted rerun before any writes or archive generation could occur." + ] +} diff --git a/.factory/validation/followup-content-archives/user-testing/synthesis.json b/.factory/validation/followup-content-archives/user-testing/synthesis.json new file mode 100644 index 0000000..3d5775f --- /dev/null +++ b/.factory/validation/followup-content-archives/user-testing/synthesis.json @@ -0,0 +1,29 @@ +{ + "milestone": "followup-content-archives", + "round": 3, + "status": "pass", + "assertionsSummary": { + "total": 1, + "passed": 1, + "failed": 0, + "blocked": 0 + }, + "passedAssertions": [ + "VAL-FOLLOWUP-005" + ], + "failedAssertions": [], + "blockedAssertions": [], + "appliedUpdates": [ + { + "target": "user-testing.md", + "description": "Recorded that follow-up round 3 resolved the no-source preserve gap by creating round 17391 as challenge b983de6f-cc7f-463e-867c-87e54f3b72f1 and then proving targeted rerun preserves its placeholder description while backfilling 248 local archives and submission URLs.", + "source": "flow-report" + }, + { + "target": "user-testing.md", + "description": "Documented round 17391 as the shared-environment no-source preserve fixture for VAL-FOLLOWUP-005 and noted the non-blocking provisional-score data issue encountered during its initial create apply.", + "source": "flow-report" + } + ], + "previousRound": ".factory/validation/followup-content-archives/user-testing/synthesis.round2.json" +} diff --git a/.factory/validation/followup-content-archives/user-testing/synthesis.round1.json b/.factory/validation/followup-content-archives/user-testing/synthesis.round1.json new file mode 100644 index 0000000..0109a8e --- /dev/null +++ b/.factory/validation/followup-content-archives/user-testing/synthesis.round1.json @@ -0,0 +1,58 @@ +{ + "milestone": "followup-content-archives", + "round": 1, + "status": "fail", + "assertionsSummary": { + "total": 8, + "passed": 1, + "failed": 0, + "blocked": 7 + }, + "passedAssertions": [ + "VAL-FOLLOWUP-003" + ], + "failedAssertions": [], + "blockedAssertions": [ + { + "id": "VAL-FOLLOWUP-001", + "blockedBy": "Positive targeted rerun for round 17948 could not execute because apply-mode importer calls from this session could not reach topcoder-services.ci8xwsszszsw.us-east-1.rds.amazonaws.com:5432." + }, + { + "id": "VAL-FOLLOWUP-002", + "blockedBy": "Archive/url backfill could not be exercised because targeted-rerun apply attempts were blocked by unreachable challenge DB connectivity, and the submissions API still does not expose persisted submission.url values." + }, + { + "id": "VAL-FOLLOWUP-004", + "blockedBy": "Patch-only positive-path behavior could not be proven because valid targeted-rerun apply attempts were blocked before importer writes by unreachable challenge DB connectivity." + }, + { + "id": "VAL-FOLLOWUP-005", + "blockedBy": "The assumed preserve fixture 13897 no longer matches the no-source precondition because current legacy lookup resolves usable component_text Markdown, and apply-mode targeted rerun was also blocked by unreachable challenge DB connectivity." + }, + { + "id": "VAL-FOLLOWUP-006", + "blockedBy": "Create-path validation for round 10758 was blocked because the importer could not reach topcoder-services.ci8xwsszszsw.us-east-1.rds.amazonaws.com:5432; the round remained absent before and after both apply attempts." + }, + { + "id": "VAL-FOLLOWUP-007", + "blockedBy": "Already-imported XML-fallback rerun candidates (9874, 9892, 13897) exist with placeholder descriptions, but targeted rerun could not be exercised because apply-mode importer access to the challenge DB was unavailable." + }, + { + "id": "VAL-FOLLOWUP-008", + "blockedBy": "Legacy conversion evidence for 10758 and 13897 is user-facing and strips XML wrappers, but persisted XML-fallback descriptions could not be validated end-to-end because create-path and targeted-rerun apply-mode were blocked by challenge DB connectivity." + } + ], + "appliedUpdates": [ + { + "target": "user-testing.md", + "description": "Documented the current follow-up user-testing blocker: apply-mode importer runs from this worker session cannot reach topcoder-services.ci8xwsszszsw.us-east-1.rds.amazonaws.com:5432, so positive live assertions remain blocked until connectivity is restored.", + "source": "flow-report" + }, + { + "target": "user-testing.md", + "description": "Recorded follow-up fixture drift: 13897 now resolves to usable component_text Markdown and is a targeted-rerun XML-fallback candidate; current imported fallback candidates include 9874, 9892, and 13897.", + "source": "flow-report" + } + ], + "previousRound": null +} diff --git a/.factory/validation/followup-content-archives/user-testing/synthesis.round2.json b/.factory/validation/followup-content-archives/user-testing/synthesis.round2.json new file mode 100644 index 0000000..161da03 --- /dev/null +++ b/.factory/validation/followup-content-archives/user-testing/synthesis.round2.json @@ -0,0 +1,45 @@ +{ + "milestone": "followup-content-archives", + "round": 2, + "status": "fail", + "assertionsSummary": { + "total": 8, + "passed": 7, + "failed": 0, + "blocked": 1 + }, + "passedAssertions": [ + "VAL-FOLLOWUP-001", + "VAL-FOLLOWUP-002", + "VAL-FOLLOWUP-003", + "VAL-FOLLOWUP-004", + "VAL-FOLLOWUP-006", + "VAL-FOLLOWUP-007", + "VAL-FOLLOWUP-008" + ], + "failedAssertions": [], + "blockedAssertions": [ + { + "id": "VAL-FOLLOWUP-005", + "blockedBy": "No already-imported exported Marathon Match challenge currently satisfies the no-source preserve precondition in the shared environment/export set; export-wide search found 237 no-source Marathon Match rounds in /mnt/Informix, but none are imported." + } + ], + "appliedUpdates": [ + { + "target": "user-testing.md", + "description": "Documented that follow-up round 2 restored positive apply-mode validation: raw-HTML targeted reruns now pass on round 17948, XML-fallback create-path validation now passes on round 10758, and XML-fallback targeted reruns now pass on round 13897.", + "source": "flow-report" + }, + { + "target": "user-testing.md", + "description": "Recorded current fixture availability: round 10015 is not imported in the shared dev environment, round 10758 is now imported as challenge 324a7cf2-f967-4578-9012-55be2730e2b0, and round 13897 is no longer a placeholder-description candidate after the successful rerun patch.", + "source": "flow-report" + }, + { + "target": "user-testing.md", + "description": "Recorded the remaining no-source preserve blocker: export-wide search found 237 no-source Marathon Match rounds, but none are already imported in the shared environment, so VAL-FOLLOWUP-005 currently lacks a valid fixture.", + "source": "flow-report" + } + ], + "previousRound": ".factory/validation/followup-content-archives/user-testing/synthesis.round1.json" +} diff --git a/.factory/validation/misc-importer-hardening/scrutiny/reviews/mm-importer-missing-submission-points-hardening.json b/.factory/validation/misc-importer-hardening/scrutiny/reviews/mm-importer-missing-submission-points-hardening.json new file mode 100644 index 0000000..9f706d2 --- /dev/null +++ b/.factory/validation/misc-importer-hardening/scrutiny/reviews/mm-importer-missing-submission-points-hardening.json @@ -0,0 +1,15 @@ +{ + "featureId": "mm-importer-missing-submission-points-hardening", + "reviewedAt": "2026-04-15T04:56:42Z", + "commitId": "f3b0a88a188e4a7d8bb151eace6ee7a7d494b2a5", + "transcriptSkeletonReviewed": true, + "diffReviewed": true, + "status": "pass", + "codeReview": { + "summary": "The malformed provisional-score hardening is appropriately scoped: it converts missing/non-numeric legacy `submission_points` values into deterministic skipped-artifact records while continuing to import valid provisional scores and preserving rerun safety for already-imported submissions.", + "issues": [] + }, + "sharedStateObservations": [], + "addressesFailureFrom": null, + "summary": "Reviewed the worker handoff, transcript skeleton, and malformed provisional-score hardening changes for `mm-importer-missing-submission-points-hardening` at commit `f3b0a88a188e4a7d8bb151eace6ee7a7d494b2a5`. Result: pass. The feature replaces the previous crash path with explicit `malformed-provisional-score` skip reporting without weakening valid provisional-score imports or rerun behavior." +} diff --git a/.factory/validation/misc-importer-hardening/scrutiny/synthesis.json b/.factory/validation/misc-importer-hardening/scrutiny/synthesis.json new file mode 100644 index 0000000..28f2902 --- /dev/null +++ b/.factory/validation/misc-importer-hardening/scrutiny/synthesis.json @@ -0,0 +1,33 @@ +{ + "milestone": "misc-importer-hardening", + "round": 1, + "status": "pass", + "validatorsRun": { + "test": { + "passed": true, + "command": "source \"$HOME/.config/nvm/nvm.sh\" && (cd \"/home/jmgasper/Documents/Git/v6/challenge-api-v6/data-migration\" && nvm use 18.19.0 >/dev/null && pnpm test --maxWorkers=16 --testPathIgnorePatterns test/challenge-migration.test.js)", + "exitCode": 0 + }, + "typecheck": { + "passed": true, + "command": "echo \"No dedicated typecheck command for this JavaScript importer surface\"", + "exitCode": 0 + }, + "lint": { + "passed": true, + "command": "source \"$HOME/.config/nvm/nvm.sh\" && (cd \"/home/jmgasper/Documents/Git/v6/challenge-api-v6/data-migration\" && nvm use 18.19.0 >/dev/null && pnpm lint)", + "exitCode": 0 + } + }, + "reviewsSummary": { + "total": 1, + "passed": 1, + "failed": 0, + "failedFeatures": [] + }, + "blockingIssues": [], + "appliedUpdates": [], + "suggestedGuidanceUpdates": [], + "rejectedObservations": [], + "previousRound": null +} diff --git a/.factory/validation/misc-importer-hardening/user-testing/flows/round-17391-malformed-provisional.json b/.factory/validation/misc-importer-hardening/user-testing/flows/round-17391-malformed-provisional.json new file mode 100644 index 0000000..108b287 --- /dev/null +++ b/.factory/validation/misc-importer-hardening/user-testing/flows/round-17391-malformed-provisional.json @@ -0,0 +1,127 @@ +{ + "groupId": "round-17391-malformed-provisional", + "surface": "importer CLI + API verification", + "testedAt": "2026-04-15T05:47:00.778319+00:00", + "isolation": { + "missionDir": "/home/jmgasper/.factory/missions/6a38fff2-4c64-45c7-b2ae-b705da0ac3d1", + "repoRoot": "/home/jmgasper/Documents/Git/v6/challenge-api-v6", + "flowReportPath": "/home/jmgasper/Documents/Git/v6/challenge-api-v6/.factory/validation/misc-importer-hardening/user-testing/flows/round-17391-malformed-provisional.json", + "evidenceDir": "/home/jmgasper/.factory/missions/6a38fff2-4c64-45c7-b2ae-b705da0ac3d1/evidence/misc-importer-hardening/round-17391-malformed-provisional", + "legacyRoundId": "17391", + "challengeId": "b983de6f-cc7f-463e-867c-87e54f3b72f1", + "surface": "importer CLI + API verification", + "validationStateUpdate": "do-not-update-validation-state-json", + "syntheticAssertionId": "MISC-HARDENING-SMOKE-001", + "firstSkippedFile": "/home/jmgasper/.factory/missions/6a38fff2-4c64-45c7-b2ae-b705da0ac3d1/evidence/misc-importer-hardening/round-17391-malformed-provisional/first-apply-skipped.json", + "secondSkippedFile": "/home/jmgasper/.factory/missions/6a38fff2-4c64-45c7-b2ae-b705da0ac3d1/evidence/misc-importer-hardening/round-17391-malformed-provisional/second-apply-skipped.json" + }, + "toolsUsed": [ + "node", + "python3", + "challenge-api", + "review-api" + ], + "commandsRun": [ + "source \"$HOME/.config/nvm/nvm.sh\" && cd \"/home/jmgasper/Documents/Git/v6/challenge-api-v6\" && set -a && source .env.importer.local && set +a && nvm use >/dev/null && REVIEW_TOKEN=$(node get_token.js | tail -n 1) && python3 ", + "source \"$HOME/.config/nvm/nvm.sh\" && cd \"/home/jmgasper/Documents/Git/v6/challenge-api-v6\" && set -a && source .env.importer.local && set +a && nvm use 18.19.0 >/dev/null && node data-migration/src/scripts/importHistoricalMarathonMatches.js --apply --round-id 17391 --skipped-file /home/jmgasper/.factory/missions/6a38fff2-4c64-45c7-b2ae-b705da0ac3d1/evidence/misc-importer-hardening/round-17391-malformed-provisional/first-apply-skipped.json", + "source \"$HOME/.config/nvm/nvm.sh\" && cd \"/home/jmgasper/Documents/Git/v6/challenge-api-v6\" && set -a && source .env.importer.local && set +a && nvm use >/dev/null && REVIEW_TOKEN=$(node get_token.js | tail -n 1) && python3 ", + "source \"$HOME/.config/nvm/nvm.sh\" && cd \"/home/jmgasper/Documents/Git/v6/challenge-api-v6\" && set -a && source .env.importer.local && set +a && nvm use 18.19.0 >/dev/null && node data-migration/src/scripts/importHistoricalMarathonMatches.js --apply --round-id 17391 --skipped-file /home/jmgasper/.factory/missions/6a38fff2-4c64-45c7-b2ae-b705da0ac3d1/evidence/misc-importer-hardening/round-17391-malformed-provisional/second-apply-skipped.json", + "source \"$HOME/.config/nvm/nvm.sh\" && cd \"/home/jmgasper/Documents/Git/v6/challenge-api-v6\" && set -a && source .env.importer.local && set +a && nvm use >/dev/null && REVIEW_TOKEN=$(node get_token.js | tail -n 1) && python3 ", + "python3 " + ], + "assertions": [ + { + "id": "MISC-HARDENING-SMOKE-001", + "title": "Round 17391 malformed provisional-score hardening survives apply, reports skips, and stays rerun-stable", + "status": "pass", + "contractAssertionIds": [], + "validationStateUpdate": "do-not-update-validation-state-json", + "steps": [ + { + "action": "Capture pre-apply API snapshots for challenge, phases, resources, submissions, and review summations on challenge b983de6f-cc7f-463e-867c-87e54f3b72f1.", + "expected": "Baseline identity sets are readable before rerun comparison.", + "observed": "Challenge lookup returned exactly one challenge with stable three-phase roster; API snapshots showed 112 submitter resources, 248 submissions, and 168 review summations (16 final, 152 provisional) with zero duplicate identity signals." + }, + { + "action": "Run standard apply for round 17391 with Node 18.19.0 and an isolated skipped-file path.", + "expected": "Apply completes successfully instead of aborting on malformed provisional-score data.", + "observed": "First apply exited 0 with APPLY_RECORD status=existing and APPLY_SUMMARY errors=0." + }, + { + "action": "Inspect APPLY_RECORD / APPLY_SUMMARY and the first skipped artifact for malformed provisional-score handling.", + "expected": "The malformed row for submission -403380560001 / coder 22836077 is explicitly skipped with the importer's exact reason code rather than crashing the run.", + "observed": "The skipped artifact recorded round 17391 / member 22836077 / legacySubmissionId -403380560001 with reasonCode=malformed-provisional-score; first-run skipped artifact reason codes were ['finalist-without-attachable-submission', 'malformed-provisional-score', 'missing-member'] with 645 total records." + }, + { + "action": "Verify that well-formed provisional-score rows still reconcile during the same apply.", + "expected": "Well-formed provisional rows are still imported or reconciled normally while malformed rows are reported separately.", + "observed": "First apply provisional reconciliation balanced exactly to 437 legacy non-example provisional rows: imported/reconciled=152 (alreadyPresent=152, created=0), malformedSkipped=96, missingMemberSkipped=189." + }, + { + "action": "Capture post-first snapshots, rerun standard apply with a second isolated skipped-file path, and compare post-first vs post-second identity sets.", + "expected": "Second apply exits successfully and challenge/phase/resource/submission/review identity sets stay stable without duplicates.", + "observed": "Second apply exited 0 with the same reconciliation totals, the two skipped artifacts were byte-for-byte equivalent as JSON content, and post-first vs post-second challenge ids / phase ids / resource tuples / submission legacy ids / review ids / final review keys / provisional review keys were all stable (sha256s identical in verification-summary.json)." + } + ], + "evidence": { + "preSnapshots": [ + "misc-importer-hardening/round-17391-malformed-provisional/pre-challenge-list.json", + "misc-importer-hardening/round-17391-malformed-provisional/pre-challenge-detail.json", + "misc-importer-hardening/round-17391-malformed-provisional/pre-resources.json", + "misc-importer-hardening/round-17391-malformed-provisional/pre-submissions.json", + "misc-importer-hardening/round-17391-malformed-provisional/pre-review-summations.json", + "misc-importer-hardening/round-17391-malformed-provisional/pre-snapshot-summary.json" + ], + "firstApply": { + "stdout": "misc-importer-hardening/round-17391-malformed-provisional/first-apply.stdout.log", + "stderr": "misc-importer-hardening/round-17391-malformed-provisional/first-apply.stderr.log", + "exitCode": "misc-importer-hardening/round-17391-malformed-provisional/first-apply.exitcode.txt", + "skippedArtifact": "misc-importer-hardening/round-17391-malformed-provisional/first-apply-skipped.json" + }, + "postFirstSnapshots": [ + "misc-importer-hardening/round-17391-malformed-provisional/post-first-challenge-list.json", + "misc-importer-hardening/round-17391-malformed-provisional/post-first-challenge-detail.json", + "misc-importer-hardening/round-17391-malformed-provisional/post-first-resources.json", + "misc-importer-hardening/round-17391-malformed-provisional/post-first-submissions.json", + "misc-importer-hardening/round-17391-malformed-provisional/post-first-review-summations.json", + "misc-importer-hardening/round-17391-malformed-provisional/post-first-snapshot-summary.json" + ], + "secondApply": { + "stdout": "misc-importer-hardening/round-17391-malformed-provisional/second-apply.stdout.log", + "stderr": "misc-importer-hardening/round-17391-malformed-provisional/second-apply.stderr.log", + "exitCode": "misc-importer-hardening/round-17391-malformed-provisional/second-apply.exitcode.txt", + "skippedArtifact": "misc-importer-hardening/round-17391-malformed-provisional/second-apply-skipped.json" + }, + "postSecondSnapshots": [ + "misc-importer-hardening/round-17391-malformed-provisional/post-second-challenge-list.json", + "misc-importer-hardening/round-17391-malformed-provisional/post-second-challenge-detail.json", + "misc-importer-hardening/round-17391-malformed-provisional/post-second-resources.json", + "misc-importer-hardening/round-17391-malformed-provisional/post-second-submissions.json", + "misc-importer-hardening/round-17391-malformed-provisional/post-second-review-summations.json", + "misc-importer-hardening/round-17391-malformed-provisional/post-second-snapshot-summary.json" + ], + "verificationSummary": "misc-importer-hardening/round-17391-malformed-provisional/verification-summary.json", + "network": [ + "GET https://api.topcoder-dev.com/v6/challenges?legacyId=17391&perPage=100 -> 200", + "GET https://api.topcoder-dev.com/v6/challenges/b983de6f-cc7f-463e-867c-87e54f3b72f1 -> 200", + "GET https://api.topcoder-dev.com/v6/resources?challengeId=b983de6f-cc7f-463e-867c-87e54f3b72f1&perPage=3000 -> 200", + "GET https://api.topcoder-dev.com/v6/submissions?challengeId=b983de6f-cc7f-463e-867c-87e54f3b72f1&perPage=3000 -> 200", + "GET https://api.topcoder-dev.com/v6/reviewSummations?challengeId=b983de6f-cc7f-463e-867c-87e54f3b72f1&perPage=3000 -> 200 (bearer token)" + ] + }, + "issues": null + } + ], + "frictions": [ + { + "description": "Round 17391 was already partially imported in the shared environment before this smoke test, so the standard apply validation exercised the existing/reconciliation path rather than a pristine create path.", + "resolved": true, + "resolution": "Captured pre/post API snapshots and relied on APPLY_RECORD reconciliation counters plus rerun identity-set diffs to validate the hardening on the live shared fixture.", + "affectedAssertions": [ + "MISC-HARDENING-SMOKE-001" + ] + } + ], + "blockers": [], + "summary": "MISC-HARDENING-SMOKE-001 passed. Both standard apply runs for round 17391 exited 0, the malformed provisional row for submission -403380560001 / coder 22836077 was explicitly reported with reasonCode `malformed-provisional-score`, well-formed provisional rows still reconciled successfully in the same run, and the second rerun preserved stable challenge/phase/resource/submission/review identity sets; validation-state.json should not be updated from this synthetic smoke flow." +} diff --git a/.factory/validation/misc-importer-hardening/user-testing/synthesis.json b/.factory/validation/misc-importer-hardening/user-testing/synthesis.json new file mode 100644 index 0000000..2c3cd8e --- /dev/null +++ b/.factory/validation/misc-importer-hardening/user-testing/synthesis.json @@ -0,0 +1,34 @@ +{ + "milestone": "misc-importer-hardening", + "round": 2, + "status": "pass", + "assertionsSummary": { + "total": 0, + "passed": 0, + "failed": 0, + "blocked": 0 + }, + "passedAssertions": [], + "failedAssertions": [], + "blockedAssertions": [], + "notes": [ + "No validation-contract assertion IDs are mapped to misc-importer-hardening, so validation-state.json remains unchanged.", + "Validation for round 2 used synthetic smoke flow MISC-HARDENING-SMOKE-001 against shared fixture round 17391." + ], + "manualChecks": [ + { + "id": "MISC-HARDENING-SMOKE-001", + "status": "pass", + "flowReport": ".factory/validation/misc-importer-hardening/user-testing/flows/round-17391-malformed-provisional.json", + "summary": "Both standard apply runs for round 17391 exited 0; submission -403380560001 for coder 22836077 was reported with reasonCode malformed-provisional-score; 437 provisional rows reconciled as 152 imported/reconciled, 96 malformed-provisional skips, and 189 missing-member skips; second-run challenge/phase/resource/submission/review identity sets remained stable." + } + ], + "appliedUpdates": [ + { + "target": "user-testing.md", + "description": "Recorded that round 17391 now serves as a shared malformed-provisional-score smoke fixture: standard apply reruns exit 0, the malformed row is reported with reasonCode malformed-provisional-score, and the current provisional partition is 152 imported/reconciled, 96 malformed-provisional skips, and 189 missing-member skips.", + "source": "flow-report" + } + ], + "previousRound": ".factory/validation/misc-importer-hardening/user-testing/synthesis.round1.json" +} diff --git a/.factory/validation/misc-importer-hardening/user-testing/synthesis.round1.json b/.factory/validation/misc-importer-hardening/user-testing/synthesis.round1.json new file mode 100644 index 0000000..051b381 --- /dev/null +++ b/.factory/validation/misc-importer-hardening/user-testing/synthesis.round1.json @@ -0,0 +1,16 @@ +{ + "milestone": "misc-importer-hardening", + "round": 1, + "status": "pass", + "assertionsSummary": { + "total": 0, + "passed": 0, + "failed": 0, + "blocked": 0 + }, + "passedAssertions": [], + "failedAssertions": [], + "blockedAssertions": [], + "appliedUpdates": [], + "previousRound": null +} diff --git a/.factory/validation/misc-readme-docs/scrutiny/reviews/mm-importer-readme-rerun-steps.json b/.factory/validation/misc-readme-docs/scrutiny/reviews/mm-importer-readme-rerun-steps.json new file mode 100644 index 0000000..dd6a130 --- /dev/null +++ b/.factory/validation/misc-readme-docs/scrutiny/reviews/mm-importer-readme-rerun-steps.json @@ -0,0 +1,15 @@ +{ + "featureId": "mm-importer-readme-rerun-steps", + "reviewedAt": "2026-04-15T23:00:08Z", + "commitId": "a745e8854e3b1fa6f1b4fbd48a2b0ee2dbac28e6", + "transcriptSkeletonReviewed": true, + "diffReviewed": false, + "status": "pass", + "codeReview": { + "summary": "The README rerun guidance on HEAD is complete and correctly scoped for this docs-only feature: it documents both standard full apply reruns and targeted rerun patch mode, includes the explicit `--apply --targeted-rerun --round-id --challenge-id ` invocation, explains `SUBMISSION_ARCHIVE_DIR` setup and challenge-id lookup by `legacyId`, preserves the approved description-source precedence, and calls out idempotent rerun behavior plus `reasonCode=malformed-provisional-score` handling.", + "issues": [] + }, + "sharedStateObservations": [], + "addressesFailureFrom": null, + "summary": "Reviewed the latest feature handoff/transcript evidence and the current `data-migration/MARATHON_README.md` content at commit `a745e8854e3b1fa6f1b4fbd48a2b0ee2dbac28e6`. Result: pass. The current README satisfies the required rerun/operator guidance for the historical Marathon Match importer. Direct `git diff` review was unavailable in this worker session because git CLI reads are confirmation-blocked, so the review used current file state plus worker handoff evidence." +} diff --git a/.factory/validation/misc-readme-docs/scrutiny/synthesis.json b/.factory/validation/misc-readme-docs/scrutiny/synthesis.json new file mode 100644 index 0000000..bc544d0 --- /dev/null +++ b/.factory/validation/misc-readme-docs/scrutiny/synthesis.json @@ -0,0 +1,33 @@ +{ + "milestone": "misc-readme-docs", + "round": 2, + "status": "pass", + "validatorsRun": { + "test": { + "passed": true, + "command": "source \"$HOME/.config/nvm/nvm.sh\" && (cd \"/home/jmgasper/Documents/Git/v6/challenge-api-v6/data-migration\" && nvm use 18.19.0 >/dev/null && pnpm test --maxWorkers=16 --testPathIgnorePatterns test/challenge-migration.test.js)", + "exitCode": 0 + }, + "typecheck": { + "passed": true, + "command": "echo \"No dedicated typecheck command for this JavaScript importer surface\"", + "exitCode": 0 + }, + "lint": { + "passed": true, + "command": "source \"$HOME/.config/nvm/nvm.sh\" && (cd \"/home/jmgasper/Documents/Git/v6/challenge-api-v6/data-migration\" && nvm use 18.19.0 >/dev/null && pnpm lint)", + "exitCode": 0 + } + }, + "reviewsSummary": { + "total": 1, + "passed": 1, + "failed": 0, + "failedFeatures": [] + }, + "blockingIssues": [], + "appliedUpdates": [], + "suggestedGuidanceUpdates": [], + "rejectedObservations": [], + "previousRound": ".factory/validation/misc-readme-docs/scrutiny/synthesis.round1.json" +} diff --git a/.factory/validation/misc-readme-docs/scrutiny/synthesis.round1.json b/.factory/validation/misc-readme-docs/scrutiny/synthesis.round1.json new file mode 100644 index 0000000..7d100d4 --- /dev/null +++ b/.factory/validation/misc-readme-docs/scrutiny/synthesis.round1.json @@ -0,0 +1,46 @@ +{ + "milestone": "misc-readme-docs", + "round": 1, + "status": "pass", + "validatorsRun": { + "test": { + "passed": true, + "command": "source \"$HOME/.config/nvm/nvm.sh\" && (cd \"/home/jmgasper/Documents/Git/v6/challenge-api-v6/data-migration\" && nvm use 18.19.0 >/dev/null && pnpm test --maxWorkers=16 --testPathIgnorePatterns test/challenge-migration.test.js)", + "exitCode": 0 + }, + "typecheck": { + "passed": true, + "command": "echo \"No dedicated typecheck command for this JavaScript importer surface\"", + "exitCode": 0 + }, + "lint": { + "passed": true, + "command": "source \"$HOME/.config/nvm/nvm.sh\" && (cd \"/home/jmgasper/Documents/Git/v6/challenge-api-v6/data-migration\" && nvm use 18.19.0 >/dev/null && pnpm lint)", + "exitCode": 0 + } + }, + "reviewsSummary": { + "total": 1, + "passed": 1, + "failed": 0, + "failedFeatures": [] + }, + "blockingIssues": [], + "appliedUpdates": [], + "suggestedGuidanceUpdates": [ + { + "target": "skill", + "suggestion": "Update `scrutiny-validator` to describe a worker-safe fallback when Task-based reviewer subagents cannot launch in mission worker sessions, such as performing the review inline and writing the review JSON directly.", + "evidence": "Both Task attempts in this run (`scrutiny-feature-reviewer` and a fallback `worker` review task) exited immediately with `insufficient permission to proceed`, so the milestone review had to be completed inline from the current file plus worker handoff evidence.", + "isSystemic": true + }, + { + "target": "skill", + "suggestion": "Document how validation workers should finish required `.factory/validation/...` commit steps when git CLI reads/writes are confirmation-blocked, or explicitly delegate the commit to the orchestrator in that scenario.", + "evidence": "This worker session could not execute read-only git commands (`git status`, `git diff`, `git ls-files`) because the mission environment required confirmation that worker sessions cannot provide, which prevents the commit flow currently mandated by `scrutiny-validator` and `mission-worker-base`.", + "isSystemic": true + } + ], + "rejectedObservations": [], + "previousRound": null +} diff --git a/.factory/validation/misc-readme-docs/user-testing/flows/readme-rerun-operator-guidance.json b/.factory/validation/misc-readme-docs/user-testing/flows/readme-rerun-operator-guidance.json new file mode 100644 index 0000000..efe9649 --- /dev/null +++ b/.factory/validation/misc-readme-docs/user-testing/flows/readme-rerun-operator-guidance.json @@ -0,0 +1,92 @@ +{ + "groupId": "readme-rerun-operator-guidance", + "surface": "importer CLI + API verification", + "testedAt": "2026-04-15T23:31:16.760760+00:00", + "isolation": { + "missionDir": "/home/jmgasper/.factory/missions/6a38fff2-4c64-45c7-b2ae-b705da0ac3d1", + "repoRoot": "/home/jmgasper/Documents/Git/v6/challenge-api-v6", + "flowReportPath": "/home/jmgasper/Documents/Git/v6/challenge-api-v6/.factory/validation/misc-readme-docs/user-testing/flows/readme-rerun-operator-guidance.json", + "evidenceDir": "/home/jmgasper/.factory/missions/6a38fff2-4c64-45c7-b2ae-b705da0ac3d1/evidence/misc-readme-docs/readme-rerun-operator-guidance", + "readmeUnderTest": "/home/jmgasper/Documents/Git/v6/challenge-api-v6/data-migration/MARATHON_README.md", + "completedImplementationFeature": "mm-importer-readme-rerun-steps", + "legacyRoundId": "17391", + "expectedChallengeId": "b983de6f-cc7f-463e-867c-87e54f3b72f1", + "surface": "importer CLI + API verification", + "validationStateUpdate": "do-not-update-validation-state-json", + "syntheticAssertionId": "MISC-README-DOCS-SMOKE-001", + "contractAssertionIds": [], + "readOnlyBoundary": "Only local file reads, node --help, curl GET requests, and evidence/report file creation were used." + }, + "toolsUsed": [ + "Read", + "node", + "curl", + "jq", + "python3" + ], + "commandsRun": [ + "cp \"/home/jmgasper/Documents/Git/v6/challenge-api-v6/data-migration/MARATHON_README.md\" \"/home/jmgasper/.factory/missions/6a38fff2-4c64-45c7-b2ae-b705da0ac3d1/evidence/misc-readme-docs/readme-rerun-operator-guidance/MARATHON_README.md\"", + "python3 ", + "node \"/home/jmgasper/Documents/Git/v6/challenge-api-v6/data-migration/src/scripts/importHistoricalMarathonMatches.js\" --help", + "curl -sS -D \"/home/jmgasper/.factory/missions/6a38fff2-4c64-45c7-b2ae-b705da0ac3d1/evidence/misc-readme-docs/readme-rerun-operator-guidance/challenge-17391-lookup.headers.txt\" \"https://api.topcoder-dev.com/v6/challenges?legacyId=17391\" -o \"/home/jmgasper/.factory/missions/6a38fff2-4c64-45c7-b2ae-b705da0ac3d1/evidence/misc-readme-docs/readme-rerun-operator-guidance/challenge-17391-lookup.json\" -w \"%{http_code}\\n\"", + "curl -s \"https://api.topcoder-dev.com/v6/challenges?legacyId=17391\" | jq -r '.[0].id'", + "python3 " + ], + "assertions": [ + { + "id": "MISC-README-DOCS-SMOKE-001", + "title": "README rerun guidance is practical and verifiable from the documented read-only operator surface", + "status": "pass", + "contractAssertionIds": [], + "validationStateUpdate": "do-not-update-validation-state-json", + "steps": [ + { + "action": "Review the importer README for rerun operator guidance.", + "expected": "README covers standard rerun, targeted rerun patch mode, challenge-id lookup, SUBMISSION_ARCHIVE_DIR setup, description precedence, idempotent rerun expectations, and malformed provisional-score skip/report behavior.", + "observed": "The README snapshot contains all requested guidance: standard full apply rerun (line 236), targeted rerun patch mode (line 258), explicit legacyId challenge lookup via curl (line 275), SUBMISSION_ARCHIVE_DIR export/setup (line 283), description precedence problem_text HTML -> component_text Markdown -> preserve existing description (lines 300-302), idempotent rerun language for both standard and targeted reruns (lines 250 and 304), and malformed provisional-score skip/report behavior with reasonCode=malformed-provisional-score (line 253)." + }, + { + "action": "Check the importer help output in read-only mode.", + "expected": "node ... --help exits successfully and documents the CLI surface referenced by the README, including --apply, --dry-run, --round-id, --round-ids, --skipped-file, --targeted-rerun, and --challenge-id.", + "observed": "The help command exited 0 and the captured help text listed all required flags: --apply, --dry-run, --round-id, --round-ids, --skipped-file, --targeted-rerun, --challenge-id. The usage header and apply-mode section matched the README command surface." + }, + { + "action": "Follow the documented challenge-id lookup step for round 17391 using curl in read-only mode.", + "expected": "The shared fixture resolves to exactly one challenge and jq extracts challenge id b983de6f-cc7f-463e-867c-87e54f3b72f1.", + "observed": "GET /v6/challenges?legacyId=17391 returned HTTP 200 with a one-item JSON array; the only returned id was b983de6f-cc7f-463e-867c-87e54f3b72f1. The documented curl|jq pipeline exited 0 and printed b983de6f-cc7f-463e-867c-87e54f3b72f1." + }, + { + "action": "Assess whether an operator can prepare for rerun execution without extra undocumented steps.", + "expected": "The README should be sufficient for rerun preparation on the documented read-only surface.", + "observed": "Pass. The README was internally consistent with the live CLI help and shared-fixture challenge lookup, and this smoke run did not require any extra undocumented preparation step beyond the instructions already present in the README." + } + ], + "evidence": { + "readmeSnapshot": "misc-readme-docs/readme-rerun-operator-guidance/MARATHON_README.md", + "readmeTopicCheck": "misc-readme-docs/readme-rerun-operator-guidance/readme-topic-check.json", + "help": { + "stdout": "misc-readme-docs/readme-rerun-operator-guidance/importer-help.txt", + "stderr": "misc-readme-docs/readme-rerun-operator-guidance/importer-help.stderr.log", + "exitCode": "misc-readme-docs/readme-rerun-operator-guidance/importer-help.exitcode.txt", + "check": "misc-readme-docs/readme-rerun-operator-guidance/importer-help-check.json" + }, + "lookup": { + "headers": "misc-readme-docs/readme-rerun-operator-guidance/challenge-17391-lookup.headers.txt", + "body": "misc-readme-docs/readme-rerun-operator-guidance/challenge-17391-lookup.json", + "httpCode": "misc-readme-docs/readme-rerun-operator-guidance/challenge-17391-lookup.httpcode.txt", + "jqResult": "misc-readme-docs/readme-rerun-operator-guidance/challenge-17391-lookup-id.txt", + "jqStderr": "misc-readme-docs/readme-rerun-operator-guidance/challenge-17391-lookup-id.stderr.log", + "jqExitCode": "misc-readme-docs/readme-rerun-operator-guidance/challenge-17391-lookup-id.exitcode.txt", + "check": "misc-readme-docs/readme-rerun-operator-guidance/challenge-17391-lookup-check.json" + }, + "network": [ + "GET https://api.topcoder-dev.com/v6/challenges?legacyId=17391 -> 200" + ] + }, + "issues": null + } + ], + "frictions": [], + "blockers": [], + "summary": "MISC-README-DOCS-SMOKE-001 passed. The README includes the requested rerun-preparation guidance, the importer help output exposes the documented flags, and the read-only challenge lookup for round 17391 resolved exactly one challenge with id b983de6f-cc7f-463e-867c-87e54f3b72f1; validation-state.json was not updated." +} diff --git a/.factory/validation/misc-readme-docs/user-testing/synthesis.json b/.factory/validation/misc-readme-docs/user-testing/synthesis.json new file mode 100644 index 0000000..0cde8c8 --- /dev/null +++ b/.factory/validation/misc-readme-docs/user-testing/synthesis.json @@ -0,0 +1,28 @@ +{ + "milestone": "misc-readme-docs", + "round": 1, + "status": "pass", + "assertionsSummary": { + "total": 0, + "passed": 0, + "failed": 0, + "blocked": 0 + }, + "passedAssertions": [], + "failedAssertions": [], + "blockedAssertions": [], + "notes": [ + "No validation-contract assertion IDs are mapped to misc-readme-docs, so validation-state.json remains unchanged.", + "Validation for round 1 used synthetic smoke flow MISC-README-DOCS-SMOKE-001 against the importer README and documented read-only challenge lookup surface." + ], + "manualChecks": [ + { + "id": "MISC-README-DOCS-SMOKE-001", + "status": "pass", + "flowReport": ".factory/validation/misc-readme-docs/user-testing/flows/readme-rerun-operator-guidance.json", + "summary": "The README contained standard rerun and targeted-rerun steps, challenge-id lookup guidance, SUBMISSION_ARCHIVE_DIR setup, description precedence, idempotency notes, and malformed provisional-score reporting; `node ... --help` exposed the documented flags; and the documented curl lookup for round 17391 returned exactly one challenge id `b983de6f-cc7f-463e-867c-87e54f3b72f1`." + } + ], + "appliedUpdates": [], + "previousRound": null +} diff --git a/.factory/validation/misc-readme-docs/user-testing/synthesis.round1.json b/.factory/validation/misc-readme-docs/user-testing/synthesis.round1.json new file mode 100644 index 0000000..0cde8c8 --- /dev/null +++ b/.factory/validation/misc-readme-docs/user-testing/synthesis.round1.json @@ -0,0 +1,28 @@ +{ + "milestone": "misc-readme-docs", + "round": 1, + "status": "pass", + "assertionsSummary": { + "total": 0, + "passed": 0, + "failed": 0, + "blocked": 0 + }, + "passedAssertions": [], + "failedAssertions": [], + "blockedAssertions": [], + "notes": [ + "No validation-contract assertion IDs are mapped to misc-readme-docs, so validation-state.json remains unchanged.", + "Validation for round 1 used synthetic smoke flow MISC-README-DOCS-SMOKE-001 against the importer README and documented read-only challenge lookup surface." + ], + "manualChecks": [ + { + "id": "MISC-README-DOCS-SMOKE-001", + "status": "pass", + "flowReport": ".factory/validation/misc-readme-docs/user-testing/flows/readme-rerun-operator-guidance.json", + "summary": "The README contained standard rerun and targeted-rerun steps, challenge-id lookup guidance, SUBMISSION_ARCHIVE_DIR setup, description precedence, idempotency notes, and malformed provisional-score reporting; `node ... --help` exposed the documented flags; and the documented curl lookup for round 17391 returned exactly one challenge id `b983de6f-cc7f-463e-867c-87e54f3b72f1`." + } + ], + "appliedUpdates": [], + "previousRound": null +} diff --git a/config/default.js b/config/default.js index bafb8db..687c96c 100644 --- a/config/default.js +++ b/config/default.js @@ -83,6 +83,14 @@ module.exports = { ? process.env.TOPGEAR_BILLING_ACCOUNTS_ID.split(",") : [], + // billing accounts that can bypass challenge activation expiry/funds validation + IGNORED_CHALLENGE_ACTIVATION_BILLING_ACCOUNT_IDS: process.env + .IGNORED_CHALLENGE_ACTIVATION_BILLING_ACCOUNT_IDS + ? process.env.IGNORED_CHALLENGE_ACTIVATION_BILLING_ACCOUNT_IDS.split(",") + .map((billingAccountId) => billingAccountId.trim()) + .filter(Boolean) + : ["80000062"], + // health check timeout in milliseconds HEALTH_CHECK_TIMEOUT: process.env.HEALTH_CHECK_TIMEOUT || 3000, diff --git a/data-migration/MARATHON_README.md b/data-migration/MARATHON_README.md index d2e0516..090b628 100644 --- a/data-migration/MARATHON_README.md +++ b/data-migration/MARATHON_README.md @@ -17,9 +17,17 @@ The script can: - discover an existing v6 Marathon Match challenge and backfill missing data - create a new Marathon Match challenge and its standard phases when no safe match exists +- backfill canonical `isRated` challenge metadata from legacy `round.rated_ind` + so member rerating can skip explicit unrated rounds - reconcile submitter resources through the Resources API - import submission history, final scores, and provisional scores into the review database +- treat `long_component_state.points` as the authoritative public final score + when legacy `long_comp_result` score columns disagree with it + +When the review submission table exposes `systemFileName`, `virusScan`, and +`isFileSubmission`, submission-history reconciliation sets or backfills those +fields to the generated zip filename, `true`, and `true` respectively. The default mode is `--dry-run`. No writes happen unless `--apply` is provided. @@ -65,6 +73,10 @@ MEMBER_DB_SCHEMA=members REVIEW_DB_URL=postgresql://user:password@host:5432/review_db REVIEW_DB_SCHEMA=reviews +# optional; when set, standard apply and targeted rerun will write deterministic +# submission zip archives locally and populate reviews.submission.url +SUBMISSION_ARCHIVE_DIR=/tmp/mm-submission-archives + # required for apply mode resource reconciliation RESOURCES_API_URL=https://api.topcoder-dev.com/v5/resources AUTH0_URL=https://topcoder-dev.auth0.com @@ -98,6 +110,8 @@ For apply mode: - `DATABASE_URL` - `REVIEW_DB_URL` +- `SUBMISSION_ARCHIVE_DIR` if you want standard apply or targeted rerun to + materialize submission archives and populate `reviews.submission.url` - `RESOURCES_API_URL` - `AUTH0_URL` - `AUTH0_AUDIENCE` @@ -231,6 +245,109 @@ Expected apply result: - `APPLY_SUMMARY.unmatched` is `0` - rounds show `created` or `existing` status as expected +## Rerun operator workflows + +### Standard full apply rerun + +Use this when you want to rerun full reconciliation for a round that was +already imported/backfilled: + +```bash +node data-migration/src/scripts/importHistoricalMarathonMatches.js \ + --apply \ + --round-id \ + --skipped-file data-migration/out/historical-mm-skipped-.json +``` + +Expected rerun behavior: + +- reruns are idempotent: already-imported records are reconciled as existing + instead of duplicated +- existing submissions are backfilled with deterministic `systemFileName`, + `virusScan=true`, and `isFileSubmission=true` when the review schema exposes + those columns +- existing final summations also backfill the attached submission row's + `finalScore`, `placement`, and `userRank` summary fields when the review + schema exposes those columns, so imported Marathon Match leaders appear + correctly in the submissions tab +- targeted reruns clear those same submission summary fields from non-final + Marathon Match submissions and demote any stray final summations, so rerunning + a previously imported round repairs provisional-vs-final display state +- when `SUBMISSION_ARCHIVE_DIR` is configured, standard apply also writes + deterministic local submission zip archives and backfills + `reviews.submission.url` +- if legacy provisional rows are malformed, they are skipped/reported (not + fatal) with `reasonCode=malformed-provisional-score` in the skipped artifact; + apply reruns continue and still complete successfully +- existing `missing-member` skips remain deterministic and rerun-stable for + members still absent from the target environment + +### Targeted rerun patch mode (description + submission archive/url + score reconciliation) + +Targeted rerun is explicit patch mode for already-imported rounds. It requires: + +- `--apply --targeted-rerun --round-id --challenge-id ` +- exactly one selected round +- the selected round to resolve an existing imported v6 challenge; if full planning + is blocked only by `target-member-resolution-unavailable`, targeted rerun can + still proceed because it patches only description, submission archive/url data, + and existing review score rows +- a writable `SUBMISSION_ARCHIVE_DIR` (used to generate local zip archives) + +Canonical command shape: + +```bash +node data-migration/src/scripts/importHistoricalMarathonMatches.js --apply --targeted-rerun --round-id --challenge-id +``` + +1. Look up the existing challenge id by legacy round id: + +```bash +curl -s "https://api.topcoder-dev.com/v6/challenges?legacyId=" \ + | jq -r '.[0].id' +``` + +2. Ensure `SUBMISSION_ARCHIVE_DIR` is configured and writable (export in-shell +if needed, instead of editing committed env files): + +```bash +export SUBMISSION_ARCHIVE_DIR=/tmp/mm-submission-archives +mkdir -p "$SUBMISSION_ARCHIVE_DIR" +``` + +3. Run targeted rerun with explicit override: + +```bash +node data-migration/src/scripts/importHistoricalMarathonMatches.js \ + --apply \ + --targeted-rerun \ + --round-id \ + --challenge-id \ + --skipped-file data-migration/out/historical-mm-skipped-.json +``` + +Description source precedence in targeted rerun: + +1. use raw legacy `problem.problem_text` only when it contains renderable HTML +2. otherwise use Markdown converted from legacy `component.component_text` XML +3. if neither source is usable, preserve the existing description + +Description writes also set `descriptionFormat` deterministically: + +- `html` when raw legacy `problem.problem_text` HTML is used +- `markdown` when converted `component.component_text` content is used or when + fallback importer text is stored + +Targeted rerun is patch-only and idempotent: + +- it may patch challenge `description`, submission archive/url data, and + existing final/provisional review summation scores +- it demotes legacy provisional review summations that were previously imported + as final, using `long_component_state.submission_number` to preserve only the + explicit final submission for each coder as final +- it must not mutate phases, resources, or review-summation identities +- rerunning the same targeted patch converges without creating duplicates + ## Recommended rollout sequence 1. Run `--dry-run` for a single round. @@ -252,3 +369,89 @@ Expected apply result: score, or provisional score import starts. - If `RESOURCES_API_URL` or Auth0 credentials are missing, apply mode will fail before participant reconciliation starts. + +## Export Marathon Match submissions + +`data-migration/src/scripts/exportMarathonMatchSubmissions.js` exports live +Marathon Match challenge metadata, submission archives, and review summations +through the v6 challenge and review APIs. + +The script expects a bearer token in the environment. It reads the first +populated variable from: + +- `M2M_TOKEN` +- `M2M_FULL_ACCESS_TOKEN` +- `TOPCODER_M2M_TOKEN` + +### Required environment + +By default the exporter uses: + +- `CHALLENGE_API_URL=https://api.topcoder.com/v6/challenges` +- `REVIEW_API_URL=https://api.topcoder.com/v6` + +Override those when you need to point at local/dev deployments. + +### Output layout + +Given `--output-dir /tmp/mm-export`, the exporter writes: + +- `/tmp/mm-export/metadata.json` +- `/tmp/mm-export/submissions/coder_/.zip` +- `/tmp/mm-export/submissions/coder_/.json` + +`metadata.json` is the raw response from: + +```text +GET /challenges/{challengeId} +``` + +Each per-submission JSON file contains every review summation returned for that +submission from: + +```text +GET /reviewSummations?challengeId=&metadata=true +``` + +That means Marathon Match submissions with multiple rows, such as provisional +and final summations, are exported as a JSON array in `{submissionId}.json`. + +Submissions that do not have any attached review summation rows are ignored and +are not exported. + +If an individual submission archive download fails, for example because the +submission is not available in clean storage after a failed virus scan, the +script logs the error and continues exporting the remaining submissions. + +### Usage + +1. Change into the package folder and select the repo Node version. + +```bash +cd challenge-api-v6/data-migration +nvm use +``` + +2. Export the challenge. + +```bash +M2M_TOKEN=your-token-here \ +pnpm run export:mm:submissions -- \ + --challenge-id \ + --output-dir ./out/mm-export- +``` + +You can also call the script directly: + +```bash +node src/scripts/exportMarathonMatchSubmissions.js \ + --challenge-id \ + --output-dir ./out/mm-export- +``` + +Optional flags: + +- `--challenge-api-url `: override the challenge collection base URL +- `--review-api-url `: override the review API root URL +- `--page-size `: pagination size for submissions and review summations +- `--concurrency `: number of concurrent submission downloads diff --git a/data-migration/package.json b/data-migration/package.json index 5fe5b1b..27be3ae 100644 --- a/data-migration/package.json +++ b/data-migration/package.json @@ -8,6 +8,7 @@ "db:down": "docker compose -f local/docker-compose.yml down", "db:reset": "docker compose -f local/docker-compose.yml down -v && docker compose -f local/docker-compose.yml up -d", "migrate": "node src/index.js", + "export:mm:submissions": "node src/scripts/exportMarathonMatchSubmissions.js", "migrate:reset": "npx prisma migrate reset -f && npm run migrate", "test": "jest", "test:watch": "jest --watch", @@ -36,7 +37,7 @@ "node": "18.19.0", "yarn": "1.22.19" }, - "overrides": { - "glob": "10.4.2" - } + "overrides": { + "glob": "10.4.2" + } } diff --git a/data-migration/src/scripts/exportMarathonMatchSubmissions.js b/data-migration/src/scripts/exportMarathonMatchSubmissions.js new file mode 100644 index 0000000..20f79c4 --- /dev/null +++ b/data-migration/src/scripts/exportMarathonMatchSubmissions.js @@ -0,0 +1,629 @@ +#!/usr/bin/env node +"use strict"; + +const fs = require("fs"); +const path = require("path"); +const { Readable } = require("stream"); +const { pipeline } = require("stream/promises"); +const dotenv = require("dotenv"); + +dotenv.config({ + path: path.resolve(__dirname, "..", "..", "..", ".env.importer.local"), + override: false, + quiet: true, +}); +dotenv.config({ quiet: true }); + +const DEFAULT_PAGE_SIZE = 100; +const DEFAULT_CONCURRENCY = 4; +const DEFAULT_REVIEW_API_URL = String( + process.env.REVIEW_API_URL || process.env.V6_API_URL || "https://api.topcoder.com/v6" +).trim(); +const DEFAULT_CHALLENGE_API_URL = String( + process.env.CHALLENGE_API_URL || `${DEFAULT_REVIEW_API_URL.replace(/\/+$/, "")}/challenges` +).trim(); +const TOKEN_ENV_NAMES = ["M2M_TOKEN", "M2M_FULL_ACCESS_TOKEN", "TOPCODER_M2M_TOKEN"]; + +/** + * Ensure a URL base ends with exactly one trailing slash so relative resource + * paths can be appended safely with the WHATWG URL constructor. + * + * @param {string} value URL base to normalize. + * @returns {string} URL base with one trailing slash. + */ +function ensureTrailingSlash(value) { + return `${String(value || "").replace(/\/+$/, "")}/`; +} + +/** + * Join a relative resource path onto a base URL. + * + * @param {string} baseUrl Base URL for a collection or API root. + * @param {string} resourcePath Relative path to append. + * @returns {string} Fully-qualified URL string. + */ +function joinUrl(baseUrl, resourcePath) { + return new URL(resourcePath, ensureTrailingSlash(baseUrl)).toString(); +} + +/** + * Validate and normalize a positive integer CLI value. + * + * @param {string|number} value Raw CLI value. + * @param {string} optionName Flag name used for error messages. + * @returns {number} Normalized positive integer. + */ +function parsePositiveInteger(value, optionName) { + const normalized = String(value || "").trim(); + if (!/^[1-9]\d*$/.test(normalized)) { + throw new Error(`${optionName} must be a positive integer.`); + } + return Number.parseInt(normalized, 10); +} + +/** + * Read the next CLI token for an option that requires a value. + * + * @param {string[]} argv Raw argv entries. + * @param {number} index Current option index. + * @param {string} optionName Flag name used for error messages. + * @returns {string} Raw option value. + */ +function requireNextValue(argv, index, optionName) { + const next = argv[index + 1]; + if (next === undefined || next.startsWith("--")) { + throw new Error(`${optionName} requires a value`); + } + return next; +} + +/** + * Parse the exporter CLI arguments. + * + * @param {string[]} argv CLI arguments excluding node/script names. + * @returns {{ + * challengeId: string | null, + * outputDir: string | null, + * challengeApiUrl: string, + * reviewApiUrl: string, + * pageSize: number, + * concurrency: number, + * help: boolean + * }} Parsed options. + */ +function parseArgs(argv) { + const options = { + challengeId: null, + outputDir: null, + challengeApiUrl: DEFAULT_CHALLENGE_API_URL, + reviewApiUrl: DEFAULT_REVIEW_API_URL, + pageSize: DEFAULT_PAGE_SIZE, + concurrency: DEFAULT_CONCURRENCY, + help: false, + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--") { + continue; + } + if (arg === "--help" || arg === "-h") { + options.help = true; + continue; + } + if (arg === "--challenge-id") { + const value = String(requireNextValue(argv, index, "--challenge-id")).trim(); + if (!value) { + throw new Error("--challenge-id requires a value"); + } + options.challengeId = value; + index += 1; + continue; + } + if (arg === "--output-dir") { + const value = String(requireNextValue(argv, index, "--output-dir")).trim(); + if (!value) { + throw new Error("--output-dir requires a value"); + } + options.outputDir = path.resolve(value); + index += 1; + continue; + } + if (arg === "--challenge-api-url") { + const value = String(requireNextValue(argv, index, "--challenge-api-url")).trim(); + if (!value) { + throw new Error("--challenge-api-url requires a value"); + } + options.challengeApiUrl = value; + index += 1; + continue; + } + if (arg === "--review-api-url") { + const value = String(requireNextValue(argv, index, "--review-api-url")).trim(); + if (!value) { + throw new Error("--review-api-url requires a value"); + } + options.reviewApiUrl = value; + index += 1; + continue; + } + if (arg === "--page-size") { + options.pageSize = parsePositiveInteger( + requireNextValue(argv, index, "--page-size"), + "--page-size" + ); + index += 1; + continue; + } + if (arg === "--concurrency") { + options.concurrency = parsePositiveInteger( + requireNextValue(argv, index, "--concurrency"), + "--concurrency" + ); + index += 1; + continue; + } + throw new Error(`Unknown option: ${arg}`); + } + + if (!options.help && !options.challengeId) { + throw new Error("--challenge-id is required."); + } + if (!options.help && !options.outputDir) { + throw new Error("--output-dir is required."); + } + + return options; +} + +const usage = `Usage: + node data-migration/src/scripts/exportMarathonMatchSubmissions.js \\ + --challenge-id \\ + --output-dir [options] + +Required options: + --challenge-id Marathon Match challenge id to export + --output-dir Destination directory for metadata.json and submissions/ + +Optional overrides: + --challenge-api-url Challenge API challenge-collection URL + (default: CHALLENGE_API_URL or ${DEFAULT_CHALLENGE_API_URL}) + --review-api-url Review API root URL + (default: REVIEW_API_URL/V6_API_URL or ${DEFAULT_REVIEW_API_URL}) + --page-size Page size for submissions/review summations (default: ${DEFAULT_PAGE_SIZE}) + --concurrency Concurrent submission downloads (default: ${DEFAULT_CONCURRENCY}) + --help Show this help + +Authentication: + The script reads a bearer token from the first populated env var in: + ${TOKEN_ENV_NAMES.join(", ")} +`; + +/** + * Resolve the bearer token used for challenge and review API requests. + * + * @returns {string} Bearer token from the environment. + */ +function resolveAccessToken() { + for (const envName of TOKEN_ENV_NAMES) { + const value = String(process.env[envName] || "").trim(); + if (value) { + return value; + } + } + + throw new Error( + `A bearer token is required. Set one of these environment variables: ${TOKEN_ENV_NAMES.join(", ")}.` + ); +} + +/** + * Read a response body as diagnostic text without assuming JSON. + * + * @param {Response} response Fetch response object. + * @returns {Promise} Best-effort response payload for error reporting. + */ +async function readErrorBody(response) { + const contentType = String(response.headers.get("content-type") || "").toLowerCase(); + + try { + if (contentType.includes("application/json")) { + return JSON.stringify(await response.json()); + } + return await response.text(); + } catch { + return ""; + } +} + +/** + * Execute an authenticated HTTP request and fail with contextual details when + * the API does not return a success response. + * + * @param {string} url Fully-qualified request URL. + * @param {string} token Bearer token for Authorization. + * @param {RequestInit} [init] Additional fetch options. + * @returns {Promise} Successful fetch response. + */ +async function fetchWithAuth(url, token, init = {}) { + const headers = { + Authorization: `Bearer ${token}`, + ...(init.headers || {}), + }; + + const response = await fetch(url, { + ...init, + headers, + }); + + if (!response.ok) { + const body = await readErrorBody(response); + const suffix = body ? `: ${body}` : ""; + throw new Error( + `Request failed for ${url} with ${response.status} ${response.statusText}${suffix}` + ); + } + + return response; +} + +/** + * Execute an authenticated JSON request. + * + * @param {string} url Fully-qualified request URL. + * @param {string} token Bearer token for Authorization. + * @returns {Promise} Parsed JSON payload. + */ +async function fetchJson(url, token) { + const response = await fetchWithAuth(url, token, { + headers: { + Accept: "application/json", + }, + }); + return response.json(); +} + +/** + * Load every page from a review API collection endpoint that follows the + * `{ data, meta }` pagination contract. + * + * @param {{ + * apiUrl: string, + * resourcePath: string, + * query: Record, + * token: string, + * pageSize: number + * }} options Pagination inputs. + * @returns {Promise} Concatenated `data` rows across all pages. + */ +async function fetchPaginatedCollection(options) { + const results = []; + let page = 1; + let totalPages = 1; + + do { + const url = new URL(joinUrl(options.apiUrl, options.resourcePath)); + Object.entries(options.query || {}).forEach(([key, value]) => { + if (value !== undefined && value !== null && value !== "") { + url.searchParams.set(key, String(value)); + } + }); + url.searchParams.set("page", String(page)); + url.searchParams.set("perPage", String(options.pageSize)); + + const payload = await fetchJson(url.toString(), options.token); + if (!payload || !Array.isArray(payload.data)) { + throw new Error( + `Unexpected paginated response shape from ${url.toString()}. Expected a { data, meta } payload.` + ); + } + + results.push(...payload.data); + + const reportedTotalPages = Number.parseInt(String(payload?.meta?.totalPages || ""), 10); + if (Number.isFinite(reportedTotalPages) && reportedTotalPages > 0) { + totalPages = reportedTotalPages; + } else if (payload.data.length < options.pageSize) { + totalPages = page; + } else { + totalPages = page + 1; + } + + page += 1; + } while (page <= totalPages); + + return results; +} + +/** + * Group review summations by submission id so each exported submission can be + * paired with every final/provisional/example summation row returned by the API. + * + * @param {any[]} reviewSummations Raw review summation rows from the API. + * @returns {Map} Submission id -> review summations. + */ +function buildReviewSummationsBySubmissionId(reviewSummations) { + const grouped = new Map(); + + reviewSummations.forEach((reviewSummation) => { + const submissionId = String(reviewSummation?.submissionId || "").trim(); + if (!submissionId) { + return; + } + const existing = grouped.get(submissionId) || []; + existing.push(reviewSummation); + grouped.set(submissionId, existing); + }); + + return grouped; +} + +/** + * Serialize a JSON payload to disk with stable pretty-printing. + * + * @param {string} filePath Destination file. + * @param {any} payload Value to serialize. + * @returns {Promise} Completion promise. + */ +async function writeJsonFile(filePath, payload) { + await fs.promises.writeFile(filePath, `${JSON.stringify(payload, null, 2)}\n`, "utf8"); +} + +/** + * Ensure the submission has the fields required to place it in the requested + * `coder_{user id}` directory structure. + * + * @param {any} submission Submission payload returned by the review API. + * @returns {{ submissionId: string, memberId: string }} Normalized identifiers. + */ +function normalizeSubmissionIdentity(submission) { + const submissionId = String(submission?.id || "").trim(); + const memberId = String(submission?.memberId || "").trim(); + + if (!submissionId) { + throw new Error("Encountered a submission without an id in the review API response."); + } + if (!memberId) { + throw new Error( + `Submission ${submissionId} is missing memberId; cannot place it into coder_{user id}.` + ); + } + + return { submissionId, memberId }; +} + +/** + * Convert a fetch response body into a Node.js readable stream for file output. + * + * @param {Response} response Fetch response with a binary body. + * @returns {Readable} Node.js readable stream. + */ +function toNodeReadable(response) { + if (!response.body) { + throw new Error("Download response did not include a body."); + } + return Readable.fromWeb(response.body); +} + +/** + * Download one submission archive into the target export directory. + * + * @param {{ + * reviewApiUrl: string, + * token: string, + * submissionId: string, + * filePath: string + * }} options Download settings. + * @returns {Promise} Completion promise. + */ +async function downloadSubmissionArchive(options) { + const downloadUrl = joinUrl( + options.reviewApiUrl, + `submissions/${encodeURIComponent(options.submissionId)}/download` + ); + const response = await fetchWithAuth(downloadUrl, options.token); + await pipeline(toNodeReadable(response), fs.createWriteStream(options.filePath)); +} + +/** + * Execute an async mapper with a fixed concurrency limit so large Marathon + * Matches can export faster without overwhelming the API. + * + * @template T + * @param {T[]} items Items to process. + * @param {number} concurrency Maximum concurrent workers. + * @param {(item: T, index: number) => Promise} worker Async item processor. + * @returns {Promise} Completion promise once all items succeed. + */ +async function mapWithConcurrency(items, concurrency, worker) { + let nextIndex = 0; + + const runWorker = async () => { + while (true) { + const currentIndex = nextIndex; + if (currentIndex >= items.length) { + return; + } + nextIndex += 1; + await worker(items[currentIndex], currentIndex); + } + }; + + const workerCount = Math.min(concurrency, Math.max(items.length, 1)); + await Promise.all(Array.from({ length: workerCount }, () => runWorker())); +} + +/** + * Export challenge metadata, submission archives, and per-submission review + * summation JSON into the requested directory layout. + * + * @param {{ + * challengeId: string, + * outputDir: string, + * challengeApiUrl?: string, + * reviewApiUrl?: string, + * token: string, + * pageSize?: number, + * concurrency?: number, + * stdout?: { write: (chunk: string) => void }, + * stderr?: { write: (chunk: string) => void } + * }} options Export settings. + * @returns {Promise<{ + * outputDir: string, + * metadataPath: string, + * submissionsDir: string, + * exportedSubmissionCount: number, + * exportedSubmitterCount: number, + * reviewSummationCount: number, + * skippedSubmissionCountWithoutReviewSummation: number, + * downloadedSubmissionCount: number, + * failedDownloadCount: number + * }>} Export summary. + */ +async function runExport(options) { + const challengeId = String(options.challengeId || "").trim(); + const outputDir = path.resolve(String(options.outputDir || "").trim()); + const challengeApiUrl = String(options.challengeApiUrl || DEFAULT_CHALLENGE_API_URL).trim(); + const reviewApiUrl = String(options.reviewApiUrl || DEFAULT_REVIEW_API_URL).trim(); + const token = String(options.token || "").trim(); + const pageSize = options.pageSize || DEFAULT_PAGE_SIZE; + const concurrency = options.concurrency || DEFAULT_CONCURRENCY; + const stdout = options.stdout || process.stdout; + const stderr = options.stderr || process.stderr; + + if (!challengeId) { + throw new Error("challengeId is required."); + } + if (!outputDir) { + throw new Error("outputDir is required."); + } + if (!token) { + throw new Error("token is required."); + } + + stdout.write(`Exporting Marathon Match ${challengeId} to ${outputDir}\n`); + + const challengeUrl = joinUrl(challengeApiUrl, encodeURIComponent(challengeId)); + const [challenge, submissions, reviewSummations] = await Promise.all([ + fetchJson(challengeUrl, token), + fetchPaginatedCollection({ + apiUrl: reviewApiUrl, + resourcePath: "submissions", + query: { challengeId }, + token, + pageSize, + }), + fetchPaginatedCollection({ + apiUrl: reviewApiUrl, + resourcePath: "reviewSummations", + query: { challengeId, metadata: true }, + token, + pageSize, + }), + ]); + + const reviewSummationsBySubmissionId = buildReviewSummationsBySubmissionId(reviewSummations); + const exportableSubmissions = submissions.filter((submission) => { + const submissionId = String(submission?.id || "").trim(); + return submissionId && reviewSummationsBySubmissionId.has(submissionId); + }); + const skippedSubmissionCountWithoutReviewSummation = + submissions.length - exportableSubmissions.length; + const metadataPath = path.join(outputDir, "metadata.json"); + const submissionsDir = path.join(outputDir, "submissions"); + const submitterIds = new Set(); + let downloadedSubmissionCount = 0; + let failedDownloadCount = 0; + + await fs.promises.mkdir(submissionsDir, { recursive: true }); + await writeJsonFile(metadataPath, challenge); + + await mapWithConcurrency(exportableSubmissions, concurrency, async (submission) => { + const { submissionId, memberId } = normalizeSubmissionIdentity(submission); + submitterIds.add(memberId); + + const submitterDir = path.join(submissionsDir, `coder_${memberId}`); + await fs.promises.mkdir(submitterDir, { recursive: true }); + + await writeJsonFile( + path.join(submitterDir, `${submissionId}.json`), + reviewSummationsBySubmissionId.get(submissionId) || [] + ); + + try { + await downloadSubmissionArchive({ + reviewApiUrl, + token, + submissionId, + filePath: path.join(submitterDir, `${submissionId}.zip`), + }); + downloadedSubmissionCount += 1; + } catch (error) { + failedDownloadCount += 1; + const message = error instanceof Error ? error.message : String(error); + stderr.write( + `Failed to download submission ${submissionId} for member ${memberId}. ${message}\n` + ); + } + }); + + stdout.write( + `Exported ${exportableSubmissions.length} submissions for ${submitterIds.size} submitters to ${outputDir} ` + + `(${skippedSubmissionCountWithoutReviewSummation} skipped without review summations, ` + + `${downloadedSubmissionCount} archive downloads succeeded, ` + + `${failedDownloadCount} archive failures)\n` + ); + + return { + outputDir, + metadataPath, + submissionsDir, + exportedSubmissionCount: exportableSubmissions.length, + exportedSubmitterCount: submitterIds.size, + reviewSummationCount: reviewSummations.length, + skippedSubmissionCountWithoutReviewSummation, + downloadedSubmissionCount, + failedDownloadCount, + }; +} + +/** + * CLI entrypoint for the Marathon Match submission exporter. + * + * @returns {Promise} Completion promise. + */ +async function main() { + const options = parseArgs(process.argv.slice(2)); + + if (options.help) { + process.stdout.write(usage); + return; + } + + await runExport({ + ...options, + token: resolveAccessToken(), + }); +} + +if (require.main === module) { + main().catch((error) => { + const message = error instanceof Error ? error.message : String(error); + process.stderr.write(`${message}\n`); + process.exit(1); + }); +} + +module.exports = { + DEFAULT_CHALLENGE_API_URL, + DEFAULT_CONCURRENCY, + DEFAULT_PAGE_SIZE, + DEFAULT_REVIEW_API_URL, + TOKEN_ENV_NAMES, + buildReviewSummationsBySubmissionId, + main, + parseArgs, + resolveAccessToken, + runExport, + usage, +}; diff --git a/data-migration/src/scripts/importHistoricalMarathonMatches.js b/data-migration/src/scripts/importHistoricalMarathonMatches.js index 8b28870..8a44e4c 100644 --- a/data-migration/src/scripts/importHistoricalMarathonMatches.js +++ b/data-migration/src/scripts/importHistoricalMarathonMatches.js @@ -8,6 +8,7 @@ const { parseArgs, usage } = require("./importHistoricalMarathonMatches/argParse const { buildDryRunPlan } = require("./importHistoricalMarathonMatches/planning"); const { runApplyMode, + runTargetedRerunMode, DEFAULT_SUBMITTER_ROLE_ID, resolveMarathonTypeId, resolveDataScienceTrackId, @@ -28,6 +29,7 @@ const { const { TARGET_MEMBER_RESOLUTION_UNAVAILABLE_REASON, DEFAULT_MEMBER_SCHEMA, + createMemberIdentityResolver, createMemberPresenceResolver, } = require("./importHistoricalMarathonMatches/targetMemberResolution"); @@ -88,6 +90,7 @@ const run = async () => { let memberLookupPrisma = null; let reviewPrisma = null; let resolveMemberPresence = null; + let resolveMemberIdentities = null; if (shouldAttemptDatabaseDiscovery) { const { PrismaClient } = requireFromRoot("@prisma/client"); @@ -221,6 +224,10 @@ const run = async () => { prisma: memberLookupPrisma, memberSchema: memberDbSchema, }); + resolveMemberIdentities = createMemberIdentityResolver({ + prisma: memberLookupPrisma, + memberSchema: memberDbSchema, + }); planningPrerequisites.memberResolution = { available: true, }; @@ -257,6 +264,21 @@ const run = async () => { return; } + if (options.targetedRerun) { + const targetedRerunResult = await runTargetedRerunMode({ + options, + plan, + prisma, + reviewClient: reviewPrisma, + reviewSchema: reviewDbSchema, + submissionArchiveDir: process.env.SUBMISSION_ARCHIVE_DIR, + actor: DEFAULT_ACTOR, + resolveMemberIdentities, + }); + emitApplyReport(targetedRerunResult); + return; + } + if (!String(process.env.RESOURCES_API_URL || "").trim()) { throw new Error("RESOURCES_API_URL must be set for apply mode participant reconciliation."); } @@ -277,12 +299,14 @@ const run = async () => { resourceClient: createDefaultResourceClient(), reviewClient: reviewPrisma, reviewSchema: reviewDbSchema, + submissionArchiveDir: process.env.SUBMISSION_ARCHIVE_DIR, importSubmissions: true, importFinalScores: true, importProvisionalScores: true, }, plan, actor: DEFAULT_ACTOR, + resolveMemberIdentities, }); emitApplyReport(applyResult); } finally { diff --git a/data-migration/src/scripts/importHistoricalMarathonMatches/apply.js b/data-migration/src/scripts/importHistoricalMarathonMatches/apply.js index d3720c7..fefbc09 100644 --- a/data-migration/src/scripts/importHistoricalMarathonMatches/apply.js +++ b/data-migration/src/scripts/importHistoricalMarathonMatches/apply.js @@ -17,6 +17,7 @@ const { DEFAULT_REVIEW_SCHEMA, loadNonExampleLegacySubmissionRowsByRoundId, createReviewSubmissionStore, + createReviewSubmissionArchiveStore, reconcileRoundSubmissionHistory, } = require("./submissionHistory"); const { @@ -29,10 +30,41 @@ const { createReviewProvisionalScoreStore, reconcileRoundProvisionalScores, } = require("./provisionalScores"); +const { + buildSubmissionArchiveFileName, + buildSubmissionArchiveEntryName, + buildSubmissionArchiveUrl, + resolveSubmissionArchiveDirectory, + writeSubmissionArchiveZip, +} = require("./submissionArchives"); +const { + resolveDescriptionCandidateFromCounters, +} = require("./descriptionSourcing"); +const { + TARGET_MEMBER_RESOLUTION_UNAVAILABLE_REASON, +} = require("./targetMemberResolution"); const STANDARD_PHASE_NAMES = ["Registration", "Submission", "Review"]; const DEFAULT_SUBMITTER_ROLE_ID = "732339e7-8e30-49d7-9198-cccf9451e221"; const TEMPORARY_RESOURCE_WRITE_STATUS = "ACTIVE"; +const CANONICAL_RATED_METADATA_NAME = "isRated"; +const LEGACY_RATING_METADATA_NAMES = ["rated", "unrated"]; +const RATED_METADATA_NAMES = [CANONICAL_RATED_METADATA_NAME, ...LEGACY_RATING_METADATA_NAMES]; +const PLACEMENT_WINNER_TYPE = "PLACEMENT"; +const buildFallbackImportedDescription = (legacyId) => + `Imported historical Marathon Match from legacy round ${legacyId}`; + +const resolveChallengeDescription = ({ legacyId, counters }) => { + const descriptionCandidate = resolveDescriptionCandidateFromCounters(counters); + if (descriptionCandidate) { + return descriptionCandidate; + } + return { + description: buildFallbackImportedDescription(legacyId), + descriptionFormat: "markdown", + source: "fallback-imported-description", + }; +}; const parseRoundLegacyId = (roundId) => { const parsed = Number.parseInt(String(roundId || "").trim(), 10); @@ -122,6 +154,130 @@ const derivePhaseWindows = (roundId, counters) => { const phaseDurationSeconds = (startDate, endDate) => Math.max(0, Math.floor((endDate.getTime() - startDate.getTime()) / 1000)); +/** + * Resolve the legacy Informix round-level rating flag. + * The historical Marathon Match importer uses `round.rated_ind` to preserve + * whether member-api rerates should include the imported challenge. + * @param {Object} round legacy Informix round row + * @returns {boolean|null} explicit rated flag, or null when legacy data is missing/indeterminate + */ +const resolveLegacyRoundIsRated = (round) => { + const normalized = String( + round && Object.prototype.hasOwnProperty.call(round, "rated_ind") ? round.rated_ind : "" + ) + .trim() + .toLowerCase(); + if (normalized === "1" || normalized === "true") { + return true; + } + if (normalized === "0" || normalized === "false") { + return false; + } + return null; +}; + +/** + * Reconcile one canonical `isRated` ChallengeMetadata row for an imported challenge. + * This keeps rerating inputs deterministic by collapsing any legacy `rated` or + * `unrated` rows into a single `isRated` metadata entry. + * @param {Object} params reconciliation inputs + * @param {Object} params.prisma Prisma client or transaction exposing challengeMetadata CRUD methods + * @param {string} params.challengeId target v6 challenge id + * @param {Object} params.round legacy Informix round row + * @param {string} params.actor audit actor used for metadata writes + * @returns {Promise<{applied: boolean, isRated: boolean|null}>} reconciliation summary + * @throws {Error} when metadata cleanup or canonicalization fails + */ +const reconcileChallengeRatedMetadata = async ({ + prisma, + challengeId, + round, + actor, +}) => { + const resolvedIsRated = resolveLegacyRoundIsRated(round); + if ( + resolvedIsRated === null || + !prisma || + !prisma.challengeMetadata || + typeof prisma.challengeMetadata.findMany !== "function" + ) { + return { + applied: false, + isRated: resolvedIsRated, + }; + } + + const metadataActor = String(actor || "").trim() || "historical-mm-importer"; + const metadataValue = resolvedIsRated ? "true" : "false"; + const existingRows = await prisma.challengeMetadata.findMany({ + where: { + challengeId, + name: { + in: RATED_METADATA_NAMES, + }, + }, + orderBy: [{ createdAt: "asc" }, { id: "asc" }], + select: { + id: true, + name: true, + value: true, + }, + }); + + const canonicalRow = + existingRows.find((row) => row.name === CANONICAL_RATED_METADATA_NAME) || existingRows[0] || null; + + if (!canonicalRow) { + await prisma.challengeMetadata.create({ + data: { + challengeId, + name: CANONICAL_RATED_METADATA_NAME, + value: metadataValue, + createdBy: metadataActor, + updatedBy: metadataActor, + }, + select: { id: true }, + }); + return { + applied: true, + isRated: resolvedIsRated, + }; + } + + if ( + canonicalRow.name !== CANONICAL_RATED_METADATA_NAME || + String(canonicalRow.value || "").trim().toLowerCase() !== metadataValue + ) { + await prisma.challengeMetadata.update({ + where: { id: canonicalRow.id }, + data: { + name: CANONICAL_RATED_METADATA_NAME, + value: metadataValue, + updatedBy: metadataActor, + }, + select: { id: true }, + }); + } + + const duplicateIds = existingRows + .filter((row) => row.id !== canonicalRow.id) + .map((row) => row.id); + if (duplicateIds.length > 0 && typeof prisma.challengeMetadata.deleteMany === "function") { + await prisma.challengeMetadata.deleteMany({ + where: { + id: { + in: duplicateIds, + }, + }, + }); + } + + return { + applied: true, + isRated: resolvedIsRated, + }; +}; + const buildChallengePhaseRows = ({ challengeId, phaseIdsByName, windows, actor }) => { const rows = []; @@ -162,6 +318,7 @@ const buildChallengeCreateData = ({ timelineTemplateId, counters, windows, + placementWinners, }) => { const legacyId = parseRoundLegacyId(roundId); const registrationCount = counters && counters.eligibleRegistrants ? counters.eligibleRegistrants.size : 0; @@ -174,13 +331,15 @@ const buildChallengeCreateData = ({ ? counters.exampleOnlyFinalistSubmissions : 0; const submissionCount = nonExampleSubmissionCount + exampleOnlyFinalistSubmissionCount; + const descriptionPayload = resolveChallengeDescription({ legacyId, counters }); - return { + const challengeData = { legacyId, name: String((round && (round.short_name || round.name)) || "").trim() || `Historical Marathon Match ${legacyId}`, - description: `Imported historical Marathon Match from legacy round ${legacyId}`, + description: descriptionPayload.description, + descriptionFormat: descriptionPayload.descriptionFormat, typeId: marathonTypeId, trackId: dataScienceTrackId, timelineTemplateId, @@ -199,6 +358,372 @@ const buildChallengeCreateData = ({ createdBy: actor, updatedBy: actor, }; + + if (Array.isArray(placementWinners) && placementWinners.length > 0) { + challengeData.winners = { + create: placementWinners, + }; + } + + return challengeData; +}; + +const parsePlacementWinnerUserId = (value) => { + const parsed = Number.parseInt(String(value || "").trim(), 10); + if (!Number.isFinite(parsed) || parsed <= 0) { + return null; + } + return parsed; +}; + +const comparePlacementWinnerCandidates = (left, right) => { + const leftScore = Number.isFinite(left.aggregateScore) ? left.aggregateScore : Number.NEGATIVE_INFINITY; + const rightScore = Number.isFinite(right.aggregateScore) ? right.aggregateScore : Number.NEGATIVE_INFINITY; + if (leftScore !== rightScore) { + return rightScore - leftScore; + } + + const leftLegacyPlacement = Number.isFinite(left.legacyPlacement) + ? left.legacyPlacement + : Number.MAX_SAFE_INTEGER; + const rightLegacyPlacement = Number.isFinite(right.legacyPlacement) + ? right.legacyPlacement + : Number.MAX_SAFE_INTEGER; + if (leftLegacyPlacement !== rightLegacyPlacement) { + return leftLegacyPlacement - rightLegacyPlacement; + } + + return String(left.coderId || "").localeCompare(String(right.coderId || ""), undefined, { + numeric: true, + }); +}; + +const normalizePlacementWinnerRecord = (winner) => { + const userId = parsePlacementWinnerUserId(winner && winner.userId); + const placement = parsePlacementWinnerUserId(winner && winner.placement); + const handle = String(winner && winner.handle ? winner.handle : "").trim(); + if (!userId || !placement || !handle) { + return null; + } + + return { + userId, + handle, + placement, + type: PLACEMENT_WINNER_TYPE, + }; +}; + +const resolvePlacementWinnerIdentity = ( + coderId, + normalizedIdentityByCoderId = new Map() +) => { + const normalizedCoderId = String(coderId || "").trim(); + if (!normalizedCoderId) { + return null; + } + + const knownIdentity = normalizedIdentityByCoderId.get(normalizedCoderId); + const memberId = parsePlacementWinnerUserId(knownIdentity && knownIdentity.memberId); + if (memberId) { + return { + coderId: normalizedCoderId, + memberId, + memberHandle: String( + knownIdentity && knownIdentity.memberHandle ? knownIdentity.memberHandle : "" + ).trim() || null, + }; + } + + const fallbackMemberId = parsePlacementWinnerUserId(normalizedCoderId); + if (!fallbackMemberId) { + return null; + } + + return { + coderId: normalizedCoderId, + memberId: fallbackMemberId, + memberHandle: null, + }; +}; + +/** + * Resolves the non-null handle value required by challenge winner rows. The + * marathon importer prefers the normalized member handle from Informix user data, + * but targeted reruns may still have a valid member id when a user export shard is + * unavailable. In that case the member id is used as a deterministic fallback so + * winner reconciliation still populates the challenge-level winners relation. + * + * @param {object} identity normalized identity with member id and optional handle + * @returns {string} handle or member-id fallback for the placement winner row + * @throws Does not throw. + */ +const resolvePlacementWinnerHandle = (identity) => { + const memberHandle = String( + identity && identity.memberHandle ? identity.memberHandle : "" + ).trim(); + if (memberHandle) { + return memberHandle; + } + const memberId = parsePlacementWinnerUserId(identity && identity.memberId); + return memberId ? String(memberId) : ""; +}; + +/** + * Backfills missing member handles in normalized identity maps from the target + * member database. It leaves Informix-provided handles intact and only fills gaps. + * + * @param {Object} params enrichment inputs + * @param {Map} params.normalizedIdentityByCoderId identity map keyed by coder id + * @param {Function} [params.resolveMemberIdentities] target DB member identity resolver + * @returns {Promise>} identity map with missing handles hydrated when possible + * @throws {Error} when the resolver query fails + */ +const hydrateMissingIdentityHandles = async ({ + normalizedIdentityByCoderId, + resolveMemberIdentities, +}) => { + if ( + !(normalizedIdentityByCoderId instanceof Map) || + typeof resolveMemberIdentities !== "function" + ) { + return normalizedIdentityByCoderId instanceof Map + ? normalizedIdentityByCoderId + : new Map(); + } + + const missingHandleMemberIds = new Set(); + normalizedIdentityByCoderId.forEach((identity) => { + const memberId = parsePlacementWinnerUserId(identity && identity.memberId); + const memberHandle = String( + identity && identity.memberHandle ? identity.memberHandle : "" + ).trim(); + if (memberId && !memberHandle) { + missingHandleMemberIds.add(String(memberId)); + } + }); + + if (missingHandleMemberIds.size === 0) { + return normalizedIdentityByCoderId; + } + + const identityByMemberId = await resolveMemberIdentities({ + memberIds: Array.from(missingHandleMemberIds), + }); + if (!(identityByMemberId instanceof Map) || identityByMemberId.size === 0) { + return normalizedIdentityByCoderId; + } + + const hydratedIdentityByCoderId = new Map(); + normalizedIdentityByCoderId.forEach((identity, coderId) => { + const memberId = parsePlacementWinnerUserId(identity && identity.memberId); + const targetIdentity = memberId ? identityByMemberId.get(String(memberId)) : null; + const targetHandle = String( + targetIdentity && targetIdentity.memberHandle ? targetIdentity.memberHandle : "" + ).trim(); + hydratedIdentityByCoderId.set( + coderId, + targetHandle + ? { + ...identity, + memberHandle: targetHandle, + } + : identity + ); + }); + + return hydratedIdentityByCoderId; +}; + +/** + * Build deterministic placement winners for a round from positive final-score rows. + * Winners are ranked by descending aggregate score, with legacy placement and coder id + * used only as stable tie-breakers so reruns keep the same ordering. + * + * @param {Object} params winner derivation inputs + * @param {string} params.roundId legacy round id + * @param {Map>} params.finalRowsByRoundId final rows keyed by round id + * @param {Map} params.normalizedIdentityByCoderId normalized member identities keyed by coder id + * @param {string} params.actor audit actor for nested winner writes + * @returns {Array} Prisma nested create inputs for placement winners + */ +const buildPlacementWinnersForRound = ({ + roundId, + finalRowsByRoundId, + normalizedIdentityByCoderId, + actor, +}) => { + const actorName = String(actor || "").trim() || "historical-mm-importer"; + const candidateByUserId = new Map(); + const finalRows = finalRowsByRoundId instanceof Map ? finalRowsByRoundId.get(roundId) || [] : []; + + finalRows.forEach((finalRow) => { + if (!Number.isFinite(finalRow && finalRow.aggregateScore) || finalRow.aggregateScore <= 0) { + return; + } + + const identity = resolvePlacementWinnerIdentity( + finalRow && finalRow.coderId, + normalizedIdentityByCoderId + ); + const userId = parsePlacementWinnerUserId(identity && identity.memberId); + if (!userId) { + return; + } + + const candidate = { + userId, + handle: resolvePlacementWinnerHandle(identity), + aggregateScore: finalRow.aggregateScore, + legacyPlacement: finalRow.legacyPlacement, + coderId: finalRow.coderId, + }; + if (!candidate.handle) { + return; + } + + const existing = candidateByUserId.get(candidate.userId); + if (!existing || comparePlacementWinnerCandidates(candidate, existing) < 0) { + candidateByUserId.set(candidate.userId, candidate); + } + }); + + return Array.from(candidateByUserId.values()) + .sort(comparePlacementWinnerCandidates) + .map((candidate, index) => ({ + userId: candidate.userId, + handle: candidate.handle, + placement: index + 1, + type: PLACEMENT_WINNER_TYPE, + createdBy: actorName, + updatedBy: actorName, + })); +}; + +/** + * Replace placement winners on an imported challenge with a deterministic winner list. + * + * @param {Object} params winner write inputs + * @param {Object} params.prisma Prisma client or transaction exposing challenge.update + * @param {string} params.challengeId target v6 challenge id + * @param {Array} params.placementWinners desired placement winners + * @param {string} params.actor audit actor for challenge updates + * @returns {Promise} + */ +const setChallengePlacementWinners = async ({ + prisma, + challengeId, + placementWinners, + actor, +}) => { + if (!prisma || !prisma.challenge || typeof prisma.challenge.update !== "function") { + throw new Error("Challenge winner reconciliation requires Prisma challenge.update."); + } + + await prisma.challenge.update({ + where: { id: challengeId }, + data: { + winners: { + deleteMany: { + type: PLACEMENT_WINNER_TYPE, + }, + ...(placementWinners.length > 0 + ? { + create: placementWinners, + } + : {}), + }, + updatedBy: String(actor || "").trim() || "historical-mm-importer", + }, + select: { id: true }, + }); +}; + +/** + * Idempotently reconcile placement winners for targeted reruns. + * + * @param {Object} params winner reconciliation inputs + * @param {Object} params.prisma Prisma client exposing challenge.findUnique/update + * @param {string} params.challengeId target v6 challenge id + * @param {Array} params.placementWinners desired placement winners + * @param {string} params.actor audit actor for challenge updates + * @returns {Promise<{updated: boolean, winnerCount: number}>} whether a challenge write was needed + */ +const reconcileChallengePlacementWinners = async ({ + prisma, + challengeId, + placementWinners, + actor, +}) => { + if ( + !prisma || + !prisma.challenge || + typeof prisma.challenge.findUnique !== "function" || + typeof prisma.challenge.update !== "function" + ) { + throw new Error( + "Challenge winner reconciliation requires Prisma challenge.findUnique and challenge.update." + ); + } + + const existingChallenge = await prisma.challenge.findUnique({ + where: { id: challengeId }, + select: { + winners: { + where: { + type: PLACEMENT_WINNER_TYPE, + }, + orderBy: [{ placement: "asc" }, { userId: "asc" }], + select: { + userId: true, + handle: true, + placement: true, + type: true, + }, + }, + }, + }); + if (!existingChallenge) { + throw new Error(`Unable to read challenge winners for ${challengeId}.`); + } + + const normalizedExisting = ((existingChallenge && existingChallenge.winners) || []) + .map(normalizePlacementWinnerRecord) + .filter(Boolean); + const normalizedDesired = (placementWinners || []) + .map(normalizePlacementWinnerRecord) + .filter(Boolean); + const winnersMatch = + normalizedExisting.length === normalizedDesired.length && + normalizedExisting.every((winner, index) => { + const desiredWinner = normalizedDesired[index]; + return ( + desiredWinner && + winner.userId === desiredWinner.userId && + winner.handle === desiredWinner.handle && + winner.placement === desiredWinner.placement && + winner.type === desiredWinner.type + ); + }); + + if (winnersMatch) { + return { + updated: false, + winnerCount: normalizedDesired.length, + }; + } + + await setChallengePlacementWinners({ + prisma, + challengeId, + placementWinners, + actor, + }); + + return { + updated: true, + winnerCount: normalizedDesired.length, + }; }; const countStandardPhaseRows = (phaseRows) => { @@ -234,6 +759,7 @@ const applyCreateRound = async ({ dataScienceTrackId, timelineTemplateId, phaseIdsByName, + placementWinners = null, }) => { const legacyId = parseRoundLegacyId(roundId); @@ -290,6 +816,21 @@ const applyCreateRound = async ({ } } + await reconcileChallengeRatedMetadata({ + prisma: tx, + challengeId: existingChallenge.id, + round, + actor, + }); + if (Array.isArray(placementWinners)) { + await setChallengePlacementWinners({ + prisma: tx, + challengeId: existingChallenge.id, + placementWinners, + actor, + }); + } + return { status: "existing", challengeId: existingChallenge.id, @@ -308,10 +849,18 @@ const applyCreateRound = async ({ timelineTemplateId, counters, windows, + placementWinners, }), select: { id: true }, }); + await reconcileChallengeRatedMetadata({ + prisma: tx, + challengeId: challenge.id, + round, + actor, + }); + const phaseRows = buildChallengePhaseRows({ challengeId: challenge.id, phaseIdsByName, @@ -594,12 +1143,715 @@ const collectSkipMemberIdsByRoundId = ({ return byRoundId; }; +const resolveTargetedRerunSelection = ({ options, planRecordByRoundId }) => { + const roundIds = Array.isArray(options && options.roundIds) ? options.roundIds : []; + if (roundIds.length !== 1) { + throw new Error("--targeted-rerun requires exactly one selected round."); + } + const [roundId] = roundIds; + const challengeIdOverride = String((options && options.challengeId) || "").trim(); + if (!challengeIdOverride) { + throw new Error("--targeted-rerun requires --challenge-id ."); + } + + const planRecord = planRecordByRoundId.get(roundId); + if (!planRecord) { + throw new Error( + `Targeted rerun requires a plan record for selected round ${roundId}; none was generated.` + ); + } + const matchedChallengeId = String((planRecord && planRecord.matchedChallengeId) || "").trim(); + const decisionAllowsTargetedRerun = + planRecord.decision === "reuse/backfill-only" || + (planRecord.decision === "unresolved" && + planRecord.reason === TARGET_MEMBER_RESOLUTION_UNAVAILABLE_REASON && + Boolean(matchedChallengeId)); + if (!decisionAllowsTargetedRerun) { + throw new Error( + `Targeted rerun requires selected round ${roundId} to be already imported (decision reuse/backfill-only), but got ${planRecord.decision}.` + ); + } + if (!matchedChallengeId) { + throw new Error( + `Targeted rerun requires selected round ${roundId} to resolve an existing matched challenge id.` + ); + } + if (challengeIdOverride !== matchedChallengeId) { + throw new Error( + `Targeted rerun challenge-id override "${challengeIdOverride}" does not match selected round ${roundId} target challenge "${matchedChallengeId}".` + ); + } + + return { + roundId, + challengeId: matchedChallengeId, + }; +}; + +const compareLegacySubmissionIds = (left, right) => + String(left || "").localeCompare(String(right || ""), undefined, { numeric: true }); + +const buildLegacySubmissionRowsByLegacySubmissionId = (roundId, legacySubmissionRows = []) => { + const byLegacySubmissionId = new Map(); + legacySubmissionRows.forEach((row) => { + const legacySubmissionId = String(row && row.legacySubmissionId ? row.legacySubmissionId : "").trim(); + if (!legacySubmissionId) { + return; + } + if (byLegacySubmissionId.has(legacySubmissionId)) { + throw new Error( + `Targeted rerun round ${roundId} has duplicate legacy submission text rows for legacySubmissionId "${legacySubmissionId}".` + ); + } + byLegacySubmissionId.set(legacySubmissionId, row); + }); + return byLegacySubmissionId; +}; + +const loadTargetedRerunLegacySubmissionRowsByRoundId = async ({ + selection, + options, + plan, + legacySubmissionRowsByRoundId, +}) => { + if (legacySubmissionRowsByRoundId instanceof Map) { + return legacySubmissionRowsByRoundId; + } + + const roundDataById = plan && plan.roundDataById instanceof Map ? plan.roundDataById : null; + const counters = roundDataById ? roundDataById.get(selection.roundId) : null; + const finalCandidateCoderIds = + counters && counters.finalCandidateCoderIds instanceof Set + ? counters.finalCandidateCoderIds + : new Set(); + return loadNonExampleLegacySubmissionRowsByRoundId({ + dataDir: options.dataDir, + longComponentStateFile: options.longComponentStateFile, + longSubmissionPattern: options.longSubmissionPattern, + roundIds: [selection.roundId], + attachableExampleOnlyFinalistCoderIdsByRoundId: new Map([ + [selection.roundId, finalCandidateCoderIds], + ]), + }); +}; + +/** + * Reconciles submission archive zip files and `reviews.submission.url` values for + * every imported review submission currently linked to a challenge. + * + * @param {Object} params reconciliation inputs + * @param {string} params.challengeId v6 challenge identifier whose review + * submissions should receive deterministic archive URLs + * @param {string} params.roundId legacy round identifier used to source legacy + * submission text rows + * @param {Object} params.options normalized CLI/runtime options for archive dir + * fallback and legacy input access + * @param {Object} [params.plan] apply/targeted-rerun plan used to recover legacy + * submission rows when they were not provided by the caller + * @param {Object} [params.submissionArchiveStore] optional injected store for + * listing/updating submission URLs + * @param {Object} [params.reviewClient] Review DB client used when no injected + * archive store is provided + * @param {string} [params.reviewSchema] review schema name for store creation + * @param {Map>} params.legacySubmissionRowsByRoundId legacy + * submission rows keyed by legacy round id + * @param {string} [params.submissionArchiveDir] optional archive directory override + * @returns {Promise} archive reconciliation summary + * @throws {Error} when archive generation is configured but legacy text recovery + * fails for an imported submission + */ +const reconcileSubmissionArchivesForChallenge = async ({ + challengeId, + roundId, + options, + plan, + submissionArchiveStore, + reviewClient, + reviewSchema, + legacySubmissionRowsByRoundId, + submissionArchiveDir, +}) => { + const archiveDirectory = resolveSubmissionArchiveDirectory( + submissionArchiveDir || + options.submissionArchiveDir || + process.env.SUBMISSION_ARCHIVE_DIR + ); + const store = + submissionArchiveStore || + (await createReviewSubmissionArchiveStore({ + reviewClient, + reviewSchema: reviewSchema || DEFAULT_REVIEW_SCHEMA, + })); + const listSubmissionsByLegacyId = + typeof store.listSubmissionsByLegacyId === "function" + ? store.listSubmissionsByLegacyId.bind(store) + : typeof store.listExistingSubmissionsByLegacyId === "function" + ? store.listExistingSubmissionsByLegacyId.bind(store) + : null; + if (!listSubmissionsByLegacyId) { + throw new Error( + "Submission archive reconciliation requires listSubmissionsByLegacyId or listExistingSubmissionsByLegacyId." + ); + } + const existingSubmissionsByLegacyId = await listSubmissionsByLegacyId({ + challengeId, + }); + const existingSubmissions = Array.from(existingSubmissionsByLegacyId.values()).sort((left, right) => + compareLegacySubmissionIds(left.legacySubmissionId, right.legacySubmissionId) + ); + if (existingSubmissions.length === 0) { + return { + submissionsReconciled: 0, + archivesWritten: 0, + urlsUpdated: 0, + urlsAlreadyMatched: 0, + archiveDirectory, + }; + } + + const rowsByRoundId = await loadTargetedRerunLegacySubmissionRowsByRoundId({ + selection: { + roundId, + challengeId, + }, + options, + plan, + legacySubmissionRowsByRoundId, + }); + const legacyRowsByLegacySubmissionId = buildLegacySubmissionRowsByLegacySubmissionId( + roundId, + (rowsByRoundId && rowsByRoundId.get(roundId)) || [] + ); + + let urlsUpdated = 0; + const archiveFileNameByLegacySubmissionId = new Map(); + existingSubmissions.forEach((submission) => { + const legacySubmissionId = String( + submission && submission.legacySubmissionId ? submission.legacySubmissionId : "" + ).trim(); + if (!legacySubmissionId) { + return; + } + const archiveFileName = buildSubmissionArchiveFileName({ + challengeId, + legacySubmissionId, + }); + const existingLegacySubmissionId = archiveFileNameByLegacySubmissionId.get(archiveFileName); + if (existingLegacySubmissionId && existingLegacySubmissionId !== legacySubmissionId) { + throw new Error( + `Submission archive reconciliation generated colliding archive filename "${archiveFileName}" for legacy submissions ${existingLegacySubmissionId} and ${legacySubmissionId}.` + ); + } + archiveFileNameByLegacySubmissionId.set(archiveFileName, legacySubmissionId); + }); + + for (const submission of existingSubmissions) { + const legacySubmissionId = String( + submission && submission.legacySubmissionId ? submission.legacySubmissionId : "" + ).trim(); + if (!legacySubmissionId) { + continue; + } + + const legacyRow = legacyRowsByLegacySubmissionId.get(legacySubmissionId); + if (!legacyRow) { + throw new Error( + `Could not recover legacy submission text for round ${roundId} legacySubmissionId "${legacySubmissionId}".` + ); + } + + const archiveFileName = buildSubmissionArchiveFileName({ + challengeId, + legacySubmissionId, + }); + const archiveEntryName = buildSubmissionArchiveEntryName({ + legacySubmissionId, + }); + const submissionArchiveUrl = buildSubmissionArchiveUrl({ archiveFileName }); + writeSubmissionArchiveZip({ + archiveDirectory, + archiveFileName, + archiveEntryName, + submissionText: legacyRow.submissionText || "", + }); + + const existingUrl = String( + submission && submission.url !== null && submission.url !== undefined ? submission.url : "" + ).trim(); + if (existingUrl !== submissionArchiveUrl) { + await store.updateSubmissionUrl({ + challengeId, + legacySubmissionId, + url: submissionArchiveUrl, + }); + urlsUpdated += 1; + } + } + + return { + submissionsReconciled: existingSubmissions.length, + archivesWritten: existingSubmissions.length, + urlsUpdated, + urlsAlreadyMatched: existingSubmissions.length - urlsUpdated, + archiveDirectory, + }; +}; + +const reconcileTargetedRerunSubmissionArchives = async ({ + selection, + options, + plan, + submissionArchiveStore, + reviewClient, + reviewSchema, + legacySubmissionRowsByRoundId, + submissionArchiveDir, +}) => + reconcileSubmissionArchivesForChallenge({ + challengeId: selection.challengeId, + roundId: selection.roundId, + options, + plan, + submissionArchiveStore, + reviewClient, + reviewSchema, + legacySubmissionRowsByRoundId, + submissionArchiveDir, + }).then((result) => ({ + targetedSubmissions: result.submissionsReconciled, + archivesWritten: result.archivesWritten, + urlsUpdated: result.urlsUpdated, + urlsAlreadyMatched: result.urlsAlreadyMatched, + archiveDirectory: result.archiveDirectory, + })); + +const runTargetedRerunMode = async ({ + options, + plan, + prisma, + actor = "historical-mm-importer", + submissionStore, + submissionArchiveStore, + finalScoreStore, + provisionalScoreStore, + reviewClient, + reviewSchema, + legacySubmissionRowsByRoundId, + submissionArchiveDir, + normalizedIdentityByCoderId: providedNormalizedIdentityByCoderId, + resolveMemberIdentities, +}) => { + const planRecordByRoundId = new Map((plan.records || []).map((record) => [record.legacyRoundId, record])); + const selection = resolveTargetedRerunSelection({ options, planRecordByRoundId }); + const roundDataById = plan && plan.roundDataById instanceof Map ? plan.roundDataById : null; + const counters = roundDataById ? roundDataById.get(selection.roundId) : null; + const finalScoreReconciliationEnabled = Boolean(finalScoreStore) || Boolean(reviewClient); + const provisionalScoreReconciliationEnabled = + Boolean(provisionalScoreStore) || Boolean(reviewClient); + const descriptionCandidate = resolveDescriptionCandidateFromCounters(counters); + const legacyProblemId = String( + counters && counters.descriptionProblemId ? counters.descriptionProblemId : "" + ).trim(); + const legacyComponentId = String( + counters && counters.descriptionComponentId ? counters.descriptionComponentId : "" + ).trim(); + const hasProblemTextUpdate = + descriptionCandidate && descriptionCandidate.source === "legacy-problem-text"; + const hasComponentMarkdownUpdate = + descriptionCandidate && descriptionCandidate.source === "legacy-component-text-markdown"; + const roundSubmissionRowsByRoundId = await loadTargetedRerunLegacySubmissionRowsByRoundId({ + selection, + options, + plan, + legacySubmissionRowsByRoundId, + }); + const submissionArchiveReconciliation = + await reconcileTargetedRerunSubmissionArchives({ + selection, + options, + plan, + submissionArchiveStore, + reviewClient, + reviewSchema, + legacySubmissionRowsByRoundId: roundSubmissionRowsByRoundId, + submissionArchiveDir, + }); + const hasSubmissionArchiveWrites = submissionArchiveReconciliation.archivesWritten > 0; + const submissionReconciliationEnabled = Boolean(submissionStore) || Boolean(reviewClient); + let submissionReconciliation = null; + let finalScoreReconciliation = null; + let provisionalScoreReconciliation = null; + let winnerReconciliation = null; + let targetedRerunNormalizedIdentityByCoderId = + providedNormalizedIdentityByCoderId instanceof Map + ? providedNormalizedIdentityByCoderId + : null; + let roundFinalRowsByRoundId = new Map(); + if ( + submissionReconciliationEnabled || + finalScoreReconciliationEnabled || + provisionalScoreReconciliationEnabled + ) { + let normalizedIdentityByCoderId = targetedRerunNormalizedIdentityByCoderId; + let roundProvisionalRowsByRoundId = new Map(); + + if (finalScoreReconciliationEnabled) { + roundFinalRowsByRoundId = await loadLegacyFinalRowsByRoundId({ + dataDir: options.dataDir, + longComponentStateFile: options.longComponentStateFile, + longCompResultPattern: options.longCompResultPattern, + roundIds: [selection.roundId], + }); + } + if (provisionalScoreReconciliationEnabled) { + roundProvisionalRowsByRoundId = await loadLegacyProvisionalRowsByRoundId({ + dataDir: options.dataDir, + longComponentStateFile: options.longComponentStateFile, + longSubmissionPattern: options.longSubmissionPattern, + roundIds: [selection.roundId], + attachableExampleOnlyFinalistCoderIdsByRoundId: new Map([ + [ + selection.roundId, + (counters && counters.finalCandidateCoderIds) || new Set(), + ], + ]), + }); + } + + if (!normalizedIdentityByCoderId) { + const relevantCoderIds = new Set(); + roundFinalRowsByRoundId.forEach((rows) => { + (rows || []).forEach((row) => { + const coderId = String(row && row.coderId ? row.coderId : "").trim(); + if (coderId) { + relevantCoderIds.add(coderId); + } + }); + }); + roundProvisionalRowsByRoundId.forEach((rows) => { + (rows || []).forEach((row) => { + const coderId = String(row && row.coderId ? row.coderId : "").trim(); + if (coderId) { + relevantCoderIds.add(coderId); + } + }); + }); + roundSubmissionRowsByRoundId.forEach((rows) => { + (rows || []).forEach((row) => { + const coderId = String(row && row.coderId ? row.coderId : "").trim(); + if (coderId) { + relevantCoderIds.add(coderId); + } + }); + }); + + normalizedIdentityByCoderId = + relevantCoderIds.size > 0 + ? await loadNormalizedIdentityByCoderId({ + dataDir: options.dataDir, + userPattern: options.userPattern || DEFAULT_USER_PATTERN, + coderIds: relevantCoderIds, + }) + : new Map(); + } + normalizedIdentityByCoderId = await hydrateMissingIdentityHandles({ + normalizedIdentityByCoderId, + resolveMemberIdentities, + }); + targetedRerunNormalizedIdentityByCoderId = normalizedIdentityByCoderId; + + if (submissionReconciliationEnabled) { + const missingMemberSubmissionSkipMemberIdsByRoundId = + collectMissingMemberSkipMemberIdsByRoundId({ + roundIds: [selection.roundId], + planRecordByRoundId, + affectedSurface: "submission", + }); + const resolvedSubmissionStore = + submissionStore || + (await createReviewSubmissionStore({ + reviewClient, + reviewSchema: reviewSchema || DEFAULT_REVIEW_SCHEMA, + actor, + })); + submissionReconciliation = await reconcileRoundSubmissionHistory({ + roundId: selection.roundId, + challengeId: selection.challengeId, + rowsByRoundId: roundSubmissionRowsByRoundId, + normalizedIdentityByCoderId, + missingMemberSubmissionSkipMemberIds: + missingMemberSubmissionSkipMemberIdsByRoundId.get(selection.roundId) || new Set(), + submissionStore: resolvedSubmissionStore, + }); + } + + if (finalScoreReconciliationEnabled) { + const missingMemberFinalSkipMemberIdsByRoundId = + collectMissingMemberSkipMemberIdsByRoundId({ + roundIds: [selection.roundId], + planRecordByRoundId, + affectedSurface: "final-score", + }); + const plannedUnattachableFinalSkipMemberIdsByRoundId = collectSkipMemberIdsByRoundId({ + roundIds: [selection.roundId], + planRecordByRoundId, + reasonCode: FINALIST_WITHOUT_ATTACHABLE_SUBMISSION_REASON_CODE, + affectedSurface: "final-score", + }); + const resolvedFinalScoreStore = + finalScoreStore || + (await createReviewFinalScoreStore({ + reviewClient, + reviewSchema: reviewSchema || DEFAULT_REVIEW_SCHEMA, + actor, + })); + finalScoreReconciliation = await reconcileRoundFinalScores({ + roundId: selection.roundId, + challengeId: selection.challengeId, + finalRowsByRoundId: roundFinalRowsByRoundId, + normalizedIdentityByCoderId, + missingMemberFinalSkipMemberIds: + missingMemberFinalSkipMemberIdsByRoundId.get(selection.roundId) || new Set(), + plannedUnattachableFinalSkipMemberIds: + plannedUnattachableFinalSkipMemberIdsByRoundId.get(selection.roundId) || new Set(), + finalScoreStore: resolvedFinalScoreStore, + updateExistingScores: true, + }); + } + + if (provisionalScoreReconciliationEnabled) { + const missingMemberProvisionalSkipMemberIdsByRoundId = + collectMissingMemberSkipMemberIdsByRoundId({ + roundIds: [selection.roundId], + planRecordByRoundId, + affectedSurface: "provisional-score", + }); + const resolvedProvisionalScoreStore = + provisionalScoreStore || + (await createReviewProvisionalScoreStore({ + reviewClient, + reviewSchema: reviewSchema || DEFAULT_REVIEW_SCHEMA, + actor, + })); + provisionalScoreReconciliation = await reconcileRoundProvisionalScores({ + roundId: selection.roundId, + challengeId: selection.challengeId, + provisionalRowsByRoundId: roundProvisionalRowsByRoundId, + normalizedIdentityByCoderId, + missingMemberProvisionalSkipMemberIds: + missingMemberProvisionalSkipMemberIdsByRoundId.get(selection.roundId) || new Set(), + provisionalScoreStore: resolvedProvisionalScoreStore, + updateExistingScores: true, + finalLegacySubmissionIdsByRoundId: roundFinalRowsByRoundId, + }); + } + } + + const placementWinners = + finalScoreReconciliationEnabled + ? buildPlacementWinnersForRound({ + roundId: selection.roundId, + finalRowsByRoundId: roundFinalRowsByRoundId, + normalizedIdentityByCoderId: targetedRerunNormalizedIdentityByCoderId || new Map(), + actor, + }) + : null; + + const hasFinalScoreWrites = Boolean( + finalScoreReconciliation && + ((finalScoreReconciliation.createdFinalScores || 0) > 0 || + (finalScoreReconciliation.updatedFinalScores || 0) > 0 || + (finalScoreReconciliation.updatedSubmissionFinalScoreSummaries || 0) > 0) + ); + const hasProvisionalScoreWrites = Boolean( + provisionalScoreReconciliation && + ((provisionalScoreReconciliation.createdProvisionalScores || 0) > 0 || + (provisionalScoreReconciliation.updatedProvisionalScores || 0) > 0) + ); + const hasScoreWrites = hasFinalScoreWrites || hasProvisionalScoreWrites; + let hasDescriptionWrite = false; + let descriptionUpdated = false; + let descriptionSource = "existing-description-preserved-no-usable-legacy-problem-text"; + let reason = "targeted-rerun-description-preserved-no-usable-legacy-problem-text"; + let status = "targeted-rerun-preserved"; + + if (descriptionCandidate) { + if ( + !prisma || + !prisma.challenge || + typeof prisma.challenge.findUnique !== "function" || + typeof prisma.challenge.update !== "function" + ) { + throw new Error( + "Targeted rerun requires Prisma challenge.findUnique and challenge.update to apply idempotent description patches." + ); + } + const nextDescription = descriptionCandidate.description; + const nextDescriptionFormat = descriptionCandidate.descriptionFormat; + const existingChallenge = await prisma.challenge.findUnique({ + where: { id: selection.challengeId }, + select: { description: true, descriptionFormat: true }, + }); + if (!existingChallenge) { + throw new Error( + `Targeted rerun challenge "${selection.challengeId}" was not found for description reconciliation.` + ); + } + const existingDescription = String( + existingChallenge.description !== null && existingChallenge.description !== undefined + ? existingChallenge.description + : "" + ); + const existingDescriptionFormat = String( + existingChallenge.descriptionFormat !== null && + existingChallenge.descriptionFormat !== undefined + ? existingChallenge.descriptionFormat + : "" + ); + const descriptionStateMatches = + existingDescription === nextDescription && + existingDescriptionFormat === nextDescriptionFormat; + + if (hasProblemTextUpdate) { + descriptionSource = "legacy-problem-text"; + reason = descriptionStateMatches + ? "targeted-rerun-description-already-matched-legacy-problem-text" + : "targeted-rerun-description-updated-from-legacy-problem-text"; + } else { + descriptionSource = "legacy-component-text-markdown"; + reason = descriptionStateMatches + ? "targeted-rerun-description-already-matched-legacy-component-text-markdown" + : "targeted-rerun-description-updated-from-legacy-component-text-markdown"; + } + + if (!descriptionStateMatches) { + await prisma.challenge.update({ + where: { id: selection.challengeId }, + data: { + description: nextDescription, + descriptionFormat: nextDescriptionFormat, + updatedBy: String(actor || "").trim() || "historical-mm-importer", + }, + select: { id: true }, + }); + hasDescriptionWrite = true; + descriptionUpdated = true; + status = "targeted-rerun-applied"; + } + } + + if (!hasDescriptionWrite && hasScoreWrites) { + status = "targeted-rerun-applied"; + } + + if (Array.isArray(placementWinners)) { + winnerReconciliation = await reconcileChallengePlacementWinners({ + prisma, + challengeId: selection.challengeId, + placementWinners, + actor, + }); + if (winnerReconciliation.updated) { + status = "targeted-rerun-applied"; + } + } + + const hasWinnerWrite = Boolean(winnerReconciliation && winnerReconciliation.updated); + const hasWritesAttempted = + hasDescriptionWrite || hasSubmissionArchiveWrites || hasScoreWrites || hasWinnerWrite; + const summaryDescriptionUpdated = descriptionUpdated ? 1 : 0; + const summaryDescriptionPreserved = descriptionUpdated ? 0 : 1; + + return { + records: [ + { + recordType: "apply-record", + legacyRoundId: selection.roundId, + status, + challengeId: selection.challengeId, + mode: "targeted-rerun", + writesAttempted: hasWritesAttempted, + descriptionUpdated, + descriptionSource, + legacyProblemId: descriptionSource === "legacy-problem-text" && legacyProblemId + ? legacyProblemId + : null, + ...(hasComponentMarkdownUpdate + ? { legacyComponentId: legacyComponentId || null } + : {}), + reason, + submissionArchiveReconciliation, + ...(submissionReconciliation ? { submissionReconciliation } : {}), + ...(finalScoreReconciliation ? { finalScoreReconciliation } : {}), + ...(provisionalScoreReconciliation ? { provisionalScoreReconciliation } : {}), + ...(winnerReconciliation ? { winnerReconciliation } : {}), + }, + ], + summary: { + recordType: "apply-summary", + created: 0, + existing: 0, + unmatched: 0, + unresolved: 0, + errors: 0, + targetedRerunValidated: 1, + targetedRerunDescriptionUpdated: summaryDescriptionUpdated, + targetedRerunDescriptionPreserved: summaryDescriptionPreserved, + targetedRerunSubmissionArchivesWritten: submissionArchiveReconciliation.archivesWritten, + targetedRerunSubmissionUrlsUpdated: submissionArchiveReconciliation.urlsUpdated, + ...(submissionReconciliation + ? { + targetedRerunSubmissionsCreated: submissionReconciliation.createdSubmissions || 0, + targetedRerunSubmissionsAlreadyPresent: + submissionReconciliation.alreadyPresentSubmissions || 0, + } + : {}), + ...(finalScoreReconciliation + ? { + targetedRerunFinalScoresCreated: finalScoreReconciliation.createdFinalScores || 0, + targetedRerunFinalScoresUpdated: finalScoreReconciliation.updatedFinalScores || 0, + ...(Object.prototype.hasOwnProperty.call( + finalScoreReconciliation, + "updatedSubmissionFinalScoreSummaries" + ) + ? { + targetedRerunSubmissionFinalScoreSummariesUpdated: + finalScoreReconciliation.updatedSubmissionFinalScoreSummaries || 0, + targetedRerunSubmissionFinalScoreSummariesAlreadyMatched: + finalScoreReconciliation.alreadyMatchedSubmissionFinalScoreSummaries || 0, + targetedRerunSubmissionFinalScoreSummariesUnsupported: + finalScoreReconciliation.unsupportedSubmissionFinalScoreSummaries || 0, + } + : {}), + } + : {}), + ...(provisionalScoreReconciliation + ? { + targetedRerunProvisionalScoresCreated: + provisionalScoreReconciliation.createdProvisionalScores || 0, + targetedRerunProvisionalScoresUpdated: + provisionalScoreReconciliation.updatedProvisionalScores || 0, + } + : {}), + ...(winnerReconciliation + ? { + targetedRerunWinnerCount: winnerReconciliation.winnerCount || 0, + targetedRerunWinnersUpdated: winnerReconciliation.updated ? 1 : 0, + } + : {}), + targetedRerunWritesAttempted: hasWritesAttempted ? 1 : 0, + skippedFileArtifact: null, + }, + }; +}; + const runApplyMode = async ({ prisma, options, plan, actor, normalizedIdentityByCoderId: providedNormalizedIdentityByCoderId, + resolveMemberIdentities, }) => { const planRecordByRoundId = new Map((plan.records || []).map((record) => [record.legacyRoundId, record])); const skippedFilePath = resolveSkippedFilePath({ @@ -737,11 +1989,17 @@ const runApplyMode = async ({ coderIds: relevantCoderIds, }); } + normalizedIdentityByCoderId = await hydrateMissingIdentityHandles({ + normalizedIdentityByCoderId, + resolveMemberIdentities, + }); let roundSubmissionRowsByRoundId = new Map(); let roundFinalRowsByRoundId = new Map(); let roundProvisionalRowsByRoundId = new Map(); let submissionStore = null; + let submissionArchiveStore = null; + let resolvedSubmissionArchiveDir = null; let finalScoreStore = null; let provisionalScoreStore = null; if (submissionImportEnabled && actionableRoundIds.length > 0) { @@ -766,6 +2024,23 @@ const runApplyMode = async ({ reviewSchema: options.reviewSchema || DEFAULT_REVIEW_SCHEMA, actor, })); + + const archiveDirCandidate = + options.submissionArchiveDir || process.env.SUBMISSION_ARCHIVE_DIR; + const submissionArchiveRequested = + Boolean(options.submissionArchiveStore) || Boolean(String(archiveDirCandidate || "").trim()); + const hasLegacySubmissionRows = Array.from(roundSubmissionRowsByRoundId.values()).some( + (rows) => Array.isArray(rows) && rows.length > 0 + ); + if (submissionArchiveRequested && hasLegacySubmissionRows) { + resolvedSubmissionArchiveDir = resolveSubmissionArchiveDirectory(archiveDirCandidate); + submissionArchiveStore = + options.submissionArchiveStore || + (await createReviewSubmissionArchiveStore({ + reviewClient: options.reviewClient, + reviewSchema: options.reviewSchema || DEFAULT_REVIEW_SCHEMA, + })); + } } if (finalScoreImportEnabled && actionableRoundIds.length > 0) { roundFinalRowsByRoundId = await loadLegacyFinalRowsByRoundId({ @@ -851,6 +2126,15 @@ const runApplyMode = async ({ } try { + const placementWinners = + finalScoreImportEnabled + ? buildPlacementWinnersForRound({ + roundId, + finalRowsByRoundId: roundFinalRowsByRoundId, + normalizedIdentityByCoderId, + actor, + }) + : null; const result = await applyCreateRound({ prisma, roundId, @@ -861,6 +2145,7 @@ const runApplyMode = async ({ dataScienceTrackId, timelineTemplateId, phaseIdsByName, + placementWinners, }); const resourceReconciliation = await reconcileSubmitterResourcesForRound({ challengeId: result.challengeId, @@ -887,6 +2172,20 @@ const runApplyMode = async ({ if (submissionReconciliation && Array.isArray(submissionReconciliation.skippedSubmissionRecords)) { runtimeSkipRecords.push(...submissionReconciliation.skippedSubmissionRecords); } + const submissionArchiveReconciliation = + submissionImportEnabled && submissionArchiveStore + ? await reconcileSubmissionArchivesForChallenge({ + challengeId: result.challengeId, + roundId, + options, + plan, + submissionArchiveStore, + reviewClient: options.reviewClient, + reviewSchema: options.reviewSchema || DEFAULT_REVIEW_SCHEMA, + legacySubmissionRowsByRoundId: roundSubmissionRowsByRoundId, + submissionArchiveDir: resolvedSubmissionArchiveDir, + }) + : null; const finalScoreReconciliation = finalScoreImportEnabled && finalScoreStore ? await reconcileRoundFinalScores({ @@ -914,6 +2213,7 @@ const runApplyMode = async ({ missingMemberProvisionalSkipMemberIds: missingMemberProvisionalSkipMemberIdsByRoundId.get(roundId) || new Set(), provisionalScoreStore, + finalLegacySubmissionIdsByRoundId: roundFinalRowsByRoundId, }) : null; if ( @@ -929,6 +2229,7 @@ const runApplyMode = async ({ challengeId: result.challengeId, resourceReconciliation, ...(submissionReconciliation ? { submissionReconciliation } : {}), + ...(submissionArchiveReconciliation ? { submissionArchiveReconciliation } : {}), ...(finalScoreReconciliation ? { finalScoreReconciliation } : {}), ...(provisionalScoreReconciliation ? { provisionalScoreReconciliation } : {}), }); @@ -1150,5 +2451,6 @@ module.exports = { resolveDataScienceTrackId, resolveCanonicalTimelineTemplateId, reconcileSubmitterResourcesForRound, + runTargetedRerunMode, runApplyMode, }; diff --git a/data-migration/src/scripts/importHistoricalMarathonMatches/argParser.js b/data-migration/src/scripts/importHistoricalMarathonMatches/argParser.js index e1c53a9..0b1e296 100644 --- a/data-migration/src/scripts/importHistoricalMarathonMatches/argParser.js +++ b/data-migration/src/scripts/importHistoricalMarathonMatches/argParser.js @@ -16,6 +16,8 @@ const DEFAULT_OPTIONS = { dryRun: true, apply: false, roundIds: [], + targetedRerun: false, + challengeId: null, help: false, }; @@ -155,6 +157,19 @@ const parseArgs = (argv) => { options.dryRun = false; continue; } + if (arg === "--targeted-rerun") { + options.targetedRerun = true; + continue; + } + if (arg === "--challenge-id") { + const value = String(requireNextValue(argv, index, "--challenge-id")).trim(); + if (!value) { + throw new Error("--challenge-id requires a value"); + } + options.challengeId = value; + index += 1; + continue; + } throw new Error(`Unknown option: ${arg}`); } @@ -164,6 +179,18 @@ const parseArgs = (argv) => { if (!options.help && options.roundIds.length === 0) { throw new Error("At least one round filter is required. Use --round-id or --round-ids."); } + if (options.targetedRerun && !options.apply) { + throw new Error("--targeted-rerun requires --apply mode."); + } + if (options.challengeId && !options.targetedRerun) { + throw new Error("--challenge-id is only supported with --targeted-rerun."); + } + if (options.targetedRerun && !options.challengeId) { + throw new Error("--targeted-rerun requires --challenge-id ."); + } + if (options.targetedRerun && options.roundIds.length !== 1) { + throw new Error("--targeted-rerun requires exactly one selected round."); + } return options; }; @@ -192,6 +219,8 @@ Input options: Apply mode: --apply Apply reconciliation writes (challenge + phase create path) + --targeted-rerun Use explicit targeted rerun patch mode (already-imported rounds only) + --challenge-id Required existing challenge-id override for --targeted-rerun Other: --help, -h Show this help diff --git a/data-migration/src/scripts/importHistoricalMarathonMatches/descriptionSourcing.js b/data-migration/src/scripts/importHistoricalMarathonMatches/descriptionSourcing.js new file mode 100644 index 0000000..ac0d2f8 --- /dev/null +++ b/data-migration/src/scripts/importHistoricalMarathonMatches/descriptionSourcing.js @@ -0,0 +1,600 @@ +"use strict"; + +const normalizeLegacyText = (value) => { + if (value === null || value === undefined) { + return null; + } + const normalized = String(value).trim(); + if (!normalized) { + return null; + } + if (normalized.toLowerCase() === "null") { + return null; + } + return normalized; +}; + +const isUsableProblemText = (value) => Boolean(normalizeLegacyText(value)); + +const decodeHtmlEntities = (value) => + String(value || "") + .replace(/</gi, "<") + .replace(/>/gi, ">") + .replace(/&/gi, "&") + .replace(/"/gi, '"') + .replace(/'/gi, "'") + .replace(/ /gi, " "); + +/** + * Decodes legacy `/ASCII123/` placeholders from Informix exports. + * + * @param {string | null | undefined} value legacy component/problem text + * @returns {string} decoded text used by the marathon importer description pipeline + * @throws {Error} This helper does not throw; unknown placeholders are preserved verbatim. + */ +const decodeLegacyAsciiPlaceholders = (value) => + String(value || "").replace(/\/ASCII(\d{1,3})\//g, (match, asciiCode) => { + const parsed = Number.parseInt(asciiCode, 10); + if (!Number.isFinite(parsed) || parsed < 0 || parsed > 127) { + return match; + } + if (parsed === 13) { + return ""; + } + return String.fromCharCode(parsed); + }); + +const stripHiddenSections = (xml) => + String(xml || "") + .replace(/]*>[\s\S]*?<\/test_cases?>/gi, " ") + .replace(/]*>[\s\S]*?<\/testcase[s]?>/gi, " ") + .replace(/]*>[\s\S]*?<\/test>/gi, " ") + .replace( + /<([a-z0-9_:-]*(?:hidden|internal|private)[a-z0-9_:-]*)\b[^>]*>[\s\S]*?<\/\1>/gi, + " " + ) + .replace(/<([a-z0-9_:-]*(?:hidden|internal|private)[a-z0-9_:-]*)\b[^>]*\/>/gi, " ") + .replace( + /]*\b(?:hidden|internal|private)\s*=\s*["']?(?:1|true|yes)["']?[^>]*>[\s\S]*?<\/test_case>/gi, + " " + ) + .replace( + /]*\b(?:public|example|sample)\s*=\s*["']?(?:0|false|no)["']?[^>]*>[\s\S]*?<\/test_case>/gi, + " " + ); + +const normalizeWhitespace = (value) => + String(value || "") + .replace(/[ \t]+\n/g, "\n") + .replace(/\n{3,}/g, "\n\n") + .trim(); + +const normalizeWhitespacePreservingCodeBlocks = (value) => + String(value || "") + .split(/(```[\s\S]*?```)/g) + .map((segment) => { + if (!segment) { + return ""; + } + if (segment.startsWith("```")) { + return segment.trim(); + } + return normalizeWhitespace(segment); + }) + .filter(Boolean) + .join("\n\n") + .replace(/\n{3,}/g, "\n\n") + .trim(); + +const MAX_COMPONENT_MARKDOWN_LENGTH = 20000; +const TOPCODER_PUBLIC_EXAMPLE_PATTERN = + /\bexample\s*=\s*["']?(?:1|true|yes)["']?/i; + +const toInlineText = (xmlLike) => + normalizeWhitespace( + decodeHtmlEntities( + decodeLegacyAsciiPlaceholders( + String(xmlLike || "") + .replace(/<[^>]+>/g, " ") + .replace(/\s+/g, " ") + ) + ) + ); + +const escapeRegExp = (value) => + String(value || "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + +/** + * Extracts the first matching XML tag body from legacy component text. + * + * @param {string | null | undefined} xml source XML from the legacy component export + * @param {string} tagName XML tag name to match, including hyphenated names + * @returns {string | null} the first matching tag body, or null when absent + * @throws {Error} This helper does not throw; malformed XML simply returns null. + */ +const extractFirstTagContent = (xml, tagName) => { + const pattern = new RegExp( + `<${escapeRegExp(tagName)}\\b[^>]*>([\\s\\S]*?)<\\/${escapeRegExp(tagName)}>`, + "i" + ); + const match = pattern.exec(String(xml || "")); + return match ? match[1] : null; +}; + +/** + * Extracts every matching XML tag body from legacy component text. + * + * @param {string | null | undefined} xml source XML from the legacy component export + * @param {string} tagName XML tag name to match, including hyphenated names + * @returns {string[]} matching tag bodies in document order for importer rendering + * @throws {Error} This helper does not throw; malformed XML yields an empty array. + */ +const extractTagContents = (xml, tagName) => { + const pattern = new RegExp( + `<${escapeRegExp(tagName)}\\b[^>]*>([\\s\\S]*?)<\\/${escapeRegExp(tagName)}>`, + "gi" + ); + return Array.from(String(xml || "").matchAll(pattern), (match) => match[1]); +}; + +/** + * Extracts full XML element blocks while preserving opening-tag attributes. + * + * @param {string | null | undefined} xml source XML from the legacy component export + * @param {string} tagName XML tag name to match, including hyphenated names + * @returns {string[]} matching XML blocks used by the importer for structured section parsing + * @throws {Error} This helper does not throw; malformed XML yields an empty array. + */ +const extractTagBlocks = (xml, tagName) => { + const pattern = new RegExp( + `<${escapeRegExp(tagName)}\\b[^>]*>[\\s\\S]*?<\\/${escapeRegExp(tagName)}>`, + "gi" + ); + return Array.from(String(xml || "").matchAll(pattern), (match) => match[0]); +}; + +const stripXmlScaffolding = (value) => + String(value || "") + .replace(//g, "") + .replace(/<\?xml[\s\S]*?\?>/gi, " ") + .replace(//g, " "); + +const stripAllTags = (value) => String(value || "").replace(/<[^>]+>/g, " "); + +const looksLikeHtmlContent = (value) => { + const normalized = normalizeLegacyText(value); + if (!normalized) { + return false; + } + return /<\/?[a-z][a-z0-9:_-]*\b[^>]*>/i.test(normalized); +}; + +const wrapSection = (heading, body) => { + const normalizedBody = normalizeWhitespacePreservingCodeBlocks(body); + if (!normalizedBody) { + return null; + } + return `## ${heading}\n\n${normalizedBody}`; +}; + +const buildCodeBlock = (value) => { + const normalized = normalizeWhitespace( + decodeHtmlEntities(decodeLegacyAsciiPlaceholders(stripAllTags(String(value || "")))) + ); + if (!normalized) { + return null; + } + return `\`\`\`text\n${normalized}\n\`\`\``; +}; + +/** + * Converts a rich XML or HTML subsection into Markdown while preserving code-style blocks. + * + * @param {string | null | undefined} value subsection text extracted from legacy component XML + * @returns {string | null} Markdown used by the marathon importer for v6 challenge descriptions + * @throws {Error} This helper does not throw; malformed markup is flattened into readable text. + */ +const convertRichTextSectionToMarkdown = (value) => { + const normalized = normalizeLegacyText(value); + if (!normalized) { + return null; + } + + const codeBlocks = []; + const stashCodeBlock = (content) => { + const token = `@@TC_CODE_BLOCK_${codeBlocks.length}@@`; + codeBlocks.push(content); + return `\n\n${token}\n\n`; + }; + + let text = stripHiddenSections(stripXmlScaffolding(normalized)); + text = decodeHtmlEntities(decodeLegacyAsciiPlaceholders(text)); + + text = text.replace(/]*>([\s\S]*?)<\/pre>/gi, (_, content) => { + const codeBlock = buildCodeBlock(content); + return codeBlock ? stashCodeBlock(codeBlock) : "\n\n"; + }); + + text = text + .replace(/\s*(?:<\/br>)?/gi, "\n") + .replace(/<(tt|code|type)\b[^>]*>([\s\S]*?)<\/\1>/gi, (_, __, content) => { + const inline = toInlineText(content); + return inline ? `\`${inline}\`` : ""; + }) + .replace(/<(b|strong)\b[^>]*>([\s\S]*?)<\/\1>/gi, (_, __, content) => { + const inline = toInlineText(content); + return inline ? `**${inline}**` : ""; + }) + .replace(/<(i|em)\b[^>]*>([\s\S]*?)<\/\1>/gi, (_, __, content) => { + const inline = toInlineText(content); + return inline ? `*${inline}*` : ""; + }) + .replace(/]*>([\s\S]*?)<\/sup>/gi, (_, content) => { + const inline = toInlineText(content); + return inline ? `^${inline}^` : ""; + }) + .replace(/]*>/gi, "\n- ") + .replace(/<\/li>/gi, "\n") + .replace(/<\/(p|div|section|example|note|item)>/gi, "\n\n") + .replace(/<(p|div|section|example|note|item)\b[^>]*>/gi, "") + .replace(/<[^>]+>/g, " ") + .replace(/[ \t]+/g, " ") + .replace(/ *\n */g, "\n"); + + let markdown = normalizeWhitespacePreservingCodeBlocks(text); + codeBlocks.forEach((codeBlock, index) => { + markdown = markdown.replace(`@@TC_CODE_BLOCK_${index}@@`, codeBlock); + }); + + return normalizeWhitespacePreservingCodeBlocks(markdown); +}; + +/** + * Builds a Markdown signature section from Topcoder problem XML. + * + * @param {string | null | undefined} signatureXml the `` block from legacy component XML + * @returns {string | null} a Markdown class/method summary for imported challenge descriptions + * @throws {Error} This helper does not throw; malformed signatures are partially rendered when possible. + */ +const buildSignatureMarkdown = (signatureXml) => { + const normalizedSignature = normalizeLegacyText(signatureXml); + if (!normalizedSignature) { + return null; + } + + const sections = []; + const className = toInlineText(extractFirstTagContent(normalizedSignature, "class")); + if (className) { + sections.push(`## Class\n\n\`${className}\``); + } + + const methodBlocks = extractTagBlocks(normalizedSignature, "method"); + const methods = methodBlocks + .map((methodBlock) => { + const methodName = toInlineText(extractFirstTagContent(methodBlock, "name")); + const returnType = toInlineText( + extractFirstTagContent(extractFirstTagContent(methodBlock, "return"), "type") + ); + const params = extractTagBlocks(extractFirstTagContent(methodBlock, "params"), "param") + .map((paramBlock) => { + const type = toInlineText(extractFirstTagContent(paramBlock, "type")); + const name = toInlineText(extractFirstTagContent(paramBlock, "name")); + if (type && name) { + return `${type} ${name}`; + } + return type || name || null; + }) + .filter(Boolean); + + if (!methodName) { + return null; + } + + const signature = `${returnType ? `${returnType} ` : ""}${methodName}(${params.join(", ")})`; + return `- \`${signature}\``; + }) + .filter(Boolean); + + if (methods.length > 0) { + sections.push(`## Methods\n\n${methods.join("\n")}`); + } + + return sections.length > 0 ? sections.join("\n\n") : null; +}; + +/** + * Builds a bullet-list Markdown section from repeated XML child tags. + * + * @param {string} heading section heading to render in the imported challenge description + * @param {string | null | undefined} xml parent XML block containing repeated child elements + * @param {string} itemTagName child tag name to extract from the parent block + * @returns {string | null} Markdown bullet list, or null when the section is empty + * @throws {Error} This helper does not throw; malformed XML yields an empty section. + */ +const buildListSectionMarkdown = (heading, xml, itemTagName) => { + const items = extractTagContents(xml, itemTagName) + .map((item) => toInlineText(item)) + .filter(Boolean) + .map((item) => `- ${item}`); + + if (items.length === 0) { + return null; + } + + return `## ${heading}\n\n${items.join("\n")}`; +}; + +/** + * Builds a Markdown examples section from legacy Topcoder `` XML. + * + * @param {string | null | undefined} testCasesXml the `` block from legacy component XML + * @returns {string | null} public example Markdown for imported challenge descriptions + * @throws {Error} This helper does not throw; malformed test-case XML is skipped. + */ +const buildExamplesMarkdown = (testCasesXml) => { + const normalized = normalizeLegacyText(testCasesXml); + if (!normalized) { + return null; + } + + const testCaseMatches = Array.from( + String(normalized).matchAll(/]*)>([\s\S]*?)<\/test-case>/gi), + (match) => ({ + attrs: match[1] || "", + body: match[2] || "", + }) + ); + if (testCaseMatches.length === 0) { + return null; + } + + const hasExplicitExamples = testCaseMatches.some((testCase) => + TOPCODER_PUBLIC_EXAMPLE_PATTERN.test(testCase.attrs) + ); + const selectedCases = hasExplicitExamples + ? testCaseMatches.filter((testCase) => TOPCODER_PUBLIC_EXAMPLE_PATTERN.test(testCase.attrs)) + : testCaseMatches; + + const renderedCases = selectedCases + .map((testCase, index) => { + const sections = [`### Example ${index + 1}`]; + const input = buildCodeBlock(extractFirstTagContent(testCase.body, "input")); + if (input) { + sections.push(`**Input**\n\n${input}`); + } + const rawOutput = extractFirstTagContent(testCase.body, "output"); + const output = + /<[a-z][a-z0-9:_-]*\b/i.test( + decodeHtmlEntities(decodeLegacyAsciiPlaceholders(String(rawOutput || ""))) + ) + ? convertRichTextSectionToMarkdown(rawOutput) + : buildCodeBlock(rawOutput); + if (output) { + sections.push(`**Output**\n\n${output}`); + } + const annotation = convertRichTextSectionToMarkdown( + extractFirstTagContent(testCase.body, "annotation") + ); + if (annotation) { + sections.push(`**Explanation**\n\n${annotation}`); + } + return sections.length > 1 ? sections.join("\n\n") : null; + }) + .filter(Boolean); + + if (renderedCases.length === 0) { + return null; + } + + return `## Examples\n\n${renderedCases.join("\n\n")}`; +}; + +const convertStructuredTopcoderProblemXmlToMarkdown = (value) => { + const normalized = normalizeLegacyText(value); + if (!normalized || !/ /memory limit/i.test(section)); + if (memLimit && !hasMemoryLimitNote) { + sections.push(`## Limits\n\n- Memory limit: ${memLimit} MB.`); + } + + return sections.length > 0 ? sections.join("\n\n") : null; +}; + +const truncateMarkdown = (markdown) => { + if (!markdown) { + return null; + } + if (!/[A-Za-z0-9]/.test(markdown)) { + return null; + } + if (markdown.length > MAX_COMPONENT_MARKDOWN_LENGTH) { + const truncated = markdown + .slice(0, MAX_COMPONENT_MARKDOWN_LENGTH) + .replace(/\s+\S*$/, "") + .trim(); + return `${truncated}\n\n...`; + } + return markdown; +}; + +const convertFallbackXmlToMarkdown = (value) => { + const normalized = normalizeLegacyText(value); + if (!normalized) { + return null; + } + + let text = stripXmlScaffolding(normalized); + text = stripHiddenSections(text); + + text = text.replace(/]*>([\s\S]*?)<\/h\1>/gi, (_, depth, content) => { + const heading = toInlineText(content); + if (!heading) { + return "\n\n"; + } + return `\n\n${"#".repeat(Number.parseInt(depth, 10))} ${heading}\n\n`; + }); + + text = text + .replace(/\s*(?:<\/br>)?/gi, "\n") + .replace(/]*>/gi, "\n- ") + .replace(/<\/li>/gi, "\n") + .replace(/<\/(p|div|section|problem_statement|statement|description|notes|example)>/gi, "\n\n") + .replace(/<\/(tr|table|ul|ol|pre|code)>/gi, "\n"); + + text = decodeHtmlEntities(decodeLegacyAsciiPlaceholders(text)) + .replace(/<[^>]+>/g, " ") + .replace(/[ \t]+/g, " ") + .replace(/ *\n */g, "\n") + .replace(/\n{3,}/g, "\n\n"); + + const lines = text + .split("\n") + .map((line) => line.trim()) + .filter((line) => { + if (!line) { + return false; + } + return !/\b(?:hidden|internal)\b.*\btest\s*case/i.test(line); + }); + + return normalizeWhitespace(lines.join("\n")) || null; +}; + +const convertComponentXmlToMarkdown = (value) => { + const markdown = + convertStructuredTopcoderProblemXmlToMarkdown(value) || + convertFallbackXmlToMarkdown(value); + return truncateMarkdown(markdown); +}; + +const isUsableComponentMarkdown = (value) => Boolean(normalizeLegacyText(value)); + +const isRenderableProblemText = (value) => + Boolean(normalizeLegacyText(value)) && looksLikeHtmlContent(value); + +/** + * Resolves the stored description body and format from planning counters. + * + * @param {object | null | undefined} counters per-round planning counters from the importer + * @returns {{ description: string, descriptionFormat: string, source: string } | null} the description payload for create/rerun writes + * @throws {Error} This helper does not throw; it returns null when no usable description candidate exists. + */ +const resolveDescriptionCandidateFromCounters = (counters) => { + const candidateProblemText = counters && counters.descriptionProblemText; + if (isUsableProblemText(candidateProblemText)) { + return { + description: String(candidateProblemText), + descriptionFormat: isRenderableProblemText(candidateProblemText) ? "html" : "markdown", + source: "legacy-problem-text", + }; + } + + const candidateComponentTextMarkdown = + counters && counters.descriptionComponentTextMarkdown; + if (isUsableComponentMarkdown(candidateComponentTextMarkdown)) { + return { + description: String(candidateComponentTextMarkdown), + descriptionFormat: "markdown", + source: "legacy-component-text-markdown", + }; + } + + return null; +}; + +const resolveDescriptionFromMappedLegacySources = ({ + componentIds = [], + componentProblemIdById = new Map(), + problemTextByProblemId = new Map(), + componentTextByComponentId = new Map(), +}) => { + const nonHtmlProblemTextFallbacks = []; + + for (const componentId of componentIds) { + const problemId = componentProblemIdById.get(componentId); + if (!problemId) { + continue; + } + const candidateProblemText = problemTextByProblemId.get(problemId); + if (!isUsableProblemText(candidateProblemText)) { + continue; + } + if (!isRenderableProblemText(candidateProblemText)) { + nonHtmlProblemTextFallbacks.push({ + source: "legacy-problem-text", + problemId, + problemText: String(candidateProblemText), + componentId: null, + componentTextMarkdown: null, + }); + continue; + } + return { + source: "legacy-problem-text", + problemId, + problemText: String(candidateProblemText), + componentId: null, + componentTextMarkdown: null, + }; + } + + for (const componentId of componentIds) { + const candidateComponentText = componentTextByComponentId.get(componentId); + const componentTextMarkdown = convertComponentXmlToMarkdown(candidateComponentText); + if (!isUsableComponentMarkdown(componentTextMarkdown)) { + continue; + } + return { + source: "legacy-component-text-markdown", + problemId: null, + problemText: null, + componentId, + componentTextMarkdown, + }; + } + + if (nonHtmlProblemTextFallbacks.length > 0) { + return nonHtmlProblemTextFallbacks[0]; + } + + return { + source: null, + problemId: null, + problemText: null, + componentId: null, + componentTextMarkdown: null, + }; +}; + +module.exports = { + isUsableProblemText, + isUsableComponentMarkdown, + isRenderableProblemText, + convertComponentXmlToMarkdown, + resolveDescriptionCandidateFromCounters, + resolveDescriptionFromMappedLegacySources, +}; diff --git a/data-migration/src/scripts/importHistoricalMarathonMatches/finalScores.js b/data-migration/src/scripts/importHistoricalMarathonMatches/finalScores.js index 7c76476..a6539e3 100644 --- a/data-migration/src/scripts/importHistoricalMarathonMatches/finalScores.js +++ b/data-migration/src/scripts/importHistoricalMarathonMatches/finalScores.js @@ -10,6 +10,9 @@ const { MISSING_MEMBER_REASON_CODE, FINALIST_WITHOUT_ATTACHABLE_SUBMISSION_REASON_CODE, } = require("./skippedArtifact"); +const { + deriveLegacySubmissionId, +} = require("./submissionHistory"); const DEFAULT_REVIEW_SCHEMA = "reviews"; @@ -60,6 +63,9 @@ const parsePlacement = (value) => { return parsed || null; }; +const hasOwnProperty = (value, propertyName) => + Boolean(value) && Object.prototype.hasOwnProperty.call(value, propertyName); + const hasAnyFinalSignal = (finalResultRow) => { const candidates = [ finalResultRow && finalResultRow.system_point_total, @@ -72,6 +78,64 @@ const hasAnyFinalSignal = (finalResultRow) => { }); }; +/** + * Checks whether an Informix final-result row explicitly marks a coder as not having + * attended the marathon round. + * + * @param {object} finalResultRow raw `long_comp_result` row from the legacy export + * @returns {boolean} true when the row has an `attended` value of `N` + * @throws Does not throw. + */ +const isExplicitlyUnattended = (finalResultRow) => + String((finalResultRow && finalResultRow.attended) || "").trim().toUpperCase() === "N"; + +/** + * Determines whether a legacy final-result row should create or update a final-score + * candidate for a round/coder pair. It is used by the marathon match importer when + * merging `long_comp_result` rows with authoritative `long_component_state` scores. + * + * @param {object} finalResultRow raw `long_comp_result` row from the legacy export + * @param {boolean} hasRankingScore whether matching `long_component_state` points exist + * @returns {boolean} true when the row contains usable final-result data + * @throws Does not throw. + */ +const shouldUseFinalResultRow = ({ finalResultRow, hasRankingScore }) => { + if (!hasAnyFinalSignal(finalResultRow)) { + return false; + } + + if (isExplicitlyUnattended(finalResultRow) && !hasRankingScore) { + return false; + } + + return true; +}; + +/** + * Derives the deterministic review submission identifier for the final + * non-example submission recorded on a legacy `long_component_state` row. + * + * @param {object} params values from the legacy state row + * @param {string|number|null} params.longComponentStateId legacy state id + * @param {string|number|null} params.submissionNumber final submission number + * @returns {string|null} deterministic legacy submission id, or null when unavailable + * @throws Does not throw. + */ +const deriveFinalLegacySubmissionId = ({ + longComponentStateId, + submissionNumber, +}) => { + const normalizedStateId = String(longComponentStateId || "").trim(); + const normalizedSubmissionNumber = parsePositiveInteger(submissionNumber); + if (!normalizedStateId || !normalizedSubmissionNumber) { + return null; + } + return deriveLegacySubmissionId({ + longComponentStateId: normalizedStateId, + submissionNumber: normalizedSubmissionNumber, + }); +}; + const resolveIdentityForCoderId = (coderId, normalizedIdentityByCoderId = new Map()) => { const normalizedCoderId = String(coderId || "").trim(); if (!normalizedCoderId) { @@ -99,11 +163,22 @@ const resolveIdentityForCoderId = (coderId, normalizedIdentityByCoderId = new Ma }; const deriveFinalScore = ({ systemPointTotal, pointTotal, rankingScore }) => { - if (Number.isFinite(systemPointTotal)) { - return { aggregateScore: systemPointTotal, scoreSource: "system_point_total" }; + const preferredLegacyScore = Number.isFinite(systemPointTotal) + ? { aggregateScore: systemPointTotal, scoreSource: "system_point_total" } + : Number.isFinite(pointTotal) + ? { aggregateScore: pointTotal, scoreSource: "point_total" } + : { aggregateScore: null, scoreSource: null }; + + if (Number.isFinite(rankingScore)) { + if ( + !Number.isFinite(preferredLegacyScore.aggregateScore) || + preferredLegacyScore.aggregateScore !== rankingScore + ) { + return { aggregateScore: rankingScore, scoreSource: "ranking_score" }; + } } - if (Number.isFinite(pointTotal)) { - return { aggregateScore: pointTotal, scoreSource: "point_total" }; + if (Number.isFinite(preferredLegacyScore.aggregateScore)) { + return preferredLegacyScore; } if (Number.isFinite(rankingScore)) { return { aggregateScore: rankingScore, scoreSource: "ranking_score" }; @@ -196,6 +271,9 @@ const loadLegacyFinalRowsByRoundId = async ({ ); const rankingScoreByRoundCoder = new Map(); + const finalSubmissionByRoundCoder = new Map(); + const resultRowByRoundCoder = new Map(); + const roundCoderKeys = new Set(); await streamJsonArray(longComponentStatePath, "long_component_state", (row) => { const roundId = String(row && row.round_id ? row.round_id : "").trim(); if (!selectedRoundIdSet.has(roundId)) { @@ -209,7 +287,24 @@ const loadLegacyFinalRowsByRoundId = async ({ if (!Number.isFinite(points)) { return; } - rankingScoreByRoundCoder.set(`${roundId}:${coderId}`, points); + const roundCoderKey = `${roundId}:${coderId}`; + rankingScoreByRoundCoder.set(roundCoderKey, points); + const longComponentStateId = String( + row && row.long_component_state_id ? row.long_component_state_id : "" + ).trim(); + const submissionNumber = parsePositiveInteger(row && row.submission_number); + const legacySubmissionId = deriveFinalLegacySubmissionId({ + longComponentStateId, + submissionNumber, + }); + if (legacySubmissionId) { + finalSubmissionByRoundCoder.set(roundCoderKey, { + longComponentStateId, + submissionNumber, + legacySubmissionId, + }); + } + roundCoderKeys.add(roundCoderKey); }); await Promise.all( @@ -219,9 +314,6 @@ const loadLegacyFinalRowsByRoundId = async ({ if (!selectedRoundIdSet.has(roundId)) { return; } - if (!hasAnyFinalSignal(row)) { - return; - } const coderId = String(row && row.coder_id ? row.coder_id : "").trim(); if (!coderId) { return; @@ -229,27 +321,57 @@ const loadLegacyFinalRowsByRoundId = async ({ const systemPointTotal = parseNumericScore(row && row.system_point_total); const pointTotal = parseNumericScore(row && row.point_total); - const rankingScore = rankingScoreByRoundCoder.get(`${roundId}:${coderId}`) ?? null; - const { aggregateScore, scoreSource } = deriveFinalScore({ - systemPointTotal, - pointTotal, - rankingScore, - }); - - rowsByRoundId.get(roundId).push({ - legacyRoundId: roundId, - coderId, + const roundCoderKey = `${roundId}:${coderId}`; + if ( + !shouldUseFinalResultRow({ + finalResultRow: row, + hasRankingScore: rankingScoreByRoundCoder.has(roundCoderKey), + }) + ) { + return; + } + roundCoderKeys.add(roundCoderKey); + resultRowByRoundCoder.set(roundCoderKey, { legacyPlacement: parsePlacement(row && row.placed), - aggregateScore, - scoreSource, systemPointTotal, pointTotal, - rankingScore, }); }) ) ); + roundCoderKeys.forEach((roundCoderKey) => { + const separatorIndex = roundCoderKey.indexOf(":"); + const roundId = separatorIndex >= 0 ? roundCoderKey.slice(0, separatorIndex) : ""; + const coderId = separatorIndex >= 0 ? roundCoderKey.slice(separatorIndex + 1) : ""; + if (!roundId || !coderId || !rowsByRoundId.has(roundId)) { + return; + } + + const resultRow = resultRowByRoundCoder.get(roundCoderKey) || {}; + const rankingScore = rankingScoreByRoundCoder.get(roundCoderKey) ?? null; + const finalSubmission = finalSubmissionByRoundCoder.get(roundCoderKey) || {}; + const { aggregateScore, scoreSource } = deriveFinalScore({ + systemPointTotal: resultRow.systemPointTotal ?? null, + pointTotal: resultRow.pointTotal ?? null, + rankingScore, + }); + + rowsByRoundId.get(roundId).push({ + legacyRoundId: roundId, + coderId, + legacyPlacement: resultRow.legacyPlacement ?? null, + aggregateScore, + scoreSource, + systemPointTotal: resultRow.systemPointTotal ?? null, + pointTotal: resultRow.pointTotal ?? null, + rankingScore, + longComponentStateId: finalSubmission.longComponentStateId || null, + submissionNumber: finalSubmission.submissionNumber || null, + legacySubmissionId: finalSubmission.legacySubmissionId || null, + }); + }); + rowsByRoundId.forEach((rows, roundId) => { rowsByRoundId.set( roundId, @@ -323,6 +445,209 @@ const buildLatestImportedSubmissionByMemberId = (submissions = []) => { return latestByMemberId; }; +/** + * Builds a lookup of imported review submissions by deterministic legacy + * submission id so final scores can attach to the exact legacy submission that + * produced the final marathon result. + * + * @param {Array} submissions imported review submission rows + * @returns {Map} imported submissions keyed by legacySubmissionId + * @throws Does not throw. + */ +const buildImportedSubmissionByLegacySubmissionId = (submissions = []) => { + const byLegacySubmissionId = new Map(); + + submissions.forEach((submission) => { + const legacySubmissionId = String( + submission && submission.legacySubmissionId ? submission.legacySubmissionId : "" + ).trim(); + const submissionId = String(submission && submission.id ? submission.id : "").trim(); + if (!legacySubmissionId || !submissionId) { + return; + } + byLegacySubmissionId.set(legacySubmissionId, submission); + }); + + return byLegacySubmissionId; +}; + +/** + * Builds a lookup from review submission id to imported submission rows for + * matching existing final summations back to their member. + * + * @param {Array} submissions imported review submission rows + * @returns {Map} imported submissions keyed by submission id + * @throws Does not throw. + */ +const buildImportedSubmissionById = (submissions = []) => { + const byId = new Map(); + + submissions.forEach((submission) => { + const submissionId = String(submission && submission.id ? submission.id : "").trim(); + if (submissionId) { + byId.set(submissionId, submission); + } + }); + + return byId; +}; + +/** + * Finds existing final summations attached to any imported submission for a + * member. Targeted reruns use this to repair historical final scores that were + * previously attached to the latest submission guess instead of the explicit + * final legacy submission. + * + * @param {object} params lookup inputs + * @param {string} params.memberId normalized member id + * @param {Map} params.importedSubmissionById submissions keyed by id + * @param {Map>} params.existingFinalSummationsBySubmissionId existing summations keyed by submission id + * @returns {Array} existing summations with their submission context + * @throws Does not throw. + */ +const findExistingFinalSummationsForMember = ({ + memberId, + importedSubmissionById, + existingFinalSummationsBySubmissionId, +}) => { + const normalizedMemberId = normalizeMemberId(memberId); + if (!normalizedMemberId) { + return []; + } + + const matches = []; + existingFinalSummationsBySubmissionId.forEach((summations, submissionId) => { + const importedSubmission = importedSubmissionById.get(submissionId); + if (normalizeMemberId(importedSubmission && importedSubmission.memberId) !== normalizedMemberId) { + return; + } + (summations || []).forEach((summation) => { + matches.push({ + submissionId, + importedSubmission, + summation, + }); + }); + }); + return matches; +}; + +/** + * Selects the review submission that should receive a legacy final score. The + * authoritative `long_component_state.submission_number` match wins; the latest + * imported non-example submission is retained as a fallback for exports that do + * not carry a final submission number. + * + * @param {object} params lookup inputs + * @param {object} params.finalRow normalized legacy final score row + * @param {string} params.memberId normalized member id + * @param {Map} params.importedSubmissionByLegacySubmissionId submissions keyed by legacySubmissionId + * @param {Map} params.latestImportedSubmissionByMemberId latest submissions keyed by member id + * @returns {object|null} imported submission to attach, or null + * @throws Does not throw. + */ +const selectAttachableFinalSubmission = ({ + finalRow, + memberId, + importedSubmissionByLegacySubmissionId, + latestImportedSubmissionByMemberId, +}) => { + const legacySubmissionId = String( + finalRow && finalRow.legacySubmissionId ? finalRow.legacySubmissionId : "" + ).trim(); + if (legacySubmissionId) { + const exactSubmission = + importedSubmissionByLegacySubmissionId.get(legacySubmissionId); + if ( + exactSubmission && + normalizeMemberId(exactSubmission.memberId) === normalizeMemberId(memberId) + ) { + return exactSubmission; + } + } + + return latestImportedSubmissionByMemberId.get(memberId) || null; +}; + +/** + * Mirrors the imported marathon final score onto the review submission summary + * columns used by submission-list APIs. The review summation remains the + * detailed score source, while `submission.finalScore`, `submission.placement`, + * and `submission.userRank` keep historical Marathon Match rows visible and + * sortable in the submissions UI. + * + * @param {object} params sync inputs + * @param {object} params.finalScoreStore store that may update submission score summaries + * @param {object} params.attachableSubmission imported submission receiving the final score + * @param {object} params.finalRow normalized legacy final score row + * @returns {Promise} update status: updated, already-matched, or unsupported + * @throws Propagates errors from `finalScoreStore.updateSubmissionFinalScoreSummary`. + */ +const syncSubmissionFinalScoreSummary = async ({ + finalScoreStore, + attachableSubmission, + finalRow, +}) => { + if ( + !finalScoreStore || + typeof finalScoreStore.updateSubmissionFinalScoreSummary !== "function" + ) { + return "unsupported"; + } + + const submissionId = String( + attachableSubmission && attachableSubmission.id ? attachableSubmission.id : "" + ).trim(); + if (!submissionId || !Number.isFinite(finalRow && finalRow.aggregateScore)) { + return "unsupported"; + } + + const desiredFinalScore = finalRow.aggregateScore; + const desiredPlacement = Number.isFinite(finalRow.legacyPlacement) + ? finalRow.legacyPlacement + : null; + const desiredUserRank = desiredPlacement; + const hasFinalScoreSnapshot = hasOwnProperty(attachableSubmission, "finalScore"); + const hasPlacementSnapshot = hasOwnProperty(attachableSubmission, "placement"); + const hasUserRankSnapshot = hasOwnProperty(attachableSubmission, "userRank"); + const finalScoreMatches = + hasFinalScoreSnapshot && + parseNumericScore(attachableSubmission.finalScore) === desiredFinalScore; + const placementMatches = + hasPlacementSnapshot && + parsePlacement(attachableSubmission.placement) === desiredPlacement; + const userRankMatches = + hasUserRankSnapshot && + parsePlacement(attachableSubmission.userRank) === desiredUserRank; + + const hasAnyScoreSummarySnapshot = + hasFinalScoreSnapshot || hasPlacementSnapshot || hasUserRankSnapshot; + const scoreSummarySnapshotMatches = + (!hasFinalScoreSnapshot || finalScoreMatches) && + (!hasPlacementSnapshot || placementMatches) && + (!hasUserRankSnapshot || userRankMatches); + if (hasAnyScoreSummarySnapshot && scoreSummarySnapshotMatches) { + return "already-matched"; + } + + const updated = await finalScoreStore.updateSubmissionFinalScoreSummary({ + submissionId, + finalScore: desiredFinalScore, + placement: desiredPlacement, + userRank: desiredUserRank, + }); + if (updated === false) { + return "unsupported"; + } + attachableSubmission.finalScore = desiredFinalScore; + attachableSubmission.placement = desiredPlacement; + attachableSubmission.userRank = desiredUserRank; + return "updated"; +}; + +const hasMatchingAggregateScore = (existingSummations = [], aggregateScore) => + existingSummations.every((summation) => summation.aggregateScore === aggregateScore); + const reconcileRoundFinalScores = async ({ roundId, challengeId, @@ -331,6 +656,7 @@ const reconcileRoundFinalScores = async ({ missingMemberFinalSkipMemberIds = new Set(), plannedUnattachableFinalSkipMemberIds = new Set(), finalScoreStore, + updateExistingScores = false, }) => { if ( !finalScoreStore || @@ -350,6 +676,9 @@ const reconcileRoundFinalScores = async ({ const latestImportedSubmissionByMemberId = buildLatestImportedSubmissionByMemberId( importedSubmissions ); + const importedSubmissionByLegacySubmissionId = + buildImportedSubmissionByLegacySubmissionId(importedSubmissions); + const importedSubmissionById = buildImportedSubmissionById(importedSubmissions); const existingFinalSummationsBySubmissionId = await finalScoreStore.listExistingFinalSummationsBySubmissionId({ challengeId, @@ -367,9 +696,22 @@ const reconcileRoundFinalScores = async ({ let createdFinalScores = 0; let alreadyPresentFinalScores = 0; + let updatedFinalScores = 0; + let updatedSubmissionFinalScoreSummaries = 0; + let alreadyMatchedSubmissionFinalScoreSummaries = 0; + let unsupportedSubmissionFinalScoreSummaries = 0; let missingMemberSkippedFinalScores = 0; let explicitSkippedFinalScores = 0; const runtimeSkipRecords = []; + const recordSubmissionFinalScoreSummarySync = (status) => { + if (status === "updated") { + updatedSubmissionFinalScoreSummaries += 1; + } else if (status === "already-matched") { + alreadyMatchedSubmissionFinalScoreSummaries += 1; + } else if (status === "unsupported") { + unsupportedSubmissionFinalScoreSummaries += 1; + } + }; for (const finalRow of legacyFinalRows) { const identity = resolveIdentityForCoderId( @@ -383,7 +725,12 @@ const reconcileRoundFinalScores = async ({ continue; } - const attachableSubmission = latestImportedSubmissionByMemberId.get(memberId); + const attachableSubmission = selectAttachableFinalSubmission({ + finalRow, + memberId, + importedSubmissionByLegacySubmissionId, + latestImportedSubmissionByMemberId, + }); if (!attachableSubmission) { explicitSkippedFinalScores += 1; if (!plannedUnattachableMemberIds.has(memberId)) { @@ -411,7 +758,131 @@ const reconcileRoundFinalScores = async ({ const submissionId = String(attachableSubmission.id || "").trim(); const existingFinalSummations = existingFinalSummationsBySubmissionId.get(submissionId) || []; + const misplacedExistingFinalSummations = + updateExistingScores && existingFinalSummations.length === 0 && finalRow.legacySubmissionId + ? findExistingFinalSummationsForMember({ + memberId, + importedSubmissionById, + existingFinalSummationsBySubmissionId, + }).filter((entry) => entry.submissionId !== submissionId) + : []; + if (misplacedExistingFinalSummations.length > 0) { + if (typeof finalScoreStore.updateFinalSummation !== "function") { + throw new Error( + "finalScoreStore must provide updateFinalSummation when updateExistingScores is enabled." + ); + } + await Promise.all( + misplacedExistingFinalSummations.map(({ summation }) => + finalScoreStore.updateFinalSummation({ + reviewSummationId: summation.id, + submissionId, + aggregateScore: finalRow.aggregateScore, + isPassing: finalRow.aggregateScore > 0, + reviewedDate: + attachableSubmission.submittedDate || + attachableSubmission.createdAt || + null, + legacySubmissionId: + attachableSubmission.legacySubmissionId || null, + isFinal: true, + isExample: false, + metadata: { + legacyRoundId: roundId, + legacyCoderId: finalRow.coderId, + scoreSource: finalRow.scoreSource, + legacyPlacement: finalRow.legacyPlacement, + rawLegacyPlacement: finalRow.rawLegacyPlacement, + }, + }) + ) + ); + misplacedExistingFinalSummations.forEach(({ submissionId: oldSubmissionId, summation }) => { + const existingForOldSubmission = + existingFinalSummationsBySubmissionId.get(oldSubmissionId) || []; + existingFinalSummationsBySubmissionId.set( + oldSubmissionId, + existingForOldSubmission.filter((existingSummation) => existingSummation !== summation) + ); + }); + existingFinalSummationsBySubmissionId.set( + submissionId, + misplacedExistingFinalSummations.map(({ summation }) => ({ + ...summation, + submissionId, + aggregateScore: finalRow.aggregateScore, + })) + ); + recordSubmissionFinalScoreSummarySync( + await syncSubmissionFinalScoreSummary({ + finalScoreStore, + attachableSubmission, + finalRow, + }) + ); + updatedFinalScores += 1; + continue; + } + if (existingFinalSummations.length > 0) { + if ( + updateExistingScores && + !hasMatchingAggregateScore(existingFinalSummations, finalRow.aggregateScore) + ) { + if (typeof finalScoreStore.updateFinalSummation !== "function") { + throw new Error( + "finalScoreStore must provide updateFinalSummation when updateExistingScores is enabled." + ); + } + await Promise.all( + existingFinalSummations.map((existingFinalSummation) => + finalScoreStore.updateFinalSummation({ + reviewSummationId: existingFinalSummation.id, + submissionId, + aggregateScore: finalRow.aggregateScore, + isPassing: finalRow.aggregateScore > 0, + reviewedDate: + attachableSubmission.submittedDate || + attachableSubmission.createdAt || + null, + legacySubmissionId: + attachableSubmission.legacySubmissionId || null, + isFinal: true, + isExample: false, + metadata: { + legacyRoundId: roundId, + legacyCoderId: finalRow.coderId, + scoreSource: finalRow.scoreSource, + legacyPlacement: finalRow.legacyPlacement, + rawLegacyPlacement: finalRow.rawLegacyPlacement, + }, + }) + ) + ); + existingFinalSummationsBySubmissionId.set( + submissionId, + existingFinalSummations.map((existingFinalSummation) => ({ + ...existingFinalSummation, + aggregateScore: finalRow.aggregateScore, + })) + ); + recordSubmissionFinalScoreSummarySync( + await syncSubmissionFinalScoreSummary({ + finalScoreStore, + attachableSubmission, + finalRow, + }) + ); + updatedFinalScores += 1; + continue; + } + recordSubmissionFinalScoreSummarySync( + await syncSubmissionFinalScoreSummary({ + finalScoreStore, + attachableSubmission, + finalRow, + }) + ); alreadyPresentFinalScores += 1; continue; } @@ -436,6 +907,13 @@ const reconcileRoundFinalScores = async ({ rawLegacyPlacement: finalRow.rawLegacyPlacement, }, }); + recordSubmissionFinalScoreSummarySync( + await syncSubmissionFinalScoreSummary({ + finalScoreStore, + attachableSubmission, + finalRow, + }) + ); existingFinalSummationsBySubmissionId.set(submissionId, [ { submissionId, @@ -445,15 +923,27 @@ const reconcileRoundFinalScores = async ({ createdFinalScores += 1; } - return { + const result = { legacyFinalCandidates: legacyFinalRows.length, - importedFinalScores: createdFinalScores + alreadyPresentFinalScores, + importedFinalScores: createdFinalScores + updatedFinalScores + alreadyPresentFinalScores, alreadyPresentFinalScores, createdFinalScores, missingMemberSkippedFinalScores, explicitSkippedFinalScores, runtimeSkipRecords, }; + if (updateExistingScores) { + result.updatedFinalScores = updatedFinalScores; + } + if (typeof finalScoreStore.updateSubmissionFinalScoreSummary === "function") { + result.updatedSubmissionFinalScoreSummaries = + updatedSubmissionFinalScoreSummaries; + result.alreadyMatchedSubmissionFinalScoreSummaries = + alreadyMatchedSubmissionFinalScoreSummaries; + result.unsupportedSubmissionFinalScoreSummaries = + unsupportedSubmissionFinalScoreSummaries; + } + return result; }; const createReviewFinalScoreStore = async ({ @@ -518,6 +1008,15 @@ const createReviewFinalScoreStore = async ({ if (submissionColumnsByName.has("isExample")) { selectedColumns.push(`"isExample"`); } + if (submissionColumnsByName.has("finalScore")) { + selectedColumns.push(`"finalScore"`); + } + if (submissionColumnsByName.has("placement")) { + selectedColumns.push(`"placement"`); + } + if (submissionColumnsByName.has("userRank")) { + selectedColumns.push(`"userRank"`); + } const whereClauses = [`"challengeId" = $1`, `"legacySubmissionId" IS NOT NULL`]; if (submissionColumnsByName.has("isExample")) { @@ -541,6 +1040,15 @@ const createReviewFinalScoreStore = async ({ submittedDate: row && row.submittedDate ? row.submittedDate : null, createdAt: row && row.createdAt ? row.createdAt : null, isExample: Boolean(row && row.isExample), + ...(submissionColumnsByName.has("finalScore") + ? { finalScore: parseNumericScore(row && row.finalScore) } + : {}), + ...(submissionColumnsByName.has("placement") + ? { placement: parsePlacement(row && row.placement) } + : {}), + ...(submissionColumnsByName.has("userRank") + ? { userRank: parsePlacement(row && row.userRank) } + : {}), })) .filter( (row) => row.id && row.memberId && row.legacySubmissionId && row.isExample !== true @@ -556,7 +1064,8 @@ const createReviewFinalScoreStore = async ({ whereClauses.push(`COALESCE(rs."isExample", false) = false`); } const rows = await reviewClient.$queryRawUnsafe( - `SELECT rs."submissionId" AS "submissionId", + `SELECT ${reviewSummationColumnsByName.has("id") ? 'rs."id" AS "id",' : ""} + rs."submissionId" AS "submissionId", rs."aggregateScore" AS "aggregateScore" FROM ${reviewSummationTable} rs INNER JOIN ${submissionTable} s ON s."id" = rs."submissionId" @@ -574,6 +1083,7 @@ const createReviewFinalScoreStore = async ({ bySubmissionId.set(submissionId, []); } bySubmissionId.get(submissionId).push({ + id: String(row && row.id ? row.id : "").trim() || null, submissionId, aggregateScore: parseNumericScore(row && row.aggregateScore), }); @@ -606,6 +1116,9 @@ const createReviewFinalScoreStore = async ({ if (reviewSummationColumnsByName.has("isFinal")) { pushColumn("isFinal", Boolean(isFinal)); } + if (reviewSummationColumnsByName.has("isProvisional")) { + pushColumn("isProvisional", !isFinal && !isExample); + } if (reviewSummationColumnsByName.has("reviewedDate") && reviewedDate) { pushColumn("reviewedDate", reviewedDate); } @@ -639,10 +1152,139 @@ const createReviewFinalScoreStore = async ({ ); }; + /** + * Updates denormalized score fields on `reviews.submission` so imported + * historical Marathon Match submissions show their final score, placement, + * and user rank in submission-list APIs. + * + * @param {Object} params update values + * @param {string} params.submissionId review submission id + * @param {number} params.finalScore final aggregate score from legacy results + * @param {number|null} params.placement legacy placement, or null when not authoritative + * @param {number|null} params.userRank legacy user rank, or null when not authoritative + * @returns {Promise} true when at least one supported column was updated + * @throws {Error} when submissionId is blank + */ + const updateSubmissionFinalScoreSummary = async ({ + submissionId, + finalScore, + placement, + userRank, + }) => { + const normalizedSubmissionId = String(submissionId || "").trim(); + if (!normalizedSubmissionId) { + throw new Error("updateSubmissionFinalScoreSummary requires submissionId."); + } + + const assignments = []; + const values = []; + const pushAssignment = (columnName, value) => { + values.push(value); + assignments.push(`"${columnName}" = $${values.length}`); + }; + + if (submissionColumnsByName.has("finalScore")) { + pushAssignment("finalScore", finalScore); + } + if (submissionColumnsByName.has("placement")) { + pushAssignment("placement", placement); + } + if (submissionColumnsByName.has("userRank")) { + pushAssignment("userRank", userRank); + } + if (assignments.length === 0) { + return false; + } + if (submissionColumnsByName.has("updatedBy")) { + pushAssignment("updatedBy", actor); + } + if (submissionColumnsByName.has("updatedAt")) { + pushAssignment("updatedAt", new Date()); + } + values.push(normalizedSubmissionId); + + await reviewClient.$queryRawUnsafe( + `UPDATE ${submissionTable} + SET ${assignments.join(", ")} + WHERE "id" = $${values.length}`, + ...values + ); + return true; + }; + + const updateFinalSummation = async ({ + reviewSummationId, + submissionId, + aggregateScore, + isPassing, + reviewedDate, + legacySubmissionId, + isFinal = true, + isExample = false, + metadata = null, + }) => { + const normalizedReviewSummationId = String(reviewSummationId || "").trim(); + if (!normalizedReviewSummationId) { + throw new Error("updateFinalSummation requires reviewSummationId."); + } + if (!reviewSummationColumnsByName.has("id")) { + throw new Error( + `Review reviewSummation table ${schema}.reviewSummation must expose id for targeted score reruns.` + ); + } + + const assignments = []; + const values = []; + const pushAssignment = (columnName, value) => { + values.push(value); + assignments.push(`"${columnName}" = $${values.length}`); + }; + + if (submissionId) { + pushAssignment("submissionId", submissionId); + } + pushAssignment("aggregateScore", aggregateScore); + pushAssignment("isPassing", Boolean(isPassing)); + if (reviewSummationColumnsByName.has("isFinal")) { + pushAssignment("isFinal", Boolean(isFinal)); + } + if (reviewSummationColumnsByName.has("isProvisional")) { + pushAssignment("isProvisional", !isFinal && !isExample); + } + if (reviewSummationColumnsByName.has("reviewedDate")) { + pushAssignment("reviewedDate", reviewedDate || null); + } + if (reviewSummationColumnsByName.has("legacySubmissionId")) { + pushAssignment("legacySubmissionId", legacySubmissionId ? String(legacySubmissionId) : null); + } + if (reviewSummationColumnsByName.has("isExample")) { + pushAssignment("isExample", Boolean(isExample)); + } + if (reviewSummationColumnsByName.has("metadata")) { + pushAssignment("metadata", metadata); + } + if (reviewSummationColumnsByName.has("updatedBy")) { + pushAssignment("updatedBy", actor); + } + if (reviewSummationColumnsByName.has("updatedAt")) { + pushAssignment("updatedAt", reviewedDate || new Date()); + } + values.push(normalizedReviewSummationId); + + await reviewClient.$queryRawUnsafe( + `UPDATE ${reviewSummationTable} + SET ${assignments.join(", ")} + WHERE "id" = $${values.length}`, + ...values + ); + }; + return { listImportedNonExampleSubmissionsByChallenge, listExistingFinalSummationsBySubmissionId, createFinalSummation, + updateFinalSummation, + updateSubmissionFinalScoreSummary, }; }; diff --git a/data-migration/src/scripts/importHistoricalMarathonMatches/planning.js b/data-migration/src/scripts/importHistoricalMarathonMatches/planning.js index 0d673cc..5c07857 100644 --- a/data-migration/src/scripts/importHistoricalMarathonMatches/planning.js +++ b/data-migration/src/scripts/importHistoricalMarathonMatches/planning.js @@ -23,11 +23,18 @@ const { const { TARGET_MEMBER_RESOLUTION_UNAVAILABLE_REASON, } = require("./targetMemberResolution"); +const { + resolveDescriptionFromMappedLegacySources, +} = require("./descriptionSourcing"); const createEmptyCounters = () => ({ round: null, componentIds: new Set(), problemIds: new Set(), + descriptionProblemId: null, + descriptionProblemText: null, + descriptionComponentId: null, + descriptionComponentTextMarkdown: null, eligibleRegistrants: new Set(), nonExampleSubmissions: 0, exampleSubmissions: 0, @@ -1055,6 +1062,10 @@ const readLegacyPlanningInputs = async (options, roundDataById) => { const selectedRoundIdSet = new Set(roundDataById.keys()); const selectedComponentIds = new Set(); + const selectedProblemIds = new Set(); + const componentProblemIdById = new Map(); + const componentTextByComponentId = new Map(); + const problemTextByProblemId = new Map(); const longComponentStateById = new Map(); const stateSubmissionSummaryById = new Map(); @@ -1085,9 +1096,16 @@ const readLegacyPlanningInputs = async (options, roundDataById) => { return; } const problemId = String(row && row.problem_id ? row.problem_id : "").trim(); + const componentText = + row && Object.prototype.hasOwnProperty.call(row, "component_text") + ? row.component_text + : null; + componentTextByComponentId.set(componentId, componentText); if (!problemId) { return; } + componentProblemIdById.set(componentId, problemId); + selectedProblemIds.add(problemId); for (const counters of roundDataById.values()) { if (counters.componentIds.has(componentId)) { counters.problemIds.add(problemId); @@ -1095,6 +1113,36 @@ const readLegacyPlanningInputs = async (options, roundDataById) => { } }); + await streamJsonArray(fixedFiles.problem, "problem", (row) => { + const problemId = String(row && row.problem_id ? row.problem_id : "").trim(); + if (!selectedProblemIds.has(problemId)) { + return; + } + const rawProblemText = + row && Object.prototype.hasOwnProperty.call(row, "problem_text") + ? row.problem_text + : null; + problemTextByProblemId.set(problemId, rawProblemText); + }); + + for (const counters of roundDataById.values()) { + counters.descriptionProblemId = null; + counters.descriptionProblemText = null; + counters.descriptionComponentId = null; + counters.descriptionComponentTextMarkdown = null; + + const descriptionSource = resolveDescriptionFromMappedLegacySources({ + componentIds: sortIds(counters.componentIds), + componentProblemIdById, + problemTextByProblemId, + componentTextByComponentId, + }); + counters.descriptionProblemId = descriptionSource.problemId; + counters.descriptionProblemText = descriptionSource.problemText; + counters.descriptionComponentId = descriptionSource.componentId; + counters.descriptionComponentTextMarkdown = descriptionSource.componentTextMarkdown; + } + await Promise.all( roundRegistrationFiles.map((filePath) => streamJsonArray(filePath, "round_registration", (row) => { @@ -1136,6 +1184,10 @@ const readLegacyPlanningInputs = async (options, roundDataById) => { roundId, coderId, }); + const rankingScore = Number.parseFloat(String(row && row.points ? row.points : "").trim()); + if (coderId && Number.isFinite(rankingScore)) { + roundDataById.get(roundId).finalCandidateCoderIds.add(coderId); + } stateSubmissionSummaryById.set(longComponentStateId, { roundId, coderId, diff --git a/data-migration/src/scripts/importHistoricalMarathonMatches/provisionalScores.js b/data-migration/src/scripts/importHistoricalMarathonMatches/provisionalScores.js index bd7ce68..747ae30 100644 --- a/data-migration/src/scripts/importHistoricalMarathonMatches/provisionalScores.js +++ b/data-migration/src/scripts/importHistoricalMarathonMatches/provisionalScores.js @@ -9,6 +9,7 @@ const { const { deriveLegacySubmissionId } = require("./submissionHistory"); const { MISSING_MEMBER_REASON_CODE, + MALFORMED_PROVISIONAL_SCORE_REASON_CODE, } = require("./skippedArtifact"); const DEFAULT_REVIEW_SCHEMA = "reviews"; @@ -55,6 +56,11 @@ const parseNumericScore = (value) => { return parsed; }; +const parsePlacement = (value) => { + const parsed = parsePositiveInteger(value); + return parsed || null; +}; + const normalizeMemberId = (value) => { const parsed = parsePositiveInteger(value); if (!parsed) { @@ -133,6 +139,51 @@ const normalizeCoderIdSetByRoundId = (value) => { return byRoundId; }; +/** + * Normalizes the explicit final legacy submission ids by legacy round. Targeted + * score reruns use this marker to repair old provisional rows that were + * mistakenly imported as final review summations. + * + * @param {Map>} value map keyed by round id; each + * entry may contain legacy submission ids or final-row objects with a + * `legacySubmissionId` field + * @returns {Map>} normalized legacy submission ids by round + * @throws Does not throw. + */ +const normalizeLegacySubmissionIdSetByRoundId = (value) => { + const byRoundId = new Map(); + if (!(value instanceof Map)) { + return byRoundId; + } + + value.forEach((submissionIdsOrRows, roundId) => { + const normalizedRoundId = String(roundId || "").trim(); + if (!normalizedRoundId) { + return; + } + + const normalizedSubmissionIds = new Set( + Array.from(submissionIdsOrRows || []) + .map((submissionIdOrRow) => { + if ( + submissionIdOrRow && + typeof submissionIdOrRow === "object" && + !Array.isArray(submissionIdOrRow) + ) { + return String(submissionIdOrRow.legacySubmissionId || "").trim(); + } + return String(submissionIdOrRow || "").trim(); + }) + .filter(Boolean) + ); + if (normalizedSubmissionIds.size > 0) { + byRoundId.set(normalizedRoundId, normalizedSubmissionIds); + } + }); + + return byRoundId; +}; + const selectLaterProvisionalRow = (currentRow, candidateRow) => { if (!currentRow) { return candidateRow || null; @@ -150,6 +201,36 @@ const formatImportedCountsByMemberId = (countsByMemberId) => ) ); +const hasMatchingAggregateScore = (existingSummations = [], aggregateScore) => + existingSummations.every((summation) => summation.aggregateScore === aggregateScore); + +const hasOwnProperty = (value, propertyName) => + Boolean(value) && Object.prototype.hasOwnProperty.call(value, propertyName); + +const hasNonBlankScoreSummaryValue = (value) => { + if (value === null || value === undefined) { + return false; + } + const normalized = String(value).trim().toLowerCase(); + return Boolean(normalized && normalized !== "null"); +}; + +/** + * Checks whether an imported review submission still has denormalized final + * score summary fields populated. Targeted reruns use this as a cheap snapshot + * guard before clearing stale final-score display data from non-final MM rows. + * + * @param {object} submission imported review submission row + * @returns {boolean} true when finalScore, placement, or userRank is present + * @throws Does not throw. + */ +const hasSubmissionFinalScoreSummary = (submission) => + ["finalScore", "placement", "userRank"].some( + (propertyName) => + hasOwnProperty(submission, propertyName) && + hasNonBlankScoreSummaryValue(submission[propertyName]) + ); + const loadLegacyProvisionalRowsByRoundId = async ({ dataDir, longComponentStateFile, @@ -300,6 +381,8 @@ const reconcileRoundProvisionalScores = async ({ normalizedIdentityByCoderId, missingMemberProvisionalSkipMemberIds = new Set(), provisionalScoreStore, + updateExistingScores = false, + finalLegacySubmissionIdsByRoundId = new Map(), }) => { if ( !provisionalScoreStore || @@ -326,6 +409,18 @@ const reconcileRoundProvisionalScores = async ({ await provisionalScoreStore.listExistingProvisionalSummationsBySubmissionId({ challengeId, }); + const finalLegacySubmissionIds = + normalizeLegacySubmissionIdSetByRoundId(finalLegacySubmissionIdsByRoundId).get(roundId) || + new Set(); + const canDemoteMisclassifiedFinalScores = + updateExistingScores && + finalLegacySubmissionIds.size > 0 && + typeof provisionalScoreStore.listExistingFinalSummationsBySubmissionId === "function"; + const existingFinalSummationsBySubmissionId = canDemoteMisclassifiedFinalScores + ? await provisionalScoreStore.listExistingFinalSummationsBySubmissionId({ + challengeId, + }) + : new Map(); const missingMemberIds = new Set( Array.from(missingMemberProvisionalSkipMemberIds || []) .map((memberId) => normalizeMemberId(memberId)) @@ -334,6 +429,10 @@ const reconcileRoundProvisionalScores = async ({ let createdProvisionalScores = 0; let alreadyPresentProvisionalScores = 0; + let updatedProvisionalScores = 0; + let demotedFinalScores = 0; + let clearedSubmissionFinalScoreSummaries = 0; + let malformedSkippedProvisionalScores = 0; let missingMemberSkippedProvisionalScores = 0; const importedCountsByMemberId = new Map(); const importedMemberIds = new Set(); @@ -374,9 +473,20 @@ const reconcileRoundProvisionalScores = async ({ } if (!Number.isFinite(provisionalRow.aggregateScore)) { - throw new Error( - `Legacy provisional score for round ${roundId} submission ${provisionalRow.legacySubmissionId} (coder ${provisionalRow.coderId}) is missing numeric submission_points.` - ); + malformedSkippedProvisionalScores += 1; + skippedProvisionalRecords.push({ + legacyRoundId: roundId, + memberId, + memberHandle: memberHandle || undefined, + coderIds: [String(provisionalRow.coderId || "").trim()].filter(Boolean), + reasonCode: MALFORMED_PROVISIONAL_SCORE_REASON_CODE, + affectedSurfaces: ["provisional-score"], + legacySubmissionId: provisionalRow.legacySubmissionId, + counts: { + provisionalScore: 1, + }, + }); + continue; } const importedSubmission = importedSubmissionByLegacySubmissionId.get( @@ -402,8 +512,132 @@ const reconcileRoundProvisionalScores = async ({ const existingProvisionalSummations = existingProvisionalSummationsBySubmissionId.get(submissionId) || []; + const isExplicitFinalSubmission = + provisionalRow.isSyntheticExampleOnlyFinalist === true || + finalLegacySubmissionIds.has(String(provisionalRow.legacySubmissionId || "").trim()); + const misclassifiedFinalSummations = + canDemoteMisclassifiedFinalScores && !isExplicitFinalSubmission + ? existingFinalSummationsBySubmissionId.get(submissionId) || [] + : []; + let demotedCurrentFinalScores = 0; + if (misclassifiedFinalSummations.length > 0) { + if (typeof provisionalScoreStore.updateProvisionalSummation !== "function") { + throw new Error( + "provisionalScoreStore must provide updateProvisionalSummation when updateExistingScores is enabled." + ); + } + await Promise.all( + misclassifiedFinalSummations.map((misclassifiedFinalSummation) => + provisionalScoreStore.updateProvisionalSummation({ + reviewSummationId: misclassifiedFinalSummation.id, + submissionId, + aggregateScore: provisionalRow.aggregateScore, + isPassing: provisionalRow.aggregateScore > 0, + reviewedDate: + importedSubmission.submittedDate || importedSubmission.createdAt || null, + legacySubmissionId: provisionalRow.legacySubmissionId || null, + isFinal: false, + isExample: false, + metadata: { + legacyRoundId: roundId, + legacyCoderId: provisionalRow.coderId, + }, + }) + ) + ); + existingFinalSummationsBySubmissionId.set(submissionId, []); + demotedCurrentFinalScores = misclassifiedFinalSummations.length; + demotedFinalScores += demotedCurrentFinalScores; + } + const canClearSubmissionFinalScoreSummary = + updateExistingScores && + finalLegacySubmissionIds.size > 0 && + !isExplicitFinalSubmission && + typeof provisionalScoreStore.clearSubmissionFinalScoreSummary === "function"; + let clearedCurrentSubmissionFinalScoreSummary = false; + if ( + canClearSubmissionFinalScoreSummary && + (demotedCurrentFinalScores > 0 || hasSubmissionFinalScoreSummary(importedSubmission)) + ) { + const cleared = await provisionalScoreStore.clearSubmissionFinalScoreSummary({ + submissionId, + }); + if (cleared) { + clearedCurrentSubmissionFinalScoreSummary = true; + clearedSubmissionFinalScoreSummaries += 1; + if (hasOwnProperty(importedSubmission, "finalScore")) { + importedSubmission.finalScore = null; + } + if (hasOwnProperty(importedSubmission, "placement")) { + importedSubmission.placement = null; + } + if (hasOwnProperty(importedSubmission, "userRank")) { + importedSubmission.userRank = null; + } + } + } + if (existingProvisionalSummations.length > 0) { - alreadyPresentProvisionalScores += 1; + let updatedExistingProvisionalScore = false; + if ( + updateExistingScores && + !hasMatchingAggregateScore(existingProvisionalSummations, provisionalRow.aggregateScore) + ) { + if (typeof provisionalScoreStore.updateProvisionalSummation !== "function") { + throw new Error( + "provisionalScoreStore must provide updateProvisionalSummation when updateExistingScores is enabled." + ); + } + await Promise.all( + existingProvisionalSummations.map((existingProvisionalSummation) => + provisionalScoreStore.updateProvisionalSummation({ + reviewSummationId: existingProvisionalSummation.id, + submissionId, + aggregateScore: provisionalRow.aggregateScore, + isPassing: provisionalRow.aggregateScore > 0, + reviewedDate: + importedSubmission.submittedDate || importedSubmission.createdAt || null, + legacySubmissionId: provisionalRow.legacySubmissionId || null, + isFinal: false, + isExample: false, + metadata: { + legacyRoundId: roundId, + legacyCoderId: provisionalRow.coderId, + }, + }) + ) + ); + existingProvisionalSummationsBySubmissionId.set( + submissionId, + existingProvisionalSummations.map((existingProvisionalSummation) => ({ + ...existingProvisionalSummation, + aggregateScore: provisionalRow.aggregateScore, + })) + ); + updatedExistingProvisionalScore = true; + } + if ( + updatedExistingProvisionalScore || + demotedCurrentFinalScores > 0 || + clearedCurrentSubmissionFinalScoreSummary + ) { + updatedProvisionalScores += 1; + } else { + alreadyPresentProvisionalScores += 1; + } + incrementImportedCount(memberId); + continue; + } + + if (demotedCurrentFinalScores > 0) { + existingProvisionalSummationsBySubmissionId.set( + submissionId, + misclassifiedFinalSummations.map((misclassifiedFinalSummation) => ({ + ...misclassifiedFinalSummation, + aggregateScore: provisionalRow.aggregateScore, + })) + ); + updatedProvisionalScores += 1; incrementImportedCount(memberId); continue; } @@ -436,9 +670,17 @@ const reconcileRoundProvisionalScores = async ({ legacyNonExampleProvisionalScores, legacyExampleOnlyFinalistProvisionalScores, importedProvisionalScores: - createdProvisionalScores + alreadyPresentProvisionalScores, + createdProvisionalScores + updatedProvisionalScores + alreadyPresentProvisionalScores, alreadyPresentProvisionalScores, createdProvisionalScores, + ...(updateExistingScores + ? { + updatedProvisionalScores, + demotedFinalScores, + clearedSubmissionFinalScoreSummaries, + } + : {}), + malformedSkippedProvisionalScores, missingMemberSkippedProvisionalScores, importedDistinctSubmitters: importedMemberIds.size, missingMemberDistinctSubmitters: missingMemberIdsObserved.size, @@ -517,6 +759,15 @@ const createReviewProvisionalScoreStore = async ({ if (submissionColumnsByName.has("isExample")) { selectedColumns.push(`"isExample"`); } + if (submissionColumnsByName.has("finalScore")) { + selectedColumns.push(`"finalScore"`); + } + if (submissionColumnsByName.has("placement")) { + selectedColumns.push(`"placement"`); + } + if (submissionColumnsByName.has("userRank")) { + selectedColumns.push(`"userRank"`); + } const whereClauses = [`"challengeId" = $1`, `"legacySubmissionId" IS NOT NULL`]; if (submissionColumnsByName.has("isExample")) { @@ -549,6 +800,15 @@ const createReviewProvisionalScoreStore = async ({ submittedDate: row && row.submittedDate ? row.submittedDate : null, createdAt: row && row.createdAt ? row.createdAt : null, isExample: Boolean(row && row.isExample), + ...(submissionColumnsByName.has("finalScore") + ? { finalScore: parseNumericScore(row && row.finalScore) } + : {}), + ...(submissionColumnsByName.has("placement") + ? { placement: parsePlacement(row && row.placement) } + : {}), + ...(submissionColumnsByName.has("userRank") + ? { userRank: parsePlacement(row && row.userRank) } + : {}), }); }); return byLegacySubmissionId; @@ -565,7 +825,46 @@ const createReviewProvisionalScoreStore = async ({ whereClauses.push(`COALESCE(rs."isExample", false) = false`); } const rows = await reviewClient.$queryRawUnsafe( - `SELECT rs."submissionId" AS "submissionId", + `SELECT ${reviewSummationColumnsByName.has("id") ? 'rs."id" AS "id",' : ""} + rs."submissionId" AS "submissionId", + rs."aggregateScore" AS "aggregateScore" + FROM ${reviewSummationTable} rs + INNER JOIN ${submissionTable} s ON s."id" = rs."submissionId" + WHERE ${whereClauses.join(" AND ")}`, + challengeId + ); + + const bySubmissionId = new Map(); + (rows || []).forEach((row) => { + const submissionId = String(row && row.submissionId ? row.submissionId : "").trim(); + if (!submissionId) { + return; + } + if (!bySubmissionId.has(submissionId)) { + bySubmissionId.set(submissionId, []); + } + bySubmissionId.get(submissionId).push({ + id: String(row && row.id ? row.id : "").trim() || null, + submissionId, + aggregateScore: parseNumericScore(row && row.aggregateScore), + }); + }); + return bySubmissionId; + }; + + const listExistingFinalSummationsBySubmissionId = async ({ + challengeId, + }) => { + const whereClauses = [ + `s."challengeId" = $1`, + `COALESCE(rs."isFinal", false) = true`, + ]; + if (reviewSummationColumnsByName.has("isExample")) { + whereClauses.push(`COALESCE(rs."isExample", false) = false`); + } + const rows = await reviewClient.$queryRawUnsafe( + `SELECT ${reviewSummationColumnsByName.has("id") ? 'rs."id" AS "id",' : ""} + rs."submissionId" AS "submissionId", rs."aggregateScore" AS "aggregateScore" FROM ${reviewSummationTable} rs INNER JOIN ${submissionTable} s ON s."id" = rs."submissionId" @@ -583,6 +882,7 @@ const createReviewProvisionalScoreStore = async ({ bySubmissionId.set(submissionId, []); } bySubmissionId.get(submissionId).push({ + id: String(row && row.id ? row.id : "").trim() || null, submissionId, aggregateScore: parseNumericScore(row && row.aggregateScore), }); @@ -615,6 +915,9 @@ const createReviewProvisionalScoreStore = async ({ if (reviewSummationColumnsByName.has("isFinal")) { pushColumn("isFinal", Boolean(isFinal)); } + if (reviewSummationColumnsByName.has("isProvisional")) { + pushColumn("isProvisional", !isFinal && !isExample); + } if (reviewSummationColumnsByName.has("reviewedDate") && reviewedDate) { pushColumn("reviewedDate", reviewedDate); } @@ -648,10 +951,128 @@ const createReviewProvisionalScoreStore = async ({ ); }; + /** + * Clears denormalized final score fields from a non-final imported Marathon + * Match submission. Provisional reruns call this after identifying a row whose + * legacy submission id is not the authoritative final submission for the member. + * + * @param {Object} params clear inputs + * @param {string} params.submissionId review submission id to repair + * @returns {Promise} true when at least one supported summary column was cleared + * @throws {Error} when submissionId is blank + */ + const clearSubmissionFinalScoreSummary = async ({ submissionId }) => { + const normalizedSubmissionId = String(submissionId || "").trim(); + if (!normalizedSubmissionId) { + throw new Error("clearSubmissionFinalScoreSummary requires submissionId."); + } + + const assignments = []; + const values = []; + const pushAssignment = (columnName, value) => { + values.push(value); + assignments.push(`"${columnName}" = $${values.length}`); + }; + + if (submissionColumnsByName.has("finalScore")) { + pushAssignment("finalScore", null); + } + if (submissionColumnsByName.has("placement")) { + pushAssignment("placement", null); + } + if (submissionColumnsByName.has("userRank")) { + pushAssignment("userRank", null); + } + if (assignments.length === 0) { + return false; + } + if (submissionColumnsByName.has("updatedBy")) { + pushAssignment("updatedBy", actor); + } + if (submissionColumnsByName.has("updatedAt")) { + pushAssignment("updatedAt", new Date()); + } + values.push(normalizedSubmissionId); + + await reviewClient.$queryRawUnsafe( + `UPDATE ${submissionTable} + SET ${assignments.join(", ")} + WHERE "id" = $${values.length}`, + ...values + ); + return true; + }; + + const updateProvisionalSummation = async ({ + reviewSummationId, + aggregateScore, + isPassing, + reviewedDate, + legacySubmissionId, + isFinal = false, + isExample = false, + metadata = null, + }) => { + const normalizedReviewSummationId = String(reviewSummationId || "").trim(); + if (!normalizedReviewSummationId) { + throw new Error("updateProvisionalSummation requires reviewSummationId."); + } + if (!reviewSummationColumnsByName.has("id")) { + throw new Error( + `Review reviewSummation table ${schema}.reviewSummation must expose id for targeted score reruns.` + ); + } + + const assignments = []; + const values = []; + const pushAssignment = (columnName, value) => { + values.push(value); + assignments.push(`"${columnName}" = $${values.length}`); + }; + + pushAssignment("aggregateScore", aggregateScore); + pushAssignment("isPassing", Boolean(isPassing)); + if (reviewSummationColumnsByName.has("isFinal")) { + pushAssignment("isFinal", Boolean(isFinal)); + } + if (reviewSummationColumnsByName.has("isProvisional")) { + pushAssignment("isProvisional", !isFinal && !isExample); + } + if (reviewSummationColumnsByName.has("reviewedDate")) { + pushAssignment("reviewedDate", reviewedDate || null); + } + if (reviewSummationColumnsByName.has("legacySubmissionId")) { + pushAssignment("legacySubmissionId", legacySubmissionId ? String(legacySubmissionId) : null); + } + if (reviewSummationColumnsByName.has("isExample")) { + pushAssignment("isExample", Boolean(isExample)); + } + if (reviewSummationColumnsByName.has("metadata")) { + pushAssignment("metadata", metadata); + } + if (reviewSummationColumnsByName.has("updatedBy")) { + pushAssignment("updatedBy", actor); + } + if (reviewSummationColumnsByName.has("updatedAt")) { + pushAssignment("updatedAt", reviewedDate || new Date()); + } + values.push(normalizedReviewSummationId); + + await reviewClient.$queryRawUnsafe( + `UPDATE ${reviewSummationTable} + SET ${assignments.join(", ")} + WHERE "id" = $${values.length}`, + ...values + ); + }; + return { listImportedNonExampleSubmissionsByLegacySubmissionId, listExistingProvisionalSummationsBySubmissionId, + listExistingFinalSummationsBySubmissionId, createProvisionalSummation, + updateProvisionalSummation, + clearSubmissionFinalScoreSummary, }; }; diff --git a/data-migration/src/scripts/importHistoricalMarathonMatches/skippedArtifact.js b/data-migration/src/scripts/importHistoricalMarathonMatches/skippedArtifact.js index d4d5f95..178cd50 100644 --- a/data-migration/src/scripts/importHistoricalMarathonMatches/skippedArtifact.js +++ b/data-migration/src/scripts/importHistoricalMarathonMatches/skippedArtifact.js @@ -5,6 +5,7 @@ const path = require("path"); const SKIPPED_ARTIFACT_SCHEMA_VERSION = 1; const MISSING_MEMBER_REASON_CODE = "missing-member"; +const MALFORMED_PROVISIONAL_SCORE_REASON_CODE = "malformed-provisional-score"; const FINALIST_WITHOUT_ATTACHABLE_SUBMISSION_REASON_CODE = "finalist-without-attachable-submission"; @@ -157,6 +158,7 @@ const writeSkippedArtifact = ({ filePath, selectedRoundIds = [], records = [] }) module.exports = { SKIPPED_ARTIFACT_SCHEMA_VERSION, MISSING_MEMBER_REASON_CODE, + MALFORMED_PROVISIONAL_SCORE_REASON_CODE, FINALIST_WITHOUT_ATTACHABLE_SUBMISSION_REASON_CODE, resolveSkippedFilePath, normalizeSkipRecords, diff --git a/data-migration/src/scripts/importHistoricalMarathonMatches/submissionArchives.js b/data-migration/src/scripts/importHistoricalMarathonMatches/submissionArchives.js new file mode 100644 index 0000000..94791c8 --- /dev/null +++ b/data-migration/src/scripts/importHistoricalMarathonMatches/submissionArchives.js @@ -0,0 +1,204 @@ +"use strict"; + +const crypto = require("crypto"); +const fs = require("fs"); +const path = require("path"); + +const DEFAULT_SUBMISSION_ARCHIVE_URL_PREFIX = + "https://s3.amazonaws.com/topcoder-submissions"; +const ZIP_EPOCH_DATE = 0x0021; +const ZIP_EPOCH_TIME = 0x0000; + +const sanitizeArchiveSegment = (value, fallback) => { + const normalized = String(value || "").trim(); + const sanitized = normalized + .replace(/[^A-Za-z0-9._-]+/g, "-") + .replace(/^-+|-+$/g, ""); + return sanitized || fallback; +}; + +const buildSubmissionArchiveBaseName = ({ challengeId, legacySubmissionId }) => { + const normalizedChallengeId = String(challengeId || "").trim(); + const normalizedLegacySubmissionId = String(legacySubmissionId || "").trim(); + if (!normalizedChallengeId) { + throw new Error("Cannot build submission archive name without challengeId."); + } + if (!normalizedLegacySubmissionId) { + throw new Error("Cannot build submission archive name without legacySubmissionId."); + } + + const safeLegacySubmissionId = sanitizeArchiveSegment( + normalizedLegacySubmissionId, + "legacy-submission" + ); + const stableHash = crypto + .createHash("sha1") + .update(`${normalizedChallengeId}:${normalizedLegacySubmissionId}`) + .digest("hex") + .slice(0, 12); + + return `${safeLegacySubmissionId}-${stableHash}`; +}; + +const buildSubmissionArchiveFileName = ({ challengeId, legacySubmissionId }) => + `${buildSubmissionArchiveBaseName({ challengeId, legacySubmissionId })}.zip`; + +const buildSubmissionArchiveEntryName = ({ legacySubmissionId }) => { + const normalizedLegacySubmissionId = String(legacySubmissionId || "").trim(); + if (!normalizedLegacySubmissionId) { + throw new Error("Cannot build submission archive entry name without legacySubmissionId."); + } + const safeLegacySubmissionId = sanitizeArchiveSegment( + normalizedLegacySubmissionId, + "legacy-submission" + ); + return `${safeLegacySubmissionId}.txt`; +}; + +const buildSubmissionArchiveUrl = ({ archiveFileName, urlPrefix = DEFAULT_SUBMISSION_ARCHIVE_URL_PREFIX }) => { + const normalizedArchiveFileName = String(archiveFileName || "").trim(); + if (!normalizedArchiveFileName) { + throw new Error("Cannot build submission archive URL without archive file name."); + } + const normalizedPrefix = String(urlPrefix || DEFAULT_SUBMISSION_ARCHIVE_URL_PREFIX) + .trim() + .replace(/\/+$/, ""); + return `${normalizedPrefix}/${normalizedArchiveFileName}`; +}; + +const resolveSubmissionArchiveDirectory = (archiveDirectory) => { + const normalizedArchiveDirectory = String(archiveDirectory || "").trim(); + if (!normalizedArchiveDirectory) { + throw new Error("SUBMISSION_ARCHIVE_DIR must be set for submission archive generation."); + } + return path.resolve(normalizedArchiveDirectory); +}; + +const buildCrc32Table = () => { + const table = new Array(256); + for (let i = 0; i < 256; i += 1) { + let value = i; + for (let j = 0; j < 8; j += 1) { + if ((value & 1) !== 0) { + value = (value >>> 1) ^ 0xedb88320; + } else { + value >>>= 1; + } + } + table[i] = value >>> 0; + } + return table; +}; + +const CRC32_TABLE = buildCrc32Table(); + +const computeCrc32 = (buffer) => { + let crc = 0xffffffff; + for (let index = 0; index < buffer.length; index += 1) { + const lookupIndex = (crc ^ buffer[index]) & 0xff; + crc = (crc >>> 8) ^ CRC32_TABLE[lookupIndex]; + } + return (crc ^ 0xffffffff) >>> 0; +}; + +const buildSingleEntryZipBuffer = ({ entryName, textContent }) => { + const normalizedEntryName = String(entryName || "").trim(); + if (!normalizedEntryName) { + throw new Error("Cannot create submission archive without an entry name."); + } + + const entryNameBuffer = Buffer.from(normalizedEntryName, "utf8"); + const fileDataBuffer = Buffer.from(String(textContent || ""), "utf8"); + const crc32 = computeCrc32(fileDataBuffer); + const compressedSize = fileDataBuffer.length; + const uncompressedSize = fileDataBuffer.length; + + const localHeader = Buffer.alloc(30); + localHeader.writeUInt32LE(0x04034b50, 0); + localHeader.writeUInt16LE(20, 4); + localHeader.writeUInt16LE(0, 6); + localHeader.writeUInt16LE(0, 8); + localHeader.writeUInt16LE(ZIP_EPOCH_TIME, 10); + localHeader.writeUInt16LE(ZIP_EPOCH_DATE, 12); + localHeader.writeUInt32LE(crc32, 14); + localHeader.writeUInt32LE(compressedSize, 18); + localHeader.writeUInt32LE(uncompressedSize, 22); + localHeader.writeUInt16LE(entryNameBuffer.length, 26); + localHeader.writeUInt16LE(0, 28); + + const centralDirectoryHeader = Buffer.alloc(46); + centralDirectoryHeader.writeUInt32LE(0x02014b50, 0); + centralDirectoryHeader.writeUInt16LE(20, 4); + centralDirectoryHeader.writeUInt16LE(20, 6); + centralDirectoryHeader.writeUInt16LE(0, 8); + centralDirectoryHeader.writeUInt16LE(0, 10); + centralDirectoryHeader.writeUInt16LE(ZIP_EPOCH_TIME, 12); + centralDirectoryHeader.writeUInt16LE(ZIP_EPOCH_DATE, 14); + centralDirectoryHeader.writeUInt32LE(crc32, 16); + centralDirectoryHeader.writeUInt32LE(compressedSize, 20); + centralDirectoryHeader.writeUInt32LE(uncompressedSize, 24); + centralDirectoryHeader.writeUInt16LE(entryNameBuffer.length, 28); + centralDirectoryHeader.writeUInt16LE(0, 30); + centralDirectoryHeader.writeUInt16LE(0, 32); + centralDirectoryHeader.writeUInt16LE(0, 34); + centralDirectoryHeader.writeUInt16LE(0, 36); + centralDirectoryHeader.writeUInt32LE(0, 38); + centralDirectoryHeader.writeUInt32LE(0, 42); + + const centralDirectorySize = centralDirectoryHeader.length + entryNameBuffer.length; + const centralDirectoryOffset = localHeader.length + entryNameBuffer.length + fileDataBuffer.length; + const endOfCentralDirectory = Buffer.alloc(22); + endOfCentralDirectory.writeUInt32LE(0x06054b50, 0); + endOfCentralDirectory.writeUInt16LE(0, 4); + endOfCentralDirectory.writeUInt16LE(0, 6); + endOfCentralDirectory.writeUInt16LE(1, 8); + endOfCentralDirectory.writeUInt16LE(1, 10); + endOfCentralDirectory.writeUInt32LE(centralDirectorySize, 12); + endOfCentralDirectory.writeUInt32LE(centralDirectoryOffset, 16); + endOfCentralDirectory.writeUInt16LE(0, 20); + + return Buffer.concat([ + localHeader, + entryNameBuffer, + fileDataBuffer, + centralDirectoryHeader, + entryNameBuffer, + endOfCentralDirectory, + ]); +}; + +const writeSubmissionArchiveZip = ({ + archiveDirectory, + archiveFileName, + archiveEntryName, + submissionText, +}) => { + const resolvedArchiveDirectory = resolveSubmissionArchiveDirectory(archiveDirectory); + const normalizedArchiveFileName = String(archiveFileName || "").trim(); + const normalizedArchiveEntryName = String(archiveEntryName || "").trim(); + if (!normalizedArchiveFileName) { + throw new Error("Cannot write submission archive without archive file name."); + } + if (!normalizedArchiveEntryName) { + throw new Error("Cannot write submission archive without archive entry name."); + } + + fs.mkdirSync(resolvedArchiveDirectory, { recursive: true }); + const archivePath = path.join(resolvedArchiveDirectory, normalizedArchiveFileName); + const archiveBuffer = buildSingleEntryZipBuffer({ + entryName: normalizedArchiveEntryName, + textContent: submissionText, + }); + fs.writeFileSync(archivePath, archiveBuffer); + return archivePath; +}; + +module.exports = { + DEFAULT_SUBMISSION_ARCHIVE_URL_PREFIX, + buildSubmissionArchiveBaseName, + buildSubmissionArchiveFileName, + buildSubmissionArchiveEntryName, + buildSubmissionArchiveUrl, + resolveSubmissionArchiveDirectory, + writeSubmissionArchiveZip, +}; diff --git a/data-migration/src/scripts/importHistoricalMarathonMatches/submissionHistory.js b/data-migration/src/scripts/importHistoricalMarathonMatches/submissionHistory.js index a893a84..0cc9142 100644 --- a/data-migration/src/scripts/importHistoricalMarathonMatches/submissionHistory.js +++ b/data-migration/src/scripts/importHistoricalMarathonMatches/submissionHistory.js @@ -2,6 +2,9 @@ const crypto = require("crypto"); +const { + buildSubmissionArchiveFileName, +} = require("./submissionArchives"); const { ensureFileExists, listFilesByPattern, @@ -48,6 +51,41 @@ const normalizeReviewSchema = (value) => { return normalized; }; +const LEGACY_SUBMISSION_TEXT_FIELDS = [ + "submission", + "submission_text", + "submissionText", + "text", + "body", + "source", + "source_code", + "sourceCode", + "code", + "content", + "contents", +]; + +const isUsableLegacySubmissionText = (value) => { + if (value === null || value === undefined) { + return false; + } + const normalized = String(value).trim(); + if (!normalized) { + return false; + } + return normalized.toLowerCase() !== "null"; +}; + +const resolveLegacySubmissionText = (row) => { + const record = row && typeof row === "object" ? row : {}; + for (const fieldName of LEGACY_SUBMISSION_TEXT_FIELDS) { + if (isUsableLegacySubmissionText(record[fieldName])) { + return String(record[fieldName]); + } + } + return ""; +}; + const buildQualifiedTableName = (schemaName, tableName) => `"${String(schemaName).replace(/"/g, "\"\"")}"."${String(tableName).replace(/"/g, "\"\"")}"`; @@ -233,6 +271,7 @@ const loadNonExampleLegacySubmissionRowsByRoundId = async ({ : null, legacySubmissionId, isSyntheticExampleOnlyFinalist: true, + submissionText: resolveLegacySubmissionText(row), } ) ); @@ -261,6 +300,7 @@ const loadNonExampleLegacySubmissionRowsByRoundId = async ({ : null, legacySubmissionId, isSyntheticExampleOnlyFinalist: false, + submissionText: resolveLegacySubmissionText(row), }); }) ) @@ -346,6 +386,18 @@ const createReviewSubmissionStore = async ({ if (columnsByName.has("submitter")) { selectedColumns.push(`"submitter"`); } + if (columnsByName.has("systemFileName")) { + selectedColumns.push(`"systemFileName"`); + } + if (columnsByName.has("virusScan")) { + selectedColumns.push(`"virusScan"`); + } + if (columnsByName.has("isFileSubmission")) { + selectedColumns.push(`"isFileSubmission"`); + } + if (columnsByName.has("submittedDate")) { + selectedColumns.push(`"submittedDate"`); + } const rows = await reviewClient.$queryRawUnsafe( `SELECT ${selectedColumns.join(", ")} @@ -365,6 +417,17 @@ const createReviewSubmissionStore = async ({ legacySubmissionId, memberId: normalizeMemberId(row && row.memberId), submitter: row && row.submitter ? String(row.submitter) : null, + systemFileName: + row && row.systemFileName !== null && row.systemFileName !== undefined + ? String(row.systemFileName).trim() + : null, + virusScan: + row && (row.virusScan === true || row.virusScan === false) ? row.virusScan : null, + isFileSubmission: + row && (row.isFileSubmission === true || row.isFileSubmission === false) + ? row.isFileSubmission + : null, + submittedDate: row && row.submittedDate ? row.submittedDate : null, }); }); return byLegacyId; @@ -381,6 +444,12 @@ const createReviewSubmissionStore = async ({ if (!normalizedLegacySubmissionId) { throw new Error("createSubmission requires legacySubmissionId."); } + const archiveFileName = columnsByName.has("systemFileName") + ? buildSubmissionArchiveFileName({ + challengeId, + legacySubmissionId: normalizedLegacySubmissionId, + }) + : null; const derivedId = crypto .createHash("sha1") @@ -411,6 +480,15 @@ const createReviewSubmissionStore = async ({ if (columnsByName.has("submittedDate") && submittedDate) { pushColumn("submittedDate", submittedDate); } + if (columnsByName.has("systemFileName") && archiveFileName) { + pushColumn("systemFileName", archiveFileName); + } + if (columnsByName.has("virusScan")) { + pushColumn("virusScan", true); + } + if (columnsByName.has("isFileSubmission")) { + pushColumn("isFileSubmission", true); + } if (columnsByName.has("isExample")) { pushColumn("isExample", false); } @@ -444,9 +522,210 @@ const createReviewSubmissionStore = async ({ ); }; + /** + * Backfills file-submission metadata for an already imported review submission row. + * + * @param {Object} params reconciliation parameters + * @param {string} params.challengeId v6 challenge identifier for the submission row + * @param {string} params.legacySubmissionId deterministic legacy submission identifier + * @param {string|number} [params.memberId] expected member id for the submission row + * @param {string} [params.memberHandle] expected submitter handle for the submission row + * @param {Date|string} [params.submittedDate] expected submitted date for the row + * @param {Object} [params.existingSubmission] current row snapshot returned from + * listExistingSubmissionsByLegacyId + * @returns {Promise} true when an UPDATE was issued, otherwise false + * @throws {Error} when legacySubmissionId is blank + */ + const updateSubmissionMetadata = async ({ + challengeId, + legacySubmissionId, + memberId = null, + memberHandle = null, + submittedDate = null, + existingSubmission = null, + }) => { + const normalizedLegacySubmissionId = String(legacySubmissionId || "").trim(); + if (!normalizedLegacySubmissionId) { + throw new Error("updateSubmissionMetadata requires legacySubmissionId."); + } + + const assignments = []; + const values = []; + const pushAssignment = (columnName, value) => { + assignments.push(`"${columnName}" = $${values.length + 1}`); + values.push(value); + }; + + const currentSystemFileName = + existingSubmission && + existingSubmission.systemFileName !== null && + existingSubmission.systemFileName !== undefined + ? String(existingSubmission.systemFileName).trim() + : null; + const expectedArchiveFileName = columnsByName.has("systemFileName") + ? buildSubmissionArchiveFileName({ + challengeId, + legacySubmissionId: normalizedLegacySubmissionId, + }) + : null; + if ( + columnsByName.has("systemFileName") && + expectedArchiveFileName && + currentSystemFileName !== expectedArchiveFileName + ) { + pushAssignment("systemFileName", expectedArchiveFileName); + } + if (columnsByName.has("virusScan") && (!existingSubmission || existingSubmission.virusScan !== true)) { + pushAssignment("virusScan", true); + } + if ( + columnsByName.has("isFileSubmission") && + (!existingSubmission || existingSubmission.isFileSubmission !== true) + ) { + pushAssignment("isFileSubmission", true); + } + const normalizedMemberId = normalizeMemberId(memberId); + const currentMemberId = normalizeMemberId(existingSubmission && existingSubmission.memberId); + if ( + columnsByName.has("memberId") && + normalizedMemberId && + currentMemberId !== normalizedMemberId + ) { + pushAssignment("memberId", normalizedMemberId); + } + const normalizedMemberHandle = String(memberHandle || "").trim(); + const currentSubmitter = String( + existingSubmission && existingSubmission.submitter ? existingSubmission.submitter : "" + ).trim(); + if ( + columnsByName.has("submitter") && + normalizedMemberHandle && + currentSubmitter !== normalizedMemberHandle + ) { + pushAssignment("submitter", normalizedMemberHandle); + } + if ( + columnsByName.has("submittedDate") && + submittedDate && + !(existingSubmission && existingSubmission.submittedDate) + ) { + pushAssignment("submittedDate", submittedDate); + } + if (assignments.length === 0) { + return false; + } + if (columnsByName.has("updatedBy")) { + pushAssignment("updatedBy", actor); + } + if (columnsByName.has("updatedAt")) { + pushAssignment("updatedAt", new Date()); + } + + values.push(challengeId); + values.push(normalizedLegacySubmissionId); + await reviewClient.$queryRawUnsafe( + `UPDATE ${submissionTable} + SET ${assignments.join(", ")} + WHERE "challengeId" = $${values.length - 1} + AND "legacySubmissionId" = $${values.length}`, + ...values + ); + return true; + }; + return { listExistingSubmissionsByLegacyId, createSubmission, + updateSubmissionMetadata, + }; +}; + +const createReviewSubmissionArchiveStore = async ({ + reviewClient, + reviewSchema = DEFAULT_REVIEW_SCHEMA, +}) => { + if (!reviewClient || typeof reviewClient.$queryRawUnsafe !== "function") { + throw new Error( + "Review DB client with $queryRawUnsafe is required for submission archive reconciliation." + ); + } + + const schema = normalizeReviewSchema(reviewSchema); + const submissionTable = buildQualifiedTableName(schema, "submission"); + const columnRows = await reviewClient.$queryRawUnsafe( + `SELECT column_name AS "columnName" + FROM information_schema.columns + WHERE table_schema = $1 + AND table_name = 'submission'`, + schema + ); + const columnNames = new Set((columnRows || []).map((columnRow) => String(columnRow.columnName))); + + if (!columnNames.has("challengeId") || !columnNames.has("legacySubmissionId")) { + throw new Error( + `Review submission table ${schema}.submission must expose challengeId and legacySubmissionId columns.` + ); + } + if (!columnNames.has("url")) { + throw new Error(`Review submission table ${schema}.submission must expose url column.`); + } + + const listSubmissionsByLegacyId = async ({ challengeId }) => { + const rows = await reviewClient.$queryRawUnsafe( + `SELECT "legacySubmissionId", "url" + FROM ${submissionTable} + WHERE "challengeId" = $1 + AND "legacySubmissionId" IS NOT NULL`, + challengeId + ); + + const byLegacySubmissionId = new Map(); + (rows || []).forEach((row) => { + const legacySubmissionId = String( + row && row.legacySubmissionId ? row.legacySubmissionId : "" + ).trim(); + if (!legacySubmissionId) { + return; + } + if (byLegacySubmissionId.has(legacySubmissionId)) { + throw new Error( + `Challenge ${challengeId} has duplicate submission rows for legacySubmissionId "${legacySubmissionId}".` + ); + } + + byLegacySubmissionId.set(legacySubmissionId, { + legacySubmissionId, + url: + row && row.url !== null && row.url !== undefined ? String(row.url) : null, + }); + }); + return byLegacySubmissionId; + }; + + const updateSubmissionUrl = async ({ challengeId, legacySubmissionId, url }) => { + const normalizedLegacySubmissionId = String(legacySubmissionId || "").trim(); + const normalizedUrl = String(url || "").trim(); + if (!normalizedLegacySubmissionId) { + throw new Error("updateSubmissionUrl requires legacySubmissionId."); + } + if (!normalizedUrl) { + throw new Error("updateSubmissionUrl requires url."); + } + + await reviewClient.$queryRawUnsafe( + `UPDATE ${submissionTable} + SET "url" = $1 + WHERE "challengeId" = $2 + AND "legacySubmissionId" = $3`, + normalizedUrl, + challengeId, + normalizedLegacySubmissionId + ); + }; + + return { + listSubmissionsByLegacyId, + updateSubmissionUrl, }; }; @@ -520,6 +799,16 @@ const reconcileRoundSubmissionHistory = async ({ `Existing submission legacySubmissionId "${row.legacySubmissionId}" is linked to memberId ${existingMemberId} but legacy coder ${row.coderId} resolves to memberId ${memberId}.` ); } + if (typeof submissionStore.updateSubmissionMetadata === "function") { + await submissionStore.updateSubmissionMetadata({ + challengeId, + legacySubmissionId: row.legacySubmissionId, + memberId, + memberHandle, + submittedDate: row.submittedDate, + existingSubmission: existing, + }); + } alreadyPresentSubmissions += 1; incrementImportedCount(memberId); continue; @@ -562,5 +851,7 @@ module.exports = { deriveLegacySubmissionId, loadNonExampleLegacySubmissionRowsByRoundId, createReviewSubmissionStore, + createReviewSubmissionArchiveStore, reconcileRoundSubmissionHistory, + resolveLegacySubmissionText, }; diff --git a/data-migration/src/scripts/importHistoricalMarathonMatches/targetMemberResolution.js b/data-migration/src/scripts/importHistoricalMarathonMatches/targetMemberResolution.js index a0db91e..f9ebf8a 100644 --- a/data-migration/src/scripts/importHistoricalMarathonMatches/targetMemberResolution.js +++ b/data-migration/src/scripts/importHistoricalMarathonMatches/targetMemberResolution.js @@ -27,6 +27,60 @@ const normalizeMemberId = (value) => { return String(parsed); }; +const normalizeMemberHandle = (value) => { + const normalized = String(value || "").trim(); + return normalized || null; +}; + +/** + * Creates a resolver that loads target-environment member identities by user id. + * The historical marathon importer uses this to backfill handles when an Informix + * user export shard is missing or incomplete, while still keeping the target DB as + * the source of truth for current member handles. + * + * @param {Object} params resolver dependencies + * @param {Object} params.prisma Prisma client with `$queryRawUnsafe` + * @param {string} [params.memberSchema] schema containing the `member` table + * @returns {Function} resolver returning a Map keyed by normalized member id + * @throws {Error} when a query-capable Prisma client is not supplied + */ +const createMemberIdentityResolver = ({ prisma, memberSchema = DEFAULT_MEMBER_SCHEMA }) => { + if (!prisma || typeof prisma.$queryRawUnsafe !== "function") { + throw new Error("A Prisma client with $queryRawUnsafe is required for member identity resolution."); + } + + const normalizedSchema = normalizeMemberSchema(memberSchema); + + return async ({ memberIds = [] }) => { + const normalizedMemberIds = Array.from( + new Set(memberIds.map((memberId) => normalizeMemberId(memberId)).filter(Boolean)) + ); + const identityByMemberId = new Map(); + if (normalizedMemberIds.length === 0) { + return identityByMemberId; + } + + const batches = chunkArray(normalizedMemberIds, 1000); + for (const batch of batches) { + const placeholders = batch.map((_, index) => `$${index + 1}::bigint`).join(", "); + const query = `SELECT "userId", "handle" FROM "${normalizedSchema}"."member" WHERE "userId" IN (${placeholders})`; + const rows = await prisma.$queryRawUnsafe(query, ...batch); + (rows || []).forEach((row) => { + const memberId = normalizeMemberId(row && row.userId); + if (!memberId) { + return; + } + identityByMemberId.set(memberId, { + memberId, + memberHandle: normalizeMemberHandle(row && row.handle), + }); + }); + } + + return identityByMemberId; + }; +}; + const createMemberPresenceResolver = ({ prisma, memberSchema = DEFAULT_MEMBER_SCHEMA }) => { if (!prisma || typeof prisma.$queryRawUnsafe !== "function") { throw new Error("A Prisma client with $queryRawUnsafe is required for member resolution."); @@ -63,5 +117,6 @@ const createMemberPresenceResolver = ({ prisma, memberSchema = DEFAULT_MEMBER_SC module.exports = { DEFAULT_MEMBER_SCHEMA, TARGET_MEMBER_RESOLUTION_UNAVAILABLE_REASON, + createMemberIdentityResolver, createMemberPresenceResolver, }; diff --git a/data-migration/src/scripts/regenerateMarathonSystemTestResultsSection3.js b/data-migration/src/scripts/regenerateMarathonSystemTestResultsSection3.js new file mode 100644 index 0000000..132037c --- /dev/null +++ b/data-migration/src/scripts/regenerateMarathonSystemTestResultsSection3.js @@ -0,0 +1,262 @@ +"use strict"; + +const fs = require("fs"); +const path = require("path"); +const { + listFilesByPattern, + streamJsonArray, +} = require("./importHistoricalMarathonMatches/legacyDataReader"); + +const DEFAULT_DATA_DIR = process.env.DATA_DIRECTORY || "/mnt/Informix"; +const DEFAULT_INPUT_FILE = "/home/jmgasper/Downloads/section_3.json"; +const DEFAULT_OUTPUT_FILE = "/home/jmgasper/Downloads/section_3.corrected_raw_scores.json"; +const DEFAULT_MISSING_FILE = "/home/jmgasper/Downloads/section_3.missing_raw_matches.json"; +const DEFAULT_SUMMARY_FILE = "/home/jmgasper/Downloads/section_3.corrected_summary.json"; + +const printUsage = () => { + console.log(`Usage: + node data-migration/src/scripts/regenerateMarathonSystemTestResultsSection3.js \\ + [--data-dir ] \\ + [--input-file ] \\ + [--output-file ] \\ + [--missing-file ] \\ + [--summary-file ] + +Defaults: + --data-dir ${DEFAULT_DATA_DIR} + --input-file ${DEFAULT_INPUT_FILE} + --output-file ${DEFAULT_OUTPUT_FILE} + --missing-file ${DEFAULT_MISSING_FILE} + --summary-file ${DEFAULT_SUMMARY_FILE}`); +}; + +const parseArgs = (argv) => { + const options = { + dataDir: DEFAULT_DATA_DIR, + inputFile: DEFAULT_INPUT_FILE, + outputFile: DEFAULT_OUTPUT_FILE, + missingFile: DEFAULT_MISSING_FILE, + summaryFile: DEFAULT_SUMMARY_FILE, + }; + + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + + if (argument === "--help" || argument === "-h") { + printUsage(); + process.exit(0); + } + + if (argument === "--data-dir") { + options.dataDir = argv[index + 1]; + index += 1; + continue; + } + + if (argument === "--input-file") { + options.inputFile = argv[index + 1]; + index += 1; + continue; + } + + if (argument === "--output-file") { + options.outputFile = argv[index + 1]; + index += 1; + continue; + } + + if (argument === "--missing-file") { + options.missingFile = argv[index + 1]; + index += 1; + continue; + } + + if (argument === "--summary-file") { + options.summaryFile = argv[index + 1]; + index += 1; + continue; + } + + throw new Error(`Unknown argument: ${argument}`); + } + + return options; +}; + +const ensureParentDirectory = (filePath) => { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); +}; + +const readInputRows = (filePath) => { + const raw = fs.readFileSync(filePath, "utf8"); + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) { + throw new Error(`Input file must contain a JSON array: ${filePath}`); + } + return parsed; +}; + +const buildKey = (roundId, coderId, testCaseId) => + `${Number(roundId)}:${Number(coderId)}:${Number(testCaseId)}`; + +const toNumeric = (value) => { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; +}; + +const buildTargetIndex = (rows) => { + const targetByKey = new Map(); + const duplicateInputKeys = []; + + rows.forEach((row, index) => { + const key = buildKey(row.round_id, row.coder_id, row.test_case_id); + if (targetByKey.has(key)) { + duplicateInputKeys.push({ + key, + firstIndex: targetByKey.get(key).index, + duplicateIndex: index, + }); + return; + } + + targetByKey.set(key, { + index, + row, + matched: false, + rawScore: null, + source: null, + }); + }); + + if (duplicateInputKeys.length > 0) { + throw new Error( + `Input file contains duplicate (round_id, coder_id, test_case_id) keys. Sample: ${JSON.stringify( + duplicateInputKeys.slice(0, 5) + )}` + ); + } + + return targetByKey; +}; + +const createRoundStats = (rows) => { + const roundStats = new Map(); + rows.forEach((row) => { + const roundId = Number(row.round_id); + const existing = roundStats.get(roundId) || { total: 0, matched: 0 }; + existing.total += 1; + roundStats.set(roundId, existing); + }); + return roundStats; +}; + +const sortNumericAscending = (left, right) => left - right; + +const writeJsonFile = (filePath, payload) => { + ensureParentDirectory(filePath); + fs.writeFileSync(filePath, JSON.stringify(payload, null, 2) + "\n", "utf8"); +}; + +const main = async () => { + const options = parseArgs(process.argv.slice(2)); + const inputRows = readInputRows(options.inputFile); + const targetByKey = buildTargetIndex(inputRows); + const roundStats = createRoundStats(inputRows); + + const resultFiles = listFilesByPattern( + options.dataDir, + "^long_system_test_result_\\d+\\.json$", + "long system test result" + ); + + for (const filePath of resultFiles) { + await streamJsonArray(filePath, "long_system_test_result", (row) => { + const key = buildKey(row.round_id, row.coder_id, row.test_case_id); + const target = targetByKey.get(key); + + if (!target || target.matched) { + return; + } + + target.matched = true; + target.rawScore = toNumeric(row.score); + target.source = { + component_id: toNumeric(row.component_id), + submission_number: toNumeric(row.submission_number), + timestamp: row.timestamp || null, + }; + + const stats = roundStats.get(Number(row.round_id)); + stats.matched += 1; + }); + } + + const correctedRows = []; + const missingRows = []; + + inputRows.forEach((row) => { + const key = buildKey(row.round_id, row.coder_id, row.test_case_id); + const target = targetByKey.get(key); + + if (target && target.matched) { + correctedRows.push({ + coder_id: Number(row.coder_id), + test_case_id: Number(row.test_case_id), + round_id: Number(row.round_id), + problem_id: Number(row.problem_id), + score: target.rawScore, + }); + return; + } + + missingRows.push({ + coder_id: Number(row.coder_id), + test_case_id: Number(row.test_case_id), + round_id: Number(row.round_id), + problem_id: Number(row.problem_id), + original_score: toNumeric(row.score), + missing_reason: "no matching long_system_test_result row", + }); + }); + + const matchedRounds = [...roundStats.entries()] + .filter(([, stats]) => stats.matched > 0) + .map(([roundId]) => roundId) + .sort(sortNumericAscending); + + const missingRoundBreakdown = [...roundStats.entries()] + .map(([roundId, stats]) => ({ + round_id: roundId, + total_rows: stats.total, + matched_rows: stats.matched, + missing_rows: stats.total - stats.matched, + })) + .filter((row) => row.missing_rows > 0) + .sort((left, right) => left.round_id - right.round_id); + + const summary = { + input_file: options.inputFile, + data_dir: options.dataDir, + scanned_long_system_test_result_files: resultFiles.length, + total_input_rows: inputRows.length, + corrected_rows: correctedRows.length, + missing_rows: missingRows.length, + total_input_rounds: roundStats.size, + matched_rounds: matchedRounds.length, + first_matched_round: matchedRounds.length > 0 ? matchedRounds[0] : null, + last_matched_round: + matchedRounds.length > 0 ? matchedRounds[matchedRounds.length - 1] : null, + missing_round_breakdown: missingRoundBreakdown, + }; + + writeJsonFile(options.outputFile, correctedRows); + writeJsonFile(options.missingFile, missingRows); + writeJsonFile(options.summaryFile, summary); + + console.log(JSON.stringify(summary, null, 2)); +}; + +main().catch((error) => { + console.error(error && error.stack ? error.stack : error); + process.exit(1); +}); diff --git a/data-migration/test/exportMarathonMatchSubmissions.test.js b/data-migration/test/exportMarathonMatchSubmissions.test.js new file mode 100644 index 0000000..ca1fc39 --- /dev/null +++ b/data-migration/test/exportMarathonMatchSubmissions.test.js @@ -0,0 +1,342 @@ +const fs = require("fs"); +const http = require("http"); +const os = require("os"); +const path = require("path"); + +const { + parseArgs, + runExport, +} = require("../src/scripts/exportMarathonMatchSubmissions"); + +const createJsonResponse = (res, payload) => { + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify(payload)); +}; + +const startFixtureServer = async () => { + const requests = []; + const challengePayload = { + id: "challenge-123", + name: "Example Marathon Match", + type: "Marathon Match", + track: "DATA_SCIENCE", + }; + const submissionPayloads = [ + { + id: "submission-001", + memberId: "1001", + challengeId: "challenge-123", + }, + { + id: "submission-002", + memberId: "1002", + challengeId: "challenge-123", + }, + { + id: "submission-003", + memberId: "1003", + challengeId: "challenge-123", + }, + ]; + const reviewSummationPayloads = [ + { + id: "review-summation-1", + submissionId: "submission-001", + aggregateScore: 99.1, + isFinal: true, + metadata: { testcase: "system" }, + }, + { + id: "review-summation-2", + submissionId: "submission-002", + aggregateScore: 88.5, + isFinal: false, + isProvisional: true, + metadata: { testcase: "provisional-a" }, + }, + { + id: "review-summation-3", + submissionId: "submission-002", + aggregateScore: 91.3, + isFinal: true, + metadata: { testcase: "final" }, + }, + ]; + const downloadBodies = new Map([ + ["submission-001", Buffer.from("zip-one")], + ["submission-002", Buffer.from("zip-two")], + ["submission-003", Buffer.from("zip-three")], + ]); + + const server = http.createServer((req, res) => { + const url = new URL(req.url, "http://127.0.0.1"); + requests.push({ + method: req.method, + path: url.pathname, + search: url.search, + authorization: req.headers.authorization, + }); + + if (req.headers.authorization !== "Bearer test-token") { + res.writeHead(401, { "content-type": "application/json" }); + res.end(JSON.stringify({ message: "unauthorized" })); + return; + } + + if (url.pathname === "/challenge-api/challenges/challenge-123") { + createJsonResponse(res, challengePayload); + return; + } + + if (url.pathname === "/review-api/submissions") { + const page = Number.parseInt(url.searchParams.get("page"), 10); + const perPage = Number.parseInt(url.searchParams.get("perPage"), 10); + expect(url.searchParams.get("challengeId")).toBe("challenge-123"); + const startIndex = (page - 1) * perPage; + const pagedData = submissionPayloads.slice(startIndex, startIndex + perPage); + createJsonResponse(res, { + data: pagedData, + meta: { + page, + perPage, + totalCount: submissionPayloads.length, + totalPages: Math.ceil(submissionPayloads.length / perPage), + }, + }); + return; + } + + if (url.pathname === "/review-api/reviewSummations") { + const page = Number.parseInt(url.searchParams.get("page"), 10); + const perPage = Number.parseInt(url.searchParams.get("perPage"), 10); + expect(url.searchParams.get("challengeId")).toBe("challenge-123"); + expect(url.searchParams.get("metadata")).toBe("true"); + const startIndex = (page - 1) * perPage; + const pagedData = reviewSummationPayloads.slice(startIndex, startIndex + perPage); + createJsonResponse(res, { + data: pagedData, + meta: { + page, + perPage, + totalCount: reviewSummationPayloads.length, + totalPages: Math.ceil(reviewSummationPayloads.length / perPage), + }, + }); + return; + } + + const downloadMatch = url.pathname.match(/^\/review-api\/submissions\/([^/]+)\/download$/); + if (downloadMatch) { + const body = downloadBodies.get(downloadMatch[1]); + if (!body) { + res.writeHead(404, { "content-type": "application/json" }); + res.end(JSON.stringify({ message: "missing" })); + return; + } + res.writeHead(200, { "content-type": "application/zip" }); + res.end(body); + return; + } + + res.writeHead(404, { "content-type": "application/json" }); + res.end(JSON.stringify({ message: "not found" })); + }); + + await new Promise((resolve) => { + server.listen(0, "127.0.0.1", resolve); + }); + + const address = server.address(); + const baseUrl = `http://127.0.0.1:${address.port}`; + + return { + baseUrl, + challengePayload, + downloadBodies, + reviewSummationPayloads, + server, + requests, + }; +}; + +describe("exportMarathonMatchSubmissions", () => { + let fixtureServer; + let outputDir; + + beforeEach(async () => { + fixtureServer = await startFixtureServer(); + outputDir = fs.mkdtempSync(path.join(os.tmpdir(), "mm-export-output-")); + }); + + afterEach(async () => { + if (fixtureServer?.server) { + await new Promise((resolve, reject) => { + fixtureServer.server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); + } + fs.rmSync(outputDir, { recursive: true, force: true }); + }); + + test("exports challenge metadata, submission archives, and per-submission review summations", async () => { + const stdout = { write: jest.fn() }; + const stderr = { write: jest.fn() }; + + const result = await runExport({ + challengeId: "challenge-123", + outputDir, + challengeApiUrl: `${fixtureServer.baseUrl}/challenge-api/challenges`, + reviewApiUrl: `${fixtureServer.baseUrl}/review-api`, + token: "test-token", + pageSize: 1, + concurrency: 2, + stdout, + stderr, + }); + + expect(result).toEqual({ + outputDir, + metadataPath: path.join(outputDir, "metadata.json"), + submissionsDir: path.join(outputDir, "submissions"), + exportedSubmissionCount: 2, + exportedSubmitterCount: 2, + reviewSummationCount: 3, + skippedSubmissionCountWithoutReviewSummation: 1, + downloadedSubmissionCount: 2, + failedDownloadCount: 0, + }); + + expect(JSON.parse(fs.readFileSync(path.join(outputDir, "metadata.json"), "utf8"))).toEqual( + fixtureServer.challengePayload + ); + + expect( + fs.readFileSync(path.join(outputDir, "submissions", "coder_1001", "submission-001.zip")) + ).toEqual(fixtureServer.downloadBodies.get("submission-001")); + expect( + fs.readFileSync(path.join(outputDir, "submissions", "coder_1002", "submission-002.zip")) + ).toEqual(fixtureServer.downloadBodies.get("submission-002")); + expect( + fs.existsSync(path.join(outputDir, "submissions", "coder_1003")) + ).toBe(false); + + expect( + JSON.parse( + fs.readFileSync( + path.join(outputDir, "submissions", "coder_1001", "submission-001.json"), + "utf8" + ) + ) + ).toEqual([ + fixtureServer.reviewSummationPayloads[0], + ]); + expect( + JSON.parse( + fs.readFileSync( + path.join(outputDir, "submissions", "coder_1002", "submission-002.json"), + "utf8" + ) + ) + ).toEqual([ + fixtureServer.reviewSummationPayloads[1], + fixtureServer.reviewSummationPayloads[2], + ]); + + expect( + fixtureServer.requests.filter((entry) => entry.path === "/review-api/submissions") + ).toHaveLength(3); + expect( + fixtureServer.requests.filter((entry) => entry.path === "/review-api/reviewSummations") + ).toHaveLength(3); + expect( + fixtureServer.requests.filter( + (entry) => entry.path === "/review-api/submissions/submission-003/download" + ) + ).toHaveLength(0); + expect(stdout.write).toHaveBeenCalledWith( + `Exporting Marathon Match challenge-123 to ${outputDir}\n` + ); + expect(stdout.write).toHaveBeenCalledWith( + `Exported 2 submissions for 2 submitters to ${outputDir} ` + + "(1 skipped without review summations, 2 archive downloads succeeded, 0 archive failures)\n" + ); + expect(stderr.write).not.toHaveBeenCalled(); + }); + + test("continues when a submission archive download fails and logs the error", async () => { + fixtureServer.downloadBodies.delete("submission-002"); + + const stdout = { write: jest.fn() }; + const stderr = { write: jest.fn() }; + + const result = await runExport({ + challengeId: "challenge-123", + outputDir, + challengeApiUrl: `${fixtureServer.baseUrl}/challenge-api/challenges`, + reviewApiUrl: `${fixtureServer.baseUrl}/review-api`, + token: "test-token", + pageSize: 10, + concurrency: 1, + stdout, + stderr, + }); + + expect(result).toEqual({ + outputDir, + metadataPath: path.join(outputDir, "metadata.json"), + submissionsDir: path.join(outputDir, "submissions"), + exportedSubmissionCount: 2, + exportedSubmitterCount: 2, + reviewSummationCount: 3, + skippedSubmissionCountWithoutReviewSummation: 1, + downloadedSubmissionCount: 1, + failedDownloadCount: 1, + }); + + expect( + fs.readFileSync(path.join(outputDir, "submissions", "coder_1001", "submission-001.zip")) + ).toEqual(fixtureServer.downloadBodies.get("submission-001")); + expect( + fs.existsSync(path.join(outputDir, "submissions", "coder_1002", "submission-002.zip")) + ).toBe(false); + expect( + fs.existsSync(path.join(outputDir, "submissions", "coder_1003")) + ).toBe(false); + expect( + JSON.parse( + fs.readFileSync( + path.join(outputDir, "submissions", "coder_1002", "submission-002.json"), + "utf8" + ) + ) + ).toEqual([ + fixtureServer.reviewSummationPayloads[1], + fixtureServer.reviewSummationPayloads[2], + ]); + + expect(stderr.write).toHaveBeenCalledWith( + expect.stringContaining("Failed to download submission submission-002 for member 1002.") + ); + expect(stderr.write).toHaveBeenCalledWith( + expect.stringContaining("Request failed for") + ); + expect(stdout.write).toHaveBeenCalledWith( + `Exported 2 submissions for 2 submitters to ${outputDir} ` + + "(1 skipped without review summations, 1 archive downloads succeeded, 1 archive failures)\n" + ); + }); + + test("parseArgs requires challenge id and output directory", () => { + expect(() => parseArgs(["--challenge-id", "challenge-123"])).toThrow( + "--output-dir is required." + ); + expect(() => parseArgs(["--output-dir", "/tmp/export"])).toThrow( + "--challenge-id is required." + ); + }); +}); diff --git a/data-migration/test/importHistoricalMarathonMatches.apply.test.js b/data-migration/test/importHistoricalMarathonMatches.apply.test.js index 6c240f5..a3128a5 100644 --- a/data-migration/test/importHistoricalMarathonMatches.apply.test.js +++ b/data-migration/test/importHistoricalMarathonMatches.apply.test.js @@ -1,3 +1,4 @@ +const fs = require("fs"); const os = require("os"); const path = require("path"); @@ -7,6 +8,7 @@ const { applyCreateRound, reconcileSubmitterResourcesForRound, runApplyMode, + runTargetedRerunMode, } = require("../src/scripts/importHistoricalMarathonMatches/apply"); const buildSkippedFilePath = (suffix) => @@ -15,6 +17,67 @@ const buildSkippedFilePath = (suffix) => `mm-apply-skipped-${suffix}-${Date.now()}-${Math.random().toString(16).slice(2)}.json` ); +const buildArchiveDirPath = (suffix) => + fs.mkdtempSync( + path.join( + os.tmpdir(), + `mm-targeted-archive-${suffix}-${Date.now()}-${Math.random().toString(16).slice(2)}-` + ) + ); + +const writeJson = (baseDir, fileName, rootKey, rows) => { + fs.writeFileSync( + path.join(baseDir, fileName), + `${JSON.stringify({ [rootKey]: rows }, null, 2)}\n`, + "utf8" + ); +}; + +const createTargetedScoreFixtureDataDirectory = () => { + const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "mm-targeted-score-fixture-")); + + writeJson(baseDir, "long_component_state_1.json", "long_component_state", [ + { long_component_state_id: "1001", round_id: "9892", coder_id: "1", points: "100.0" }, + ]); + writeJson(baseDir, "long_comp_result_1.json", "long_comp_result", [ + { round_id: "9892", coder_id: "1", system_point_total: "100.0", point_total: "95.0", placed: "1" }, + ]); + writeJson(baseDir, "long_submission_1.json", "long_submission", [ + { + long_component_state_id: "1001", + submission_number: "1", + example: "0", + submit_time: "1000", + submission_points: "9.5", + }, + ]); + + return baseDir; +}; + +const readSingleEntryStoredZip = (zipPath) => { + const zipBuffer = fs.readFileSync(zipPath); + expect(zipBuffer.readUInt32LE(0)).toBe(0x04034b50); + const fileNameLength = zipBuffer.readUInt16LE(26); + const extraFieldLength = zipBuffer.readUInt16LE(28); + const compressedSize = zipBuffer.readUInt32LE(18); + const localFileDataOffset = 30 + fileNameLength + extraFieldLength; + const fileName = zipBuffer + .slice(30, 30 + fileNameLength) + .toString("utf8"); + const contents = zipBuffer + .slice(localFileDataOffset, localFileDataOffset + compressedSize) + .toString("utf8"); + + const centralDirectoryOffset = localFileDataOffset + compressedSize; + expect(zipBuffer.readUInt32LE(centralDirectoryOffset)).toBe(0x02014b50); + const endRecordOffset = zipBuffer.length - 22; + expect(zipBuffer.readUInt32LE(endRecordOffset)).toBe(0x06054b50); + expect(zipBuffer.readUInt16LE(endRecordOffset + 8)).toBe(1); + + return { fileName, contents }; +}; + describe("importHistoricalMarathonMatches apply create-path behavior", () => { test("derives coherent closed MM phase windows from legacy activity", () => { const windows = derivePhaseWindows("9892", { @@ -76,7 +139,11 @@ describe("importHistoricalMarathonMatches apply create-path behavior", () => { }); test("apply create-path inserts one completed challenge and phase trio for missing rounds", async () => { - const calls = { createdChallenge: null, createdPhases: null }; + const calls = { + createdChallenge: null, + createdPhases: null, + createdMetadata: null, + }; const tx = { challenge: { findMany: jest.fn().mockResolvedValue([]), @@ -91,6 +158,13 @@ describe("importHistoricalMarathonMatches apply create-path behavior", () => { return { count: data.length }; }), }, + challengeMetadata: { + findMany: jest.fn().mockResolvedValue([]), + create: jest.fn().mockImplementation(async ({ data }) => { + calls.createdMetadata = data; + return { id: "metadata-1" }; + }), + }, }; const prisma = { @@ -100,7 +174,12 @@ describe("importHistoricalMarathonMatches apply create-path behavior", () => { const result = await applyCreateRound({ prisma, roundId: "9892", - round: { round_id: "9892", name: "Intel Multi-Threading Competition 2", short_name: "Intel 2" }, + round: { + round_id: "9892", + name: "Intel Multi-Threading Competition 2", + short_name: "Intel 2", + rated_ind: "0", + }, counters: { registrationStartMs: Date.parse("2020-01-01T00:00:00.000Z"), registrationEndMs: Date.parse("2020-01-01T12:00:00.000Z"), @@ -131,11 +210,20 @@ describe("importHistoricalMarathonMatches apply create-path behavior", () => { typeId: "type-mm", trackId: "track-ds", timelineTemplateId: "timeline-mm", + description: "Imported historical Marathon Match from legacy round 9892", + descriptionFormat: "markdown", status: "COMPLETED", currentPhaseNames: [], numOfRegistrants: 2, numOfSubmissions: 3, }); + expect(calls.createdMetadata).toEqual({ + challengeId: "challenge-1", + name: "isRated", + value: "false", + createdBy: "importer", + updatedBy: "importer", + }); expect(calls.createdPhases).toHaveLength(3); expect(calls.createdPhases.map((row) => row.name)).toEqual([ "Registration", @@ -144,6 +232,105 @@ describe("importHistoricalMarathonMatches apply create-path behavior", () => { ]); }); + test("apply create-path uses mapped raw legacy problem HTML when available", async () => { + const calls = { createdChallenge: null }; + const tx = { + challenge: { + findMany: jest.fn().mockResolvedValue([]), + create: jest.fn().mockImplementation(async ({ data }) => { + calls.createdChallenge = data; + return { id: "challenge-1" }; + }), + }, + challengePhase: { + createMany: jest.fn().mockResolvedValue({ count: 3 }), + }, + }; + + const prisma = { + $transaction: async (callback) => callback(tx), + }; + + const rawProblemHtml = "

Legacy description

"; + await applyCreateRound({ + prisma, + roundId: "9892", + round: { round_id: "9892", name: "MM 9892" }, + counters: { + registrationStartMs: Date.parse("2020-01-01T00:00:00.000Z"), + registrationEndMs: Date.parse("2020-01-01T12:00:00.000Z"), + earliestSubmissionOpenMs: Date.parse("2020-01-01T01:00:00.000Z"), + earliestNonExampleSubmitMs: Date.parse("2020-01-01T02:00:00.000Z"), + latestNonExampleSubmitMs: Date.parse("2020-01-02T00:00:00.000Z"), + eligibleRegistrants: new Set(["1", "2"]), + nonExampleSubmissions: 3, + descriptionProblemText: rawProblemHtml, + }, + actor: "importer", + marathonTypeId: "type-mm", + dataScienceTrackId: "track-ds", + timelineTemplateId: "timeline-mm", + phaseIdsByName: { + Registration: "phase-registration", + Submission: "phase-submission", + Review: "phase-review", + }, + }); + + expect(calls.createdChallenge.description).toBe(rawProblemHtml); + expect(calls.createdChallenge.descriptionFormat).toBe("html"); + }); + + test("apply create-path falls back to mapped component_text markdown when problem text is unusable", async () => { + const calls = { createdChallenge: null }; + const tx = { + challenge: { + findMany: jest.fn().mockResolvedValue([]), + create: jest.fn().mockImplementation(async ({ data }) => { + calls.createdChallenge = data; + return { id: "challenge-1" }; + }), + }, + challengePhase: { + createMany: jest.fn().mockResolvedValue({ count: 3 }), + }, + }; + + const prisma = { + $transaction: async (callback) => callback(tx), + }; + + const markdownFallback = "## Robot Routing\n\nPublic example only."; + await applyCreateRound({ + prisma, + roundId: "9892", + round: { round_id: "9892", name: "MM 9892" }, + counters: { + registrationStartMs: Date.parse("2020-01-01T00:00:00.000Z"), + registrationEndMs: Date.parse("2020-01-01T12:00:00.000Z"), + earliestSubmissionOpenMs: Date.parse("2020-01-01T01:00:00.000Z"), + earliestNonExampleSubmitMs: Date.parse("2020-01-01T02:00:00.000Z"), + latestNonExampleSubmitMs: Date.parse("2020-01-02T00:00:00.000Z"), + eligibleRegistrants: new Set(["1", "2"]), + nonExampleSubmissions: 3, + descriptionProblemText: " ", + descriptionComponentTextMarkdown: markdownFallback, + }, + actor: "importer", + marathonTypeId: "type-mm", + dataScienceTrackId: "track-ds", + timelineTemplateId: "timeline-mm", + phaseIdsByName: { + Registration: "phase-registration", + Submission: "phase-submission", + Review: "phase-review", + }, + }); + + expect(calls.createdChallenge.description).toBe(markdownFallback); + expect(calls.createdChallenge.descriptionFormat).toBe("markdown"); + }); + test("apply create-path is idempotent when challenge already exists", async () => { const calls = { createdPhases: null }; const tx = { @@ -256,6 +443,88 @@ describe("importHistoricalMarathonMatches apply create-path behavior", () => { expect(tx.challengePhase.createMany).not.toHaveBeenCalled(); }); + test("reuse path canonicalizes legacy rating metadata into one isRated flag", async () => { + const calls = { + updatedMetadata: null, + deletedMetadata: null, + }; + const tx = { + challenge: { + findMany: jest.fn().mockResolvedValue([ + { id: "existing-challenge-1", typeId: "type-mm", trackId: "track-ds" }, + ]), + create: jest.fn(), + }, + challengePhase: { + findMany: jest.fn().mockResolvedValue([ + { id: "cp-1", name: "Registration", isOpen: false }, + { id: "cp-2", name: "Submission", isOpen: false }, + { id: "cp-3", name: "Review", isOpen: false }, + ]), + createMany: jest.fn(), + }, + challengeMetadata: { + findMany: jest.fn().mockResolvedValue([ + { id: "metadata-rated", name: "rated", value: "true" }, + { id: "metadata-unrated", name: "unrated", value: "false" }, + ]), + create: jest.fn(), + update: jest.fn().mockImplementation(async ({ data }) => { + calls.updatedMetadata = data; + return { id: "metadata-rated" }; + }), + deleteMany: jest.fn().mockImplementation(async ({ where }) => { + calls.deletedMetadata = where; + return { count: 1 }; + }), + }, + }; + const prisma = { + $transaction: async (callback) => callback(tx), + }; + + const result = await applyCreateRound({ + prisma, + roundId: "9892", + round: { round_id: "9892", rated_ind: "0" }, + counters: { + registrationStartMs: Date.parse("2020-01-01T00:00:00.000Z"), + registrationEndMs: Date.parse("2020-01-01T12:00:00.000Z"), + earliestSubmissionOpenMs: Date.parse("2020-01-01T01:00:00.000Z"), + earliestNonExampleSubmitMs: Date.parse("2020-01-01T02:00:00.000Z"), + latestNonExampleSubmitMs: Date.parse("2020-01-02T00:00:00.000Z"), + eligibleRegistrants: new Set(["1", "2"]), + nonExampleSubmissions: 3, + }, + actor: "importer", + marathonTypeId: "type-mm", + dataScienceTrackId: "track-ds", + timelineTemplateId: "timeline-mm", + phaseIdsByName: { + Registration: "phase-registration", + Submission: "phase-submission", + Review: "phase-review", + }, + }); + + expect(result).toEqual({ + status: "existing", + challengeId: "existing-challenge-1", + legacyRoundId: "9892", + }); + expect(tx.challengeMetadata.create).not.toHaveBeenCalled(); + expect(calls.updatedMetadata).toEqual({ + name: "isRated", + value: "false", + updatedBy: "importer", + }); + expect(calls.deletedMetadata).toEqual({ + id: { + in: ["metadata-unrated"], + }, + }); + }); + test("apply-mode reruns converge create decisions by backfilling missing standard phases", async () => { const tx = { challenge: { @@ -624,6 +893,1300 @@ describe("importHistoricalMarathonMatches apply create-path behavior", () => { expect(tx.challengePhase.createMany).not.toHaveBeenCalled(); }); + test("targeted rerun mode fails closed when challenge-id override is missing", async () => { + await expect( + runTargetedRerunMode({ + options: { + roundIds: ["9892"], + }, + plan: { + records: [ + { + legacyRoundId: "9892", + decision: "reuse/backfill-only", + matchedChallengeId: "challenge-1", + }, + ], + }, + }) + ).rejects.toThrow("--targeted-rerun requires --challenge-id"); + }); + + test("targeted rerun mode fails closed when challenge-id override mismatches selected round", async () => { + await expect( + runTargetedRerunMode({ + options: { + roundIds: ["9892"], + challengeId: "challenge-2", + }, + plan: { + records: [ + { + legacyRoundId: "9892", + decision: "reuse/backfill-only", + matchedChallengeId: "challenge-1", + }, + ], + }, + }) + ).rejects.toThrow( + 'Targeted rerun challenge-id override "challenge-2" does not match selected round 9892 target challenge "challenge-1".' + ); + }); + + test("targeted rerun mode accepts matched rounds that are unresolved only because member resolution is unavailable", async () => { + const archiveDir = buildArchiveDirPath("member-resolution-unavailable"); + try { + const submissionArchiveStore = { + listSubmissionsByLegacyId: jest.fn().mockResolvedValue(new Map()), + updateSubmissionUrl: jest.fn().mockResolvedValue(undefined), + }; + + const result = await runTargetedRerunMode({ + options: { + roundIds: ["9892"], + challengeId: "challenge-1", + }, + plan: { + records: [ + { + legacyRoundId: "9892", + decision: "unresolved", + reason: "target-member-resolution-unavailable", + matchedChallengeId: "challenge-1", + }, + ], + roundDataById: new Map([["9892", {}]]), + }, + submissionArchiveStore, + submissionArchiveDir: archiveDir, + legacySubmissionRowsByRoundId: new Map(), + }); + + expect(submissionArchiveStore.listSubmissionsByLegacyId).toHaveBeenCalledWith({ + challengeId: "challenge-1", + }); + expect(result).toEqual({ + records: [ + { + recordType: "apply-record", + legacyRoundId: "9892", + status: "targeted-rerun-preserved", + challengeId: "challenge-1", + mode: "targeted-rerun", + writesAttempted: false, + descriptionUpdated: false, + descriptionSource: "existing-description-preserved-no-usable-legacy-problem-text", + legacyProblemId: null, + reason: "targeted-rerun-description-preserved-no-usable-legacy-problem-text", + submissionArchiveReconciliation: { + targetedSubmissions: 0, + archivesWritten: 0, + urlsUpdated: 0, + urlsAlreadyMatched: 0, + archiveDirectory: archiveDir, + }, + }, + ], + summary: { + recordType: "apply-summary", + created: 0, + existing: 0, + unmatched: 0, + unresolved: 0, + errors: 0, + targetedRerunValidated: 1, + targetedRerunDescriptionUpdated: 0, + targetedRerunDescriptionPreserved: 1, + targetedRerunSubmissionArchivesWritten: 0, + targetedRerunSubmissionUrlsUpdated: 0, + targetedRerunWritesAttempted: 0, + skippedFileArtifact: null, + }, + }); + } finally { + fs.rmSync(archiveDir, { recursive: true, force: true }); + } + }); + + test("targeted rerun mode backfills deterministic submission archives and URLs while applying mapped raw problem HTML", async () => { + const archiveDir = buildArchiveDirPath("description-and-archives"); + try { + const rawProblemHtml = "
Legacy HTML
"; + const prisma = { + challenge: { + findUnique: jest.fn().mockResolvedValue({ + description: "Old description", + descriptionFormat: "markdown", + }), + update: jest.fn().mockResolvedValue({ id: "challenge-1" }), + }, + }; + const submissionArchiveStore = { + listSubmissionsByLegacyId: jest.fn().mockResolvedValue( + new Map([ + ["50010001", { legacySubmissionId: "50010001", url: null }], + ["50010002", { legacySubmissionId: "50010002", url: "https://example.com/old.zip" }], + ]) + ), + updateSubmissionUrl: jest.fn().mockResolvedValue(undefined), + }; + + const result = await runTargetedRerunMode({ + options: { + roundIds: ["9892"], + challengeId: "challenge-1", + }, + plan: { + records: [ + { + legacyRoundId: "9892", + decision: "reuse/backfill-only", + matchedChallengeId: "challenge-1", + }, + ], + roundDataById: new Map([ + [ + "9892", + { + descriptionProblemText: rawProblemHtml, + descriptionProblemId: "9001", + }, + ], + ]), + }, + prisma, + submissionArchiveStore, + submissionArchiveDir: archiveDir, + legacySubmissionRowsByRoundId: new Map([ + [ + "9892", + [ + { legacySubmissionId: "50010001", submissionText: "first legacy submission text" }, + { legacySubmissionId: "50010002", submissionText: "second legacy submission text" }, + ], + ], + ]), + actor: "importer", + }); + + expect(prisma.challenge.update).toHaveBeenCalledWith({ + where: { id: "challenge-1" }, + data: { + description: rawProblemHtml, + descriptionFormat: "html", + updatedBy: "importer", + }, + select: { id: true }, + }); + expect(submissionArchiveStore.listSubmissionsByLegacyId).toHaveBeenCalledWith({ + challengeId: "challenge-1", + }); + expect(submissionArchiveStore.updateSubmissionUrl).toHaveBeenCalledTimes(2); + const updatedUrlsByLegacyId = Object.fromEntries( + submissionArchiveStore.updateSubmissionUrl.mock.calls.map(([call]) => [ + call.legacySubmissionId, + call.url, + ]) + ); + expect(Object.keys(updatedUrlsByLegacyId).sort()).toEqual(["50010001", "50010002"]); + Object.values(updatedUrlsByLegacyId).forEach((url) => { + expect(url.startsWith("https://s3.amazonaws.com/topcoder-submissions/")).toBe(true); + expect(url.endsWith(".zip")).toBe(true); + }); + expect(updatedUrlsByLegacyId["50010001"]).not.toBe(updatedUrlsByLegacyId["50010002"]); + + const archiveFiles = fs.readdirSync(archiveDir).filter((entry) => entry.endsWith(".zip")).sort(); + expect(archiveFiles).toHaveLength(2); + const entryInfo = readSingleEntryStoredZip(path.join(archiveDir, archiveFiles[0])); + expect(entryInfo.fileName.endsWith(".txt")).toBe(true); + expect(entryInfo.contents.length).toBeGreaterThan(0); + + expect(result).toEqual({ + records: [ + { + recordType: "apply-record", + legacyRoundId: "9892", + status: "targeted-rerun-applied", + challengeId: "challenge-1", + mode: "targeted-rerun", + writesAttempted: true, + descriptionUpdated: true, + descriptionSource: "legacy-problem-text", + legacyProblemId: "9001", + reason: "targeted-rerun-description-updated-from-legacy-problem-text", + submissionArchiveReconciliation: { + targetedSubmissions: 2, + archivesWritten: 2, + urlsUpdated: 2, + urlsAlreadyMatched: 0, + archiveDirectory: archiveDir, + }, + }, + ], + summary: { + recordType: "apply-summary", + created: 0, + existing: 0, + unmatched: 0, + unresolved: 0, + errors: 0, + targetedRerunValidated: 1, + targetedRerunDescriptionUpdated: 1, + targetedRerunDescriptionPreserved: 0, + targetedRerunSubmissionArchivesWritten: 2, + targetedRerunSubmissionUrlsUpdated: 2, + targetedRerunWritesAttempted: 1, + skippedFileArtifact: null, + }, + }); + } finally { + fs.rmSync(archiveDir, { recursive: true, force: true }); + } + }); + + test("targeted rerun mode backfills component_text markdown when problem text is unusable", async () => { + const archiveDir = buildArchiveDirPath("component-markdown-fallback"); + try { + const componentMarkdown = "## Robot Routing\n\nPublic example only."; + const prisma = { + challenge: { + findUnique: jest.fn().mockResolvedValue({ + description: "Old description", + descriptionFormat: "html", + }), + update: jest.fn().mockResolvedValue({ id: "challenge-1" }), + }, + }; + const submissionArchiveStore = { + listSubmissionsByLegacyId: jest.fn().mockResolvedValue( + new Map([["50010001", { legacySubmissionId: "50010001", url: null }]]) + ), + updateSubmissionUrl: jest.fn().mockResolvedValue(undefined), + }; + + const result = await runTargetedRerunMode({ + options: { + roundIds: ["9892"], + challengeId: "challenge-1", + }, + plan: { + records: [ + { + legacyRoundId: "9892", + decision: "reuse/backfill-only", + matchedChallengeId: "challenge-1", + }, + ], + roundDataById: new Map([ + [ + "9892", + { + descriptionProblemText: " ", + descriptionProblemId: "9001", + descriptionComponentId: "5503", + descriptionComponentTextMarkdown: componentMarkdown, + }, + ], + ]), + }, + prisma, + submissionArchiveStore, + submissionArchiveDir: archiveDir, + legacySubmissionRowsByRoundId: new Map([ + [ + "9892", + [{ legacySubmissionId: "50010001", submissionText: "single legacy submission text" }], + ], + ]), + actor: "importer", + }); + + expect(prisma.challenge.update).toHaveBeenCalledWith({ + where: { id: "challenge-1" }, + data: { + description: componentMarkdown, + descriptionFormat: "markdown", + updatedBy: "importer", + }, + select: { id: true }, + }); + expect(result.records).toEqual([ + { + recordType: "apply-record", + legacyRoundId: "9892", + status: "targeted-rerun-applied", + challengeId: "challenge-1", + mode: "targeted-rerun", + writesAttempted: true, + descriptionUpdated: true, + descriptionSource: "legacy-component-text-markdown", + legacyProblemId: null, + legacyComponentId: "5503", + reason: "targeted-rerun-description-updated-from-legacy-component-text-markdown", + submissionArchiveReconciliation: { + targetedSubmissions: 1, + archivesWritten: 1, + urlsUpdated: 1, + urlsAlreadyMatched: 0, + archiveDirectory: archiveDir, + }, + }, + ]); + } finally { + fs.rmSync(archiveDir, { recursive: true, force: true }); + } + }); + + test("targeted rerun mode updates description format even when the description text already matches", async () => { + const archiveDir = buildArchiveDirPath("description-format-only"); + try { + const componentMarkdown = "## Robot Routing\n\nPublic example only."; + const prisma = { + challenge: { + findUnique: jest.fn().mockResolvedValue({ + description: componentMarkdown, + descriptionFormat: null, + }), + update: jest.fn().mockResolvedValue({ id: "challenge-1" }), + }, + }; + const submissionArchiveStore = { + listSubmissionsByLegacyId: jest.fn().mockResolvedValue(new Map()), + updateSubmissionUrl: jest.fn().mockResolvedValue(undefined), + }; + + const result = await runTargetedRerunMode({ + options: { + roundIds: ["9892"], + challengeId: "challenge-1", + }, + plan: { + records: [ + { + legacyRoundId: "9892", + decision: "reuse/backfill-only", + matchedChallengeId: "challenge-1", + }, + ], + roundDataById: new Map([ + [ + "9892", + { + descriptionComponentId: "5503", + descriptionComponentTextMarkdown: componentMarkdown, + }, + ], + ]), + }, + prisma, + submissionArchiveStore, + submissionArchiveDir: archiveDir, + legacySubmissionRowsByRoundId: new Map([["9892", []]]), + actor: "importer", + }); + + expect(prisma.challenge.update).toHaveBeenCalledWith({ + where: { id: "challenge-1" }, + data: { + description: componentMarkdown, + descriptionFormat: "markdown", + updatedBy: "importer", + }, + select: { id: true }, + }); + expect(result.records).toEqual([ + { + recordType: "apply-record", + legacyRoundId: "9892", + status: "targeted-rerun-applied", + challengeId: "challenge-1", + mode: "targeted-rerun", + writesAttempted: true, + descriptionUpdated: true, + descriptionSource: "legacy-component-text-markdown", + legacyProblemId: null, + legacyComponentId: "5503", + reason: "targeted-rerun-description-updated-from-legacy-component-text-markdown", + submissionArchiveReconciliation: { + targetedSubmissions: 0, + archivesWritten: 0, + urlsUpdated: 0, + urlsAlreadyMatched: 0, + archiveDirectory: archiveDir, + }, + }, + ]); + } finally { + fs.rmSync(archiveDir, { recursive: true, force: true }); + } + }); + + test("targeted rerun mode preserves existing description but still backfills submission archive URLs", async () => { + const archiveDir = buildArchiveDirPath("preserve-description"); + try { + const prisma = { + challenge: { + update: jest.fn(), + }, + }; + const submissionArchiveStore = { + listSubmissionsByLegacyId: jest.fn().mockResolvedValue( + new Map([["50010001", { legacySubmissionId: "50010001", url: null }]]) + ), + updateSubmissionUrl: jest.fn().mockResolvedValue(undefined), + }; + + const result = await runTargetedRerunMode({ + options: { + roundIds: ["9892"], + challengeId: "challenge-1", + }, + plan: { + records: [ + { + legacyRoundId: "9892", + decision: "reuse/backfill-only", + matchedChallengeId: "challenge-1", + }, + ], + roundDataById: new Map([ + [ + "9892", + { + descriptionProblemText: " ", + descriptionProblemId: "9001", + }, + ], + ]), + }, + prisma, + submissionArchiveStore, + submissionArchiveDir: archiveDir, + legacySubmissionRowsByRoundId: new Map([ + [ + "9892", + [{ legacySubmissionId: "50010001", submissionText: "single legacy submission text" }], + ], + ]), + actor: "importer", + }); + + expect(prisma.challenge.update).not.toHaveBeenCalled(); + expect(submissionArchiveStore.updateSubmissionUrl).toHaveBeenCalledTimes(1); + expect(result.records).toEqual([ + { + recordType: "apply-record", + legacyRoundId: "9892", + status: "targeted-rerun-preserved", + challengeId: "challenge-1", + mode: "targeted-rerun", + writesAttempted: true, + descriptionUpdated: false, + descriptionSource: "existing-description-preserved-no-usable-legacy-problem-text", + legacyProblemId: null, + reason: "targeted-rerun-description-preserved-no-usable-legacy-problem-text", + submissionArchiveReconciliation: { + targetedSubmissions: 1, + archivesWritten: 1, + urlsUpdated: 1, + urlsAlreadyMatched: 0, + archiveDirectory: archiveDir, + }, + }, + ]); + expect(result.summary).toEqual({ + recordType: "apply-summary", + created: 0, + existing: 0, + unmatched: 0, + unresolved: 0, + errors: 0, + targetedRerunValidated: 1, + targetedRerunDescriptionUpdated: 0, + targetedRerunDescriptionPreserved: 1, + targetedRerunSubmissionArchivesWritten: 1, + targetedRerunSubmissionUrlsUpdated: 1, + targetedRerunWritesAttempted: 1, + skippedFileArtifact: null, + }); + } finally { + fs.rmSync(archiveDir, { recursive: true, force: true }); + } + }); + + test("targeted rerun mode updates existing final and provisional scores", async () => { + const archiveDir = buildArchiveDirPath("score-reconciliation"); + const fixtureDir = createTargetedScoreFixtureDataDirectory(); + try { + const prisma = { + challenge: { + findUnique: jest.fn().mockResolvedValue({ + winners: [], + }), + update: jest.fn().mockResolvedValue({ id: "challenge-1" }), + }, + }; + const submissionArchiveStore = { + listSubmissionsByLegacyId: jest.fn().mockResolvedValue(new Map()), + updateSubmissionUrl: jest.fn().mockResolvedValue(undefined), + }; + const finalScoreStore = { + listImportedNonExampleSubmissionsByChallenge: jest.fn().mockResolvedValue([ + { + id: "sub-1", + memberId: "101", + legacySubmissionId: "10010001", + submittedDate: new Date("2020-01-01T01:00:00.000Z"), + createdAt: new Date("2020-01-01T01:00:00.000Z"), + }, + ]), + listExistingFinalSummationsBySubmissionId: jest.fn().mockResolvedValue( + new Map([ + [ + "sub-1", + [{ id: "final-1", submissionId: "sub-1", aggregateScore: 1 }], + ], + ]) + ), + createFinalSummation: jest.fn().mockResolvedValue(undefined), + updateFinalSummation: jest.fn().mockResolvedValue(undefined), + }; + const provisionalScoreStore = { + listImportedNonExampleSubmissionsByLegacySubmissionId: jest.fn().mockResolvedValue( + new Map([ + [ + "10010001", + { + id: "sub-1", + memberId: "101", + legacySubmissionId: "10010001", + submittedDate: new Date("2020-01-01T01:00:00.000Z"), + createdAt: new Date("2020-01-01T01:00:00.000Z"), + }, + ], + ]) + ), + listExistingProvisionalSummationsBySubmissionId: jest.fn().mockResolvedValue( + new Map([ + [ + "sub-1", + [{ id: "prov-1", submissionId: "sub-1", aggregateScore: 2 }], + ], + ]) + ), + createProvisionalSummation: jest.fn().mockResolvedValue(undefined), + updateProvisionalSummation: jest.fn().mockResolvedValue(undefined), + }; + + const result = await runTargetedRerunMode({ + options: { + roundIds: ["9892"], + challengeId: "challenge-1", + dataDir: fixtureDir, + longComponentStateFile: "long_component_state_1.json", + longSubmissionPattern: "^long_submission_\\d+\\.json$", + longCompResultPattern: "^long_comp_result_\\d+\\.json$", + }, + plan: { + records: [ + { + legacyRoundId: "9892", + decision: "reuse/backfill-only", + matchedChallengeId: "challenge-1", + }, + ], + roundDataById: new Map([ + [ + "9892", + { + finalCandidateCoderIds: new Set(["1"]), + }, + ], + ]), + }, + prisma, + submissionArchiveStore, + finalScoreStore, + provisionalScoreStore, + submissionArchiveDir: archiveDir, + legacySubmissionRowsByRoundId: new Map([["9892", []]]), + actor: "importer", + normalizedIdentityByCoderId: new Map([ + ["1", { coderId: "1", memberId: 101, memberHandle: "alpha" }], + ]), + }); + + expect(finalScoreStore.updateFinalSummation).toHaveBeenCalledWith( + expect.objectContaining({ + reviewSummationId: "final-1", + submissionId: "sub-1", + aggregateScore: 100, + legacySubmissionId: "10010001", + isFinal: true, + }) + ); + expect(provisionalScoreStore.updateProvisionalSummation).toHaveBeenCalledWith( + expect.objectContaining({ + reviewSummationId: "prov-1", + submissionId: "sub-1", + aggregateScore: 9.5, + legacySubmissionId: "10010001", + isFinal: false, + }) + ); + expect(prisma.challenge.update).toHaveBeenCalledWith({ + where: { id: "challenge-1" }, + data: { + winners: { + deleteMany: { + type: "PLACEMENT", + }, + create: [ + { + userId: 101, + handle: "alpha", + placement: 1, + type: "PLACEMENT", + createdBy: "importer", + updatedBy: "importer", + }, + ], + }, + updatedBy: "importer", + }, + select: { id: true }, + }); + expect(result).toEqual({ + records: [ + { + recordType: "apply-record", + legacyRoundId: "9892", + status: "targeted-rerun-applied", + challengeId: "challenge-1", + mode: "targeted-rerun", + writesAttempted: true, + descriptionUpdated: false, + descriptionSource: "existing-description-preserved-no-usable-legacy-problem-text", + legacyProblemId: null, + reason: "targeted-rerun-description-preserved-no-usable-legacy-problem-text", + submissionArchiveReconciliation: { + targetedSubmissions: 0, + archivesWritten: 0, + urlsUpdated: 0, + urlsAlreadyMatched: 0, + archiveDirectory: archiveDir, + }, + finalScoreReconciliation: { + legacyFinalCandidates: 1, + importedFinalScores: 1, + alreadyPresentFinalScores: 0, + createdFinalScores: 0, + updatedFinalScores: 1, + missingMemberSkippedFinalScores: 0, + explicitSkippedFinalScores: 0, + runtimeSkipRecords: [], + }, + provisionalScoreReconciliation: { + legacyNonExampleProvisionalScores: 1, + legacyExampleOnlyFinalistProvisionalScores: 0, + importedProvisionalScores: 1, + alreadyPresentProvisionalScores: 0, + createdProvisionalScores: 0, + updatedProvisionalScores: 1, + demotedFinalScores: 0, + clearedSubmissionFinalScoreSummaries: 0, + malformedSkippedProvisionalScores: 0, + missingMemberSkippedProvisionalScores: 0, + importedDistinctSubmitters: 1, + missingMemberDistinctSubmitters: 0, + importedProvisionalCountsByMemberId: { + 101: 1, + }, + skippedProvisionalRecords: [], + }, + winnerReconciliation: { + updated: true, + winnerCount: 1, + }, + }, + ], + summary: { + recordType: "apply-summary", + created: 0, + existing: 0, + unmatched: 0, + unresolved: 0, + errors: 0, + targetedRerunValidated: 1, + targetedRerunDescriptionUpdated: 0, + targetedRerunDescriptionPreserved: 1, + targetedRerunSubmissionArchivesWritten: 0, + targetedRerunSubmissionUrlsUpdated: 0, + targetedRerunFinalScoresCreated: 0, + targetedRerunFinalScoresUpdated: 1, + targetedRerunProvisionalScoresCreated: 0, + targetedRerunProvisionalScoresUpdated: 1, + targetedRerunWinnerCount: 1, + targetedRerunWinnersUpdated: 1, + targetedRerunWritesAttempted: 1, + skippedFileArtifact: null, + }, + }); + } finally { + fs.rmSync(archiveDir, { recursive: true, force: true }); + fs.rmSync(fixtureDir, { recursive: true, force: true }); + } + }); + + test("targeted rerun mode demotes final scores from non-final legacy submissions", async () => { + const archiveDir = buildArchiveDirPath("score-demotion"); + const fixtureDir = fs.mkdtempSync(path.join(os.tmpdir(), "mm-targeted-score-demotion-")); + try { + writeJson(fixtureDir, "long_component_state_1.json", "long_component_state", [ + { + long_component_state_id: "1001", + round_id: "9892", + coder_id: "1", + points: "100.0", + submission_number: "2", + }, + ]); + writeJson(fixtureDir, "long_comp_result_1.json", "long_comp_result", [ + { + round_id: "9892", + coder_id: "1", + system_point_total: "100.0", + point_total: "95.0", + placed: "1", + }, + ]); + writeJson(fixtureDir, "long_submission_1.json", "long_submission", [ + { + long_component_state_id: "1001", + submission_number: "1", + example: "0", + submit_time: "1000", + submission_points: "9.5", + }, + { + long_component_state_id: "1001", + submission_number: "2", + example: "0", + submit_time: "2000", + submission_points: "11.5", + }, + ]); + + const prisma = { + challenge: { + findUnique: jest.fn().mockResolvedValue({ + winners: [], + }), + update: jest.fn().mockResolvedValue({ id: "challenge-1" }), + }, + }; + const importedSubmissions = [ + { + id: "sub-1", + memberId: "101", + legacySubmissionId: "10010001", + submittedDate: new Date("2020-01-01T01:00:00.000Z"), + createdAt: new Date("2020-01-01T01:00:00.000Z"), + }, + { + id: "sub-2", + memberId: "101", + legacySubmissionId: "10010002", + submittedDate: new Date("2020-01-01T02:00:00.000Z"), + createdAt: new Date("2020-01-01T02:00:00.000Z"), + }, + ]; + const existingFinalSummationsBySubmissionId = new Map([ + [ + "sub-1", + [{ id: "final-misclassified", submissionId: "sub-1", aggregateScore: 9.5 }], + ], + [ + "sub-2", + [{ id: "final-correct", submissionId: "sub-2", aggregateScore: 100 }], + ], + ]); + const finalScoreStore = { + listImportedNonExampleSubmissionsByChallenge: jest.fn().mockResolvedValue(importedSubmissions), + listExistingFinalSummationsBySubmissionId: jest.fn().mockResolvedValue( + existingFinalSummationsBySubmissionId + ), + createFinalSummation: jest.fn().mockResolvedValue(undefined), + updateFinalSummation: jest.fn().mockResolvedValue(undefined), + }; + const provisionalScoreStore = { + listImportedNonExampleSubmissionsByLegacySubmissionId: jest.fn().mockResolvedValue( + new Map(importedSubmissions.map((submission) => [submission.legacySubmissionId, submission])) + ), + listExistingProvisionalSummationsBySubmissionId: jest.fn().mockResolvedValue( + new Map([ + [ + "sub-1", + [{ id: "prov-1", submissionId: "sub-1", aggregateScore: 9.5 }], + ], + [ + "sub-2", + [{ id: "prov-2", submissionId: "sub-2", aggregateScore: 11.5 }], + ], + ]) + ), + listExistingFinalSummationsBySubmissionId: jest.fn().mockResolvedValue( + existingFinalSummationsBySubmissionId + ), + createProvisionalSummation: jest.fn().mockResolvedValue(undefined), + updateProvisionalSummation: jest.fn().mockResolvedValue(undefined), + clearSubmissionFinalScoreSummary: jest.fn().mockResolvedValue(true), + }; + + const result = await runTargetedRerunMode({ + options: { + roundIds: ["9892"], + challengeId: "challenge-1", + dataDir: fixtureDir, + longComponentStateFile: "long_component_state_1.json", + longSubmissionPattern: "^long_submission_\\d+\\.json$", + longCompResultPattern: "^long_comp_result_\\d+\\.json$", + }, + plan: { + records: [ + { + legacyRoundId: "9892", + decision: "reuse/backfill-only", + matchedChallengeId: "challenge-1", + }, + ], + roundDataById: new Map([ + [ + "9892", + { + finalCandidateCoderIds: new Set(["1"]), + }, + ], + ]), + }, + prisma, + submissionArchiveStore: { + listSubmissionsByLegacyId: jest.fn().mockResolvedValue(new Map()), + updateSubmissionUrl: jest.fn().mockResolvedValue(undefined), + }, + finalScoreStore, + provisionalScoreStore, + submissionArchiveDir: archiveDir, + legacySubmissionRowsByRoundId: new Map([["9892", []]]), + actor: "importer", + normalizedIdentityByCoderId: new Map([ + ["1", { coderId: "1", memberId: 101, memberHandle: "alpha" }], + ]), + }); + + expect(finalScoreStore.updateFinalSummation).not.toHaveBeenCalled(); + expect(provisionalScoreStore.updateProvisionalSummation).toHaveBeenCalledWith( + expect.objectContaining({ + reviewSummationId: "final-misclassified", + submissionId: "sub-1", + aggregateScore: 9.5, + legacySubmissionId: "10010001", + isFinal: false, + }) + ); + expect(provisionalScoreStore.clearSubmissionFinalScoreSummary).toHaveBeenCalledWith({ + submissionId: "sub-1", + }); + expect(provisionalScoreStore.createProvisionalSummation).not.toHaveBeenCalled(); + expect(result.records[0].provisionalScoreReconciliation).toEqual( + expect.objectContaining({ + importedProvisionalScores: 2, + alreadyPresentProvisionalScores: 1, + createdProvisionalScores: 0, + updatedProvisionalScores: 1, + demotedFinalScores: 1, + clearedSubmissionFinalScoreSummaries: 1, + }) + ); + } finally { + fs.rmSync(archiveDir, { recursive: true, force: true }); + fs.rmSync(fixtureDir, { recursive: true, force: true }); + } + }); + + test("targeted rerun mode repairs winners when scores already match", async () => { + const archiveDir = buildArchiveDirPath("winner-reconciliation-existing-scores"); + const fixtureDir = createTargetedScoreFixtureDataDirectory(); + try { + const prisma = { + challenge: { + findUnique: jest.fn().mockResolvedValue({ + winners: [], + }), + update: jest.fn().mockResolvedValue({ id: "challenge-1" }), + }, + }; + const submissionArchiveStore = { + listSubmissionsByLegacyId: jest.fn().mockResolvedValue(new Map()), + updateSubmissionUrl: jest.fn().mockResolvedValue(undefined), + }; + const submissionStore = { + listExistingSubmissionsByLegacyId: jest.fn().mockResolvedValue( + new Map([ + [ + "10010001", + { + legacySubmissionId: "10010001", + memberId: "101", + submitter: "101", + systemFileName: null, + virusScan: null, + isFileSubmission: null, + }, + ], + ]) + ), + createSubmission: jest.fn().mockResolvedValue(undefined), + updateSubmissionMetadata: jest.fn().mockResolvedValue(true), + }; + const finalScoreStore = { + listImportedNonExampleSubmissionsByChallenge: jest.fn().mockResolvedValue([ + { + id: "sub-1", + memberId: "101", + legacySubmissionId: "10010001", + submittedDate: new Date("2020-01-01T01:00:00.000Z"), + createdAt: new Date("2020-01-01T01:00:00.000Z"), + }, + ]), + listExistingFinalSummationsBySubmissionId: jest.fn().mockResolvedValue( + new Map([ + [ + "sub-1", + [{ id: "final-1", submissionId: "sub-1", aggregateScore: 100 }], + ], + ]) + ), + createFinalSummation: jest.fn().mockResolvedValue(undefined), + updateFinalSummation: jest.fn().mockResolvedValue(undefined), + }; + + const result = await runTargetedRerunMode({ + options: { + roundIds: ["9892"], + challengeId: "challenge-1", + dataDir: fixtureDir, + longComponentStateFile: "long_component_state_1.json", + longSubmissionPattern: "^long_submission_\\d+\\.json$", + longCompResultPattern: "^long_comp_result_\\d+\\.json$", + }, + plan: { + records: [ + { + legacyRoundId: "9892", + decision: "reuse/backfill-only", + matchedChallengeId: "challenge-1", + }, + ], + roundDataById: new Map([ + [ + "9892", + { + finalCandidateCoderIds: new Set(["1"]), + }, + ], + ]), + }, + prisma, + submissionStore, + submissionArchiveStore, + finalScoreStore, + submissionArchiveDir: archiveDir, + actor: "importer", + normalizedIdentityByCoderId: new Map([ + ["1", { coderId: "1", memberId: 101, memberHandle: null }], + ]), + resolveMemberIdentities: jest.fn().mockResolvedValue( + new Map([["101", { memberId: "101", memberHandle: "alpha" }]]) + ), + }); + + expect(finalScoreStore.updateFinalSummation).not.toHaveBeenCalled(); + expect(submissionStore.updateSubmissionMetadata).toHaveBeenCalledWith( + expect.objectContaining({ + challengeId: "challenge-1", + legacySubmissionId: "10010001", + memberId: "101", + memberHandle: "alpha", + }) + ); + expect(prisma.challenge.update).toHaveBeenCalledWith({ + where: { id: "challenge-1" }, + data: { + winners: { + deleteMany: { + type: "PLACEMENT", + }, + create: [ + { + userId: 101, + handle: "alpha", + placement: 1, + type: "PLACEMENT", + createdBy: "importer", + updatedBy: "importer", + }, + ], + }, + updatedBy: "importer", + }, + select: { id: true }, + }); + expect(result.records[0]).toEqual( + expect.objectContaining({ + status: "targeted-rerun-applied", + finalScoreReconciliation: { + legacyFinalCandidates: 1, + importedFinalScores: 1, + alreadyPresentFinalScores: 1, + createdFinalScores: 0, + updatedFinalScores: 0, + missingMemberSkippedFinalScores: 0, + explicitSkippedFinalScores: 0, + runtimeSkipRecords: [], + }, + submissionReconciliation: { + legacyNonExampleSubmissions: 1, + legacyExampleOnlyFinalistSubmissions: 0, + importedSubmissions: 1, + alreadyPresentSubmissions: 1, + createdSubmissions: 0, + missingMemberSkippedSubmissions: 0, + importedDistinctSubmitters: 1, + missingMemberDistinctSubmitters: 0, + importedSubmissionCountsByMemberId: { + 101: 1, + }, + skippedSubmissionRecords: [], + }, + winnerReconciliation: { + updated: true, + winnerCount: 1, + }, + }) + ); + expect(result.summary).toEqual( + expect.objectContaining({ + targetedRerunSubmissionsCreated: 0, + targetedRerunSubmissionsAlreadyPresent: 1, + targetedRerunFinalScoresUpdated: 0, + targetedRerunWinnerCount: 1, + targetedRerunWinnersUpdated: 1, + targetedRerunWritesAttempted: 1, + }) + ); + } finally { + fs.rmSync(archiveDir, { recursive: true, force: true }); + fs.rmSync(fixtureDir, { recursive: true, force: true }); + } + }); + + test("targeted rerun mode skips challenge description write when legacy problem HTML already matches", async () => { + const archiveDir = buildArchiveDirPath("problem-text-already-matched"); + try { + const rawProblemHtml = "
Legacy HTML
"; + const prisma = { + challenge: { + findUnique: jest.fn().mockResolvedValue({ + description: rawProblemHtml, + descriptionFormat: "html", + }), + update: jest.fn(), + }, + }; + const submissionArchiveStore = { + listSubmissionsByLegacyId: jest.fn().mockResolvedValue(new Map()), + updateSubmissionUrl: jest.fn().mockResolvedValue(undefined), + }; + + const result = await runTargetedRerunMode({ + options: { + roundIds: ["9892"], + challengeId: "challenge-1", + }, + plan: { + records: [ + { + legacyRoundId: "9892", + decision: "reuse/backfill-only", + matchedChallengeId: "challenge-1", + }, + ], + roundDataById: new Map([ + [ + "9892", + { + descriptionProblemText: rawProblemHtml, + descriptionProblemId: "9001", + }, + ], + ]), + }, + prisma, + submissionArchiveStore, + submissionArchiveDir: archiveDir, + legacySubmissionRowsByRoundId: new Map([["9892", []]]), + actor: "importer", + }); + + expect(prisma.challenge.findUnique).toHaveBeenCalledWith({ + where: { id: "challenge-1" }, + select: { description: true, descriptionFormat: true }, + }); + expect(prisma.challenge.update).not.toHaveBeenCalled(); + expect(result).toEqual({ + records: [ + { + recordType: "apply-record", + legacyRoundId: "9892", + status: "targeted-rerun-preserved", + challengeId: "challenge-1", + mode: "targeted-rerun", + writesAttempted: false, + descriptionUpdated: false, + descriptionSource: "legacy-problem-text", + legacyProblemId: "9001", + reason: "targeted-rerun-description-already-matched-legacy-problem-text", + submissionArchiveReconciliation: { + targetedSubmissions: 0, + archivesWritten: 0, + urlsUpdated: 0, + urlsAlreadyMatched: 0, + archiveDirectory: archiveDir, + }, + }, + ], + summary: { + recordType: "apply-summary", + created: 0, + existing: 0, + unmatched: 0, + unresolved: 0, + errors: 0, + targetedRerunValidated: 1, + targetedRerunDescriptionUpdated: 0, + targetedRerunDescriptionPreserved: 1, + targetedRerunSubmissionArchivesWritten: 0, + targetedRerunSubmissionUrlsUpdated: 0, + targetedRerunWritesAttempted: 0, + skippedFileArtifact: null, + }, + }); + } finally { + fs.rmSync(archiveDir, { recursive: true, force: true }); + } + }); + + test("targeted rerun mode skips challenge description write when component markdown already matches", async () => { + const archiveDir = buildArchiveDirPath("component-markdown-already-matched"); + try { + const componentMarkdown = "## Robot Routing\n\nPublic example only."; + const prisma = { + challenge: { + findUnique: jest.fn().mockResolvedValue({ + description: componentMarkdown, + descriptionFormat: "markdown", + }), + update: jest.fn(), + }, + }; + const submissionArchiveStore = { + listSubmissionsByLegacyId: jest.fn().mockResolvedValue(new Map()), + updateSubmissionUrl: jest.fn().mockResolvedValue(undefined), + }; + + const result = await runTargetedRerunMode({ + options: { + roundIds: ["9892"], + challengeId: "challenge-1", + }, + plan: { + records: [ + { + legacyRoundId: "9892", + decision: "reuse/backfill-only", + matchedChallengeId: "challenge-1", + }, + ], + roundDataById: new Map([ + [ + "9892", + { + descriptionProblemText: " ", + descriptionProblemId: "9001", + descriptionComponentId: "5503", + descriptionComponentTextMarkdown: componentMarkdown, + }, + ], + ]), + }, + prisma, + submissionArchiveStore, + submissionArchiveDir: archiveDir, + legacySubmissionRowsByRoundId: new Map([["9892", []]]), + actor: "importer", + }); + + expect(prisma.challenge.findUnique).toHaveBeenCalledWith({ + where: { id: "challenge-1" }, + select: { description: true, descriptionFormat: true }, + }); + expect(prisma.challenge.update).not.toHaveBeenCalled(); + expect(result).toEqual({ + records: [ + { + recordType: "apply-record", + legacyRoundId: "9892", + status: "targeted-rerun-preserved", + challengeId: "challenge-1", + mode: "targeted-rerun", + writesAttempted: false, + descriptionUpdated: false, + descriptionSource: "legacy-component-text-markdown", + legacyProblemId: null, + legacyComponentId: "5503", + reason: "targeted-rerun-description-already-matched-legacy-component-text-markdown", + submissionArchiveReconciliation: { + targetedSubmissions: 0, + archivesWritten: 0, + urlsUpdated: 0, + urlsAlreadyMatched: 0, + archiveDirectory: archiveDir, + }, + }, + ], + summary: { + recordType: "apply-summary", + created: 0, + existing: 0, + unmatched: 0, + unresolved: 0, + errors: 0, + targetedRerunValidated: 1, + targetedRerunDescriptionUpdated: 0, + targetedRerunDescriptionPreserved: 1, + targetedRerunSubmissionArchivesWritten: 0, + targetedRerunSubmissionUrlsUpdated: 0, + targetedRerunWritesAttempted: 0, + skippedFileArtifact: null, + }, + }); + } finally { + fs.rmSync(archiveDir, { recursive: true, force: true }); + } + }); + test("apply mode reconciles submitter resources from eligible registrations", async () => { const tx = { challenge: { diff --git a/data-migration/test/importHistoricalMarathonMatches.applyFinalScores.test.js b/data-migration/test/importHistoricalMarathonMatches.applyFinalScores.test.js index a21b3ab..1d51508 100644 --- a/data-migration/test/importHistoricalMarathonMatches.applyFinalScores.test.js +++ b/data-migration/test/importHistoricalMarathonMatches.applyFinalScores.test.js @@ -37,10 +37,16 @@ describe("importHistoricalMarathonMatches apply mode final-score wiring", () => }); test("apply-mode imports final scores and appends runtime unattachable-finalist skips", async () => { + const calls = { + createdChallenge: null, + }; const tx = { challenge: { findMany: jest.fn().mockResolvedValue([]), - create: jest.fn().mockResolvedValue({ id: "challenge-1" }), + create: jest.fn().mockImplementation(async ({ data }) => { + calls.createdChallenge = data; + return { id: "challenge-1" }; + }), }, challengePhase: { findMany: jest.fn().mockResolvedValue([]), @@ -162,8 +168,8 @@ describe("importHistoricalMarathonMatches apply mode final-score wiring", () => }, actor: "importer", normalizedIdentityByCoderId: new Map([ - ["1", { coderId: "1", memberId: 1, memberHandle: "alpha" }], - ["3", { coderId: "3", memberId: 3, memberHandle: "charlie" }], + ["1", { coderId: "1", memberId: 101, memberHandle: "alpha" }], + ["3", { coderId: "3", memberId: 303, memberHandle: "charlie" }], ]), }); @@ -175,6 +181,30 @@ describe("importHistoricalMarathonMatches apply mode final-score wiring", () => legacySubmissionId: "10010001", }) ); + expect(calls.createdChallenge).toEqual( + expect.objectContaining({ + winners: { + create: [ + expect.objectContaining({ + userId: 101, + handle: "alpha", + placement: 1, + type: "PLACEMENT", + createdBy: "importer", + updatedBy: "importer", + }), + expect.objectContaining({ + userId: 303, + handle: "charlie", + placement: 2, + type: "PLACEMENT", + createdBy: "importer", + updatedBy: "importer", + }), + ], + }, + }) + ); expect(result.records).toEqual([ expect.objectContaining({ recordType: "apply-record", @@ -191,7 +221,7 @@ describe("importHistoricalMarathonMatches apply mode final-score wiring", () => runtimeSkipRecords: [ expect.objectContaining({ reasonCode: "finalist-without-attachable-submission", - memberId: "3", + memberId: "303", }), ], }, diff --git a/data-migration/test/importHistoricalMarathonMatches.applyProvisionalScores.test.js b/data-migration/test/importHistoricalMarathonMatches.applyProvisionalScores.test.js index 0baedfa..2d6e7ce 100644 --- a/data-migration/test/importHistoricalMarathonMatches.applyProvisionalScores.test.js +++ b/data-migration/test/importHistoricalMarathonMatches.applyProvisionalScores.test.js @@ -230,6 +230,7 @@ describe("importHistoricalMarathonMatches apply mode provisional-score wiring", importedProvisionalScores: 1, alreadyPresentProvisionalScores: 0, createdProvisionalScores: 1, + malformedSkippedProvisionalScores: 0, missingMemberSkippedProvisionalScores: 1, importedDistinctSubmitters: 1, missingMemberDistinctSubmitters: 1, @@ -418,6 +419,7 @@ describe("importHistoricalMarathonMatches apply mode provisional-score wiring", importedProvisionalScores: 3, alreadyPresentProvisionalScores: 0, createdProvisionalScores: 3, + malformedSkippedProvisionalScores: 0, missingMemberSkippedProvisionalScores: 0, importedDistinctSubmitters: 3, missingMemberDistinctSubmitters: 0, diff --git a/data-migration/test/importHistoricalMarathonMatches.applySubmissions.test.js b/data-migration/test/importHistoricalMarathonMatches.applySubmissions.test.js index 6aabedb..d3ba9f2 100644 --- a/data-migration/test/importHistoricalMarathonMatches.applySubmissions.test.js +++ b/data-migration/test/importHistoricalMarathonMatches.applySubmissions.test.js @@ -5,6 +5,10 @@ const path = require("path"); const { runApplyMode, } = require("../src/scripts/importHistoricalMarathonMatches/apply"); +const { + buildSubmissionArchiveFileName, + buildSubmissionArchiveUrl, +} = require("../src/scripts/importHistoricalMarathonMatches/submissionArchives"); const writeJson = (baseDir, fileName, rootKey, rows) => { fs.writeFileSync( @@ -364,4 +368,540 @@ describe("importHistoricalMarathonMatches apply mode submission-history wiring", ]) ); }); + + test("apply-mode writes submission archives and URLs when archive generation is configured", async () => { + const archiveDir = path.join(fixtureDir, "archives"); + fs.mkdirSync(archiveDir, { recursive: true }); + + const tx = { + challenge: { + findMany: jest.fn().mockResolvedValue([]), + create: jest.fn().mockResolvedValue({ id: "challenge-1" }), + }, + challengePhase: { + findMany: jest.fn().mockResolvedValue([]), + createMany: jest.fn(), + }, + }; + + const phaseRows = [ + { id: "phase-registration", name: "Registration" }, + { id: "phase-submission", name: "Submission" }, + { id: "phase-review", name: "Review" }, + ]; + + const prisma = { + challengeType: { + findMany: jest.fn().mockResolvedValue([{ id: "type-mm" }]), + }, + challengeTrack: { + findMany: jest.fn().mockResolvedValue([{ id: "track-ds" }]), + }, + phase: { + findMany: jest + .fn() + .mockResolvedValueOnce(phaseRows) + .mockResolvedValueOnce(phaseRows), + }, + challengeTimelineTemplate: { + findMany: jest.fn().mockResolvedValue([ + { + timelineTemplateId: "timeline-mm", + isDefault: true, + timelineTemplate: { + phases: [ + { phaseId: "phase-registration" }, + { phaseId: "phase-submission" }, + { phaseId: "phase-review" }, + ], + }, + }, + ]), + }, + $transaction: async (callback) => callback(tx), + }; + + const submissionStoreRecords = new Map(); + const submissionStore = { + listExistingSubmissionsByLegacyId: async () => new Map(submissionStoreRecords), + createSubmission: async ({ legacySubmissionId, memberId, memberHandle }) => { + submissionStoreRecords.set(legacySubmissionId, { + legacySubmissionId, + memberId: String(memberId), + submitter: memberHandle || null, + url: null, + }); + }, + }; + const submissionArchiveStore = { + listExistingSubmissionsByLegacyId: async () => + new Map( + Array.from(submissionStoreRecords.entries()).map(([legacySubmissionId, submission]) => [ + legacySubmissionId, + { + legacySubmissionId, + url: submission.url, + }, + ]) + ), + updateSubmissionUrl: jest.fn().mockImplementation(async ({ legacySubmissionId, url }) => { + submissionStoreRecords.get(legacySubmissionId).url = url; + }), + }; + + const result = await runApplyMode({ + prisma, + options: { + roundIds: ["9892"], + skippedFilePath: path.join(fixtureDir, "apply-archive-submission-skipped.json"), + importSubmissions: true, + submissionStore, + submissionArchiveStore, + submissionArchiveDir: archiveDir, + dataDir: fixtureDir, + longComponentStateFile: "long_component_state_1.json", + longSubmissionPattern: "^long_submission_\\d+\\.json$", + resourceClient: { + listSubmitterResources: jest.fn().mockResolvedValue([]), + createSubmitterResource: jest.fn().mockResolvedValue({}), + }, + }, + plan: { + records: [ + { + legacyRoundId: "9892", + decision: "create", + reason: "no-matching-v6-challenge-found", + plannedSkipRecords: [], + }, + ], + roundDataById: new Map([ + [ + "9892", + { + round: { round_id: "9892", round_type_id: "13", name: "MM 9892" }, + registrationStartMs: Date.parse("2020-01-01T00:00:00.000Z"), + registrationEndMs: Date.parse("2020-01-01T12:00:00.000Z"), + earliestSubmissionOpenMs: Date.parse("2020-01-01T01:00:00.000Z"), + earliestNonExampleSubmitMs: Date.parse("2020-01-01T02:00:00.000Z"), + latestNonExampleSubmitMs: Date.parse("2020-01-02T00:00:00.000Z"), + eligibleRegistrants: new Set(["1", "2"]), + nonExampleSubmissions: 2, + nonExampleSubmitterCoderIds: new Set(["1", "2"]), + finalCandidateCoderIds: new Set(), + }, + ], + ]), + }, + actor: "importer", + normalizedIdentityByCoderId: new Map([ + ["1", { coderId: "1", memberId: 1, memberHandle: "alpha" }], + ["2", { coderId: "2", memberId: 2, memberHandle: "bravo" }], + ]), + }); + + expect(result.records).toEqual([ + expect.objectContaining({ + recordType: "apply-record", + legacyRoundId: "9892", + status: "created", + challengeId: "challenge-1", + submissionArchiveReconciliation: { + submissionsReconciled: 2, + archivesWritten: 2, + urlsUpdated: 2, + urlsAlreadyMatched: 0, + archiveDirectory: archiveDir, + }, + }), + ]); + expect(submissionArchiveStore.updateSubmissionUrl).toHaveBeenCalledTimes(2); + + const archiveFiles = fs.readdirSync(archiveDir).filter((entry) => entry.endsWith(".zip")).sort(); + expect(archiveFiles).toHaveLength(2); + + expect(submissionStoreRecords).toEqual( + new Map([ + [ + "10010001", + { + legacySubmissionId: "10010001", + memberId: "1", + submitter: "alpha", + url: buildSubmissionArchiveUrl({ + archiveFileName: buildSubmissionArchiveFileName({ + challengeId: "challenge-1", + legacySubmissionId: "10010001", + }), + }), + }, + ], + [ + "10020001", + { + legacySubmissionId: "10020001", + memberId: "2", + submitter: "bravo", + url: buildSubmissionArchiveUrl({ + archiveFileName: buildSubmissionArchiveFileName({ + challengeId: "challenge-1", + legacySubmissionId: "10020001", + }), + }), + }, + ], + ]) + ); + }); + + test("apply-mode backfills existing submission file metadata on rerun", async () => { + const tx = { + challenge: { + findMany: jest.fn().mockResolvedValue([]), + create: jest.fn().mockResolvedValue({ id: "challenge-1" }), + }, + challengePhase: { + findMany: jest.fn().mockResolvedValue([]), + createMany: jest.fn(), + }, + }; + + const phaseRows = [ + { id: "phase-registration", name: "Registration" }, + { id: "phase-submission", name: "Submission" }, + { id: "phase-review", name: "Review" }, + ]; + + const prisma = { + challengeType: { + findMany: jest.fn().mockResolvedValue([{ id: "type-mm" }]), + }, + challengeTrack: { + findMany: jest.fn().mockResolvedValue([{ id: "track-ds" }]), + }, + phase: { + findMany: jest + .fn() + .mockResolvedValueOnce(phaseRows) + .mockResolvedValueOnce(phaseRows), + }, + challengeTimelineTemplate: { + findMany: jest.fn().mockResolvedValue([ + { + timelineTemplateId: "timeline-mm", + isDefault: true, + timelineTemplate: { + phases: [ + { phaseId: "phase-registration" }, + { phaseId: "phase-submission" }, + { phaseId: "phase-review" }, + ], + }, + }, + ]), + }, + $transaction: async (callback) => callback(tx), + }; + + const submissionStoreRecords = new Map([ + [ + "10010001", + { + legacySubmissionId: "10010001", + memberId: "1", + submitter: "alpha", + systemFileName: null, + virusScan: false, + isFileSubmission: false, + }, + ], + ]); + const submissionStore = { + listExistingSubmissionsByLegacyId: async () => new Map(submissionStoreRecords), + createSubmission: jest.fn(), + updateSubmissionMetadata: async ({ challengeId, legacySubmissionId }) => { + const existingSubmission = submissionStoreRecords.get(legacySubmissionId); + existingSubmission.systemFileName = buildSubmissionArchiveFileName({ + challengeId, + legacySubmissionId, + }); + existingSubmission.virusScan = true; + existingSubmission.isFileSubmission = true; + }, + }; + + const result = await runApplyMode({ + prisma, + options: { + roundIds: ["9892"], + skippedFilePath: path.join(fixtureDir, "apply-existing-submission-skipped.json"), + importSubmissions: true, + submissionStore, + dataDir: fixtureDir, + longComponentStateFile: "long_component_state_1.json", + longSubmissionPattern: "^long_submission_\\d+\\.json$", + resourceClient: { + listSubmitterResources: jest.fn().mockResolvedValue([]), + createSubmitterResource: jest.fn().mockResolvedValue({}), + }, + }, + plan: { + records: [ + { + legacyRoundId: "9892", + decision: "create", + reason: "no-matching-v6-challenge-found", + plannedSkipRecords: [ + { + legacyRoundId: "9892", + memberId: "2", + reasonCode: "missing-member", + affectedSurfaces: ["resource", "submission"], + }, + ], + }, + ], + roundDataById: new Map([ + [ + "9892", + { + round: { round_id: "9892", round_type_id: "13", name: "MM 9892" }, + registrationStartMs: Date.parse("2020-01-01T00:00:00.000Z"), + registrationEndMs: Date.parse("2020-01-01T12:00:00.000Z"), + earliestSubmissionOpenMs: Date.parse("2020-01-01T01:00:00.000Z"), + earliestNonExampleSubmitMs: Date.parse("2020-01-01T02:00:00.000Z"), + latestNonExampleSubmitMs: Date.parse("2020-01-02T00:00:00.000Z"), + eligibleRegistrants: new Set(["1", "2"]), + nonExampleSubmissions: 2, + nonExampleSubmitterCoderIds: new Set(["1", "2"]), + finalCandidateCoderIds: new Set(), + }, + ], + ]), + }, + actor: "importer", + normalizedIdentityByCoderId: new Map([ + ["1", { coderId: "1", memberId: 1, memberHandle: "alpha" }], + ["2", { coderId: "2", memberId: 2, memberHandle: "bravo" }], + ]), + }); + + expect(result.records).toEqual([ + expect.objectContaining({ + recordType: "apply-record", + legacyRoundId: "9892", + status: "created", + challengeId: "challenge-1", + submissionReconciliation: { + legacyNonExampleSubmissions: 2, + legacyExampleOnlyFinalistSubmissions: 0, + importedSubmissions: 1, + alreadyPresentSubmissions: 1, + createdSubmissions: 0, + missingMemberSkippedSubmissions: 1, + importedDistinctSubmitters: 1, + missingMemberDistinctSubmitters: 1, + importedSubmissionCountsByMemberId: { + 1: 1, + }, + skippedSubmissionRecords: [ + expect.objectContaining({ + legacyRoundId: "9892", + memberId: "2", + reasonCode: "missing-member", + legacySubmissionId: "10020001", + affectedSurfaces: ["submission"], + }), + ], + }, + }), + ]); + expect(submissionStore.createSubmission).not.toHaveBeenCalled(); + expect(submissionStoreRecords.get("10010001")).toEqual({ + legacySubmissionId: "10010001", + memberId: "1", + submitter: "alpha", + systemFileName: buildSubmissionArchiveFileName({ + challengeId: "challenge-1", + legacySubmissionId: "10010001", + }), + virusScan: true, + isFileSubmission: true, + }); + }); + + test("apply-mode backfills existing submission archive URLs on rerun when archive generation is configured", async () => { + const archiveDir = path.join(fixtureDir, "rerun-archives"); + fs.mkdirSync(archiveDir, { recursive: true }); + + const tx = { + challenge: { + findMany: jest.fn().mockResolvedValue([]), + create: jest.fn().mockResolvedValue({ id: "challenge-1" }), + }, + challengePhase: { + findMany: jest.fn().mockResolvedValue([]), + createMany: jest.fn(), + }, + }; + + const phaseRows = [ + { id: "phase-registration", name: "Registration" }, + { id: "phase-submission", name: "Submission" }, + { id: "phase-review", name: "Review" }, + ]; + + const prisma = { + challengeType: { + findMany: jest.fn().mockResolvedValue([{ id: "type-mm" }]), + }, + challengeTrack: { + findMany: jest.fn().mockResolvedValue([{ id: "track-ds" }]), + }, + phase: { + findMany: jest + .fn() + .mockResolvedValueOnce(phaseRows) + .mockResolvedValueOnce(phaseRows), + }, + challengeTimelineTemplate: { + findMany: jest.fn().mockResolvedValue([ + { + timelineTemplateId: "timeline-mm", + isDefault: true, + timelineTemplate: { + phases: [ + { phaseId: "phase-registration" }, + { phaseId: "phase-submission" }, + { phaseId: "phase-review" }, + ], + }, + }, + ]), + }, + $transaction: async (callback) => callback(tx), + }; + + const submissionStoreRecords = new Map([ + [ + "10010001", + { + legacySubmissionId: "10010001", + memberId: "1", + submitter: "alpha", + systemFileName: buildSubmissionArchiveFileName({ + challengeId: "challenge-1", + legacySubmissionId: "10010001", + }), + virusScan: true, + isFileSubmission: true, + url: null, + }, + ], + ]); + const submissionStore = { + listExistingSubmissionsByLegacyId: async () => new Map(submissionStoreRecords), + createSubmission: jest.fn(), + updateSubmissionMetadata: jest.fn(), + }; + const submissionArchiveStore = { + listExistingSubmissionsByLegacyId: async () => + new Map([ + [ + "10010001", + { + legacySubmissionId: "10010001", + url: submissionStoreRecords.get("10010001").url, + }, + ], + ]), + updateSubmissionUrl: jest.fn().mockImplementation(async ({ legacySubmissionId, url }) => { + submissionStoreRecords.get(legacySubmissionId).url = url; + }), + }; + + const result = await runApplyMode({ + prisma, + options: { + roundIds: ["9892"], + skippedFilePath: path.join(fixtureDir, "apply-rerun-archive-submission-skipped.json"), + importSubmissions: true, + submissionStore, + submissionArchiveStore, + submissionArchiveDir: archiveDir, + dataDir: fixtureDir, + longComponentStateFile: "long_component_state_1.json", + longSubmissionPattern: "^long_submission_\\d+\\.json$", + resourceClient: { + listSubmitterResources: jest.fn().mockResolvedValue([]), + createSubmitterResource: jest.fn().mockResolvedValue({}), + }, + }, + plan: { + records: [ + { + legacyRoundId: "9892", + decision: "create", + reason: "no-matching-v6-challenge-found", + plannedSkipRecords: [ + { + legacyRoundId: "9892", + memberId: "2", + reasonCode: "missing-member", + affectedSurfaces: ["resource", "submission"], + }, + ], + }, + ], + roundDataById: new Map([ + [ + "9892", + { + round: { round_id: "9892", round_type_id: "13", name: "MM 9892" }, + registrationStartMs: Date.parse("2020-01-01T00:00:00.000Z"), + registrationEndMs: Date.parse("2020-01-01T12:00:00.000Z"), + earliestSubmissionOpenMs: Date.parse("2020-01-01T01:00:00.000Z"), + earliestNonExampleSubmitMs: Date.parse("2020-01-01T02:00:00.000Z"), + latestNonExampleSubmitMs: Date.parse("2020-01-02T00:00:00.000Z"), + eligibleRegistrants: new Set(["1", "2"]), + nonExampleSubmissions: 2, + nonExampleSubmitterCoderIds: new Set(["1", "2"]), + finalCandidateCoderIds: new Set(), + }, + ], + ]), + }, + actor: "importer", + normalizedIdentityByCoderId: new Map([ + ["1", { coderId: "1", memberId: 1, memberHandle: "alpha" }], + ["2", { coderId: "2", memberId: 2, memberHandle: "bravo" }], + ]), + }); + + expect(result.records).toEqual([ + expect.objectContaining({ + recordType: "apply-record", + legacyRoundId: "9892", + status: "created", + challengeId: "challenge-1", + submissionArchiveReconciliation: { + submissionsReconciled: 1, + archivesWritten: 1, + urlsUpdated: 1, + urlsAlreadyMatched: 0, + archiveDirectory: archiveDir, + }, + }), + ]); + expect(submissionStore.createSubmission).not.toHaveBeenCalled(); + expect(submissionArchiveStore.updateSubmissionUrl).toHaveBeenCalledTimes(1); + expect(submissionStoreRecords.get("10010001").url).toBe( + buildSubmissionArchiveUrl({ + archiveFileName: buildSubmissionArchiveFileName({ + challengeId: "challenge-1", + legacySubmissionId: "10010001", + }), + }) + ); + }); }); diff --git a/data-migration/test/importHistoricalMarathonMatches.descriptionSourcing.test.js b/data-migration/test/importHistoricalMarathonMatches.descriptionSourcing.test.js new file mode 100644 index 0000000..5738a3c --- /dev/null +++ b/data-migration/test/importHistoricalMarathonMatches.descriptionSourcing.test.js @@ -0,0 +1,54 @@ +const { + convertComponentXmlToMarkdown, + resolveDescriptionCandidateFromCounters, +} = require("../src/scripts/importHistoricalMarathonMatches/descriptionSourcing"); + +describe("importHistoricalMarathonMatches description sourcing", () => { + test("converts structured Topcoder component XML into readable markdown", () => { + const markdown = convertComponentXmlToMarkdown( + "RandomWalkingdisplayTestCaseStringStringsinitintintnodeswalkintint[]seqA random walk in a directed graph starts at some node in the graph.



You should write two methods: init and walk.
The memory limit is 64 MB.The thread limit is 32./ASCII34/1/ASCII34//ASCII34/0/ASCII58/ 1 2 9 \\n1/ASCII58/ 0 4 5 6 7 \\n/ASCII34/Public sample./ASCII34/private/ASCII34//ASCII34/ignored/ASCII34/
" + ); + + expect(markdown).toContain("## Class"); + expect(markdown).toContain("`RandomWalking`"); + expect(markdown).toContain("## Methods"); + expect(markdown).toContain("`String displayTestCase(String s)`"); + expect(markdown).toContain("`int init(int nodes)`"); + expect(markdown).toContain("`int walk(int[] seq)`"); + expect(markdown).toContain("## Statement"); + expect(markdown).toContain("You should write two methods: init and walk."); + expect(markdown).toContain("## Notes"); + expect(markdown).toContain("- The memory limit is 64 MB."); + expect(markdown).toContain("## Examples"); + expect(markdown).toContain("### Example 1"); + expect(markdown).toContain("\"1\""); + expect(markdown).toContain("0: 1 2 9"); + expect(markdown).toContain("Public sample."); + expect(markdown).not.toContain("private"); + expect(markdown).not.toContain("/ASCII34/"); + }); + + test("resolves html problem text with html description format", () => { + expect( + resolveDescriptionCandidateFromCounters({ + descriptionProblemText: "

Legacy description

", + }) + ).toEqual({ + description: "

Legacy description

", + descriptionFormat: "html", + source: "legacy-problem-text", + }); + }); + + test("resolves component markdown with markdown description format", () => { + expect( + resolveDescriptionCandidateFromCounters({ + descriptionComponentTextMarkdown: "## Robot Routing\n\nPublic example only.", + }) + ).toEqual({ + description: "## Robot Routing\n\nPublic example only.", + descriptionFormat: "markdown", + source: "legacy-component-text-markdown", + }); + }); +}); diff --git a/data-migration/test/importHistoricalMarathonMatches.finalScores.test.js b/data-migration/test/importHistoricalMarathonMatches.finalScores.test.js index fac7a79..0ae1c7a 100644 --- a/data-migration/test/importHistoricalMarathonMatches.finalScores.test.js +++ b/data-migration/test/importHistoricalMarathonMatches.finalScores.test.js @@ -5,6 +5,7 @@ const path = require("path"); const { loadLegacyFinalRowsByRoundId, reconcileRoundFinalScores, + createReviewFinalScoreStore, } = require("../src/scripts/importHistoricalMarathonMatches/finalScores"); const { FINALIST_WITHOUT_ATTACHABLE_SUBMISSION_REASON_CODE, @@ -139,6 +140,164 @@ describe("importHistoricalMarathonMatches final score import", () => { } }); + test("prefers long component state points when legacy final score fields disagree and keeps state-only finalists", async () => { + const mismatchFixtureDir = fs.mkdtempSync( + path.join(os.tmpdir(), "mm-final-scores-mismatch-fixture-") + ); + try { + writeJson( + mismatchFixtureDir, + "long_component_state_1.json", + "long_component_state", + [ + { + long_component_state_id: "2720455", + round_id: "10082", + coder_id: "10597114", + points: "867.31", + }, + { + long_component_state_id: "2720629", + round_id: "10082", + coder_id: "274023", + points: "1131.96", + }, + ] + ); + writeJson( + mismatchFixtureDir, + "long_comp_result_1.json", + "long_comp_result", + [ + { + round_id: "10082", + coder_id: "10597114", + system_point_total: "310402.31", + point_total: "103.30", + placed: "1", + }, + ] + ); + + const rowsByRoundId = await loadLegacyFinalRowsByRoundId({ + dataDir: mismatchFixtureDir, + longComponentStateFile: "long_component_state_1.json", + longCompResultPattern: "^long_comp_result_\\d+\\.json$", + roundIds: ["10082"], + }); + + expect(rowsByRoundId.get("10082")).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + legacyRoundId: "10082", + coderId: "10597114", + aggregateScore: 867.31, + scoreSource: "ranking_score", + systemPointTotal: 310402.31, + pointTotal: 103.3, + }), + expect.objectContaining({ + legacyRoundId: "10082", + coderId: "274023", + aggregateScore: 1131.96, + scoreSource: "ranking_score", + legacyPlacement: null, + systemPointTotal: null, + pointTotal: null, + }), + ]) + ); + } finally { + fs.rmSync(mismatchFixtureDir, { recursive: true, force: true }); + } + }); + + test("ignores unattended result-only artifacts when loading final scores", async () => { + const unattendedArtifactFixtureDir = fs.mkdtempSync( + path.join(os.tmpdir(), "mm-final-scores-unattended-artifact-fixture-") + ); + try { + writeJson( + unattendedArtifactFixtureDir, + "long_component_state_1.json", + "long_component_state", + [ + { + long_component_state_id: "2664738", + round_id: "10015", + coder_id: "16064986", + points: "7186.79", + }, + { + long_component_state_id: "2664602", + round_id: "10015", + coder_id: "21874802", + points: "7176.17", + }, + ] + ); + writeJson( + unattendedArtifactFixtureDir, + "long_comp_result_1.json", + "long_comp_result", + [ + { + round_id: "10015", + coder_id: "10597114", + system_point_total: "310402.31", + point_total: null, + attended: "N", + placed: "1", + }, + { + round_id: "10015", + coder_id: "16064986", + system_point_total: "7186.79", + point_total: "7186.79", + attended: "Y", + placed: "1", + }, + { + round_id: "10015", + coder_id: "21874802", + system_point_total: "7176.17", + point_total: "7176.17", + attended: "Y", + placed: "2", + }, + ] + ); + + const rowsByRoundId = await loadLegacyFinalRowsByRoundId({ + dataDir: unattendedArtifactFixtureDir, + longComponentStateFile: "long_component_state_1.json", + longCompResultPattern: "^long_comp_result_\\d+\\.json$", + roundIds: ["10015"], + }); + + expect(rowsByRoundId.get("10015")).toEqual([ + expect.objectContaining({ + legacyRoundId: "10015", + coderId: "16064986", + legacyPlacement: 1, + aggregateScore: 7186.79, + scoreSource: "system_point_total", + rawLegacyPlacement: 1, + }), + expect.objectContaining({ + legacyRoundId: "10015", + coderId: "21874802", + legacyPlacement: 2, + aggregateScore: 7176.17, + scoreSource: "system_point_total", + rawLegacyPlacement: 2, + }), + ]); + } finally { + fs.rmSync(unattendedArtifactFixtureDir, { recursive: true, force: true }); + } + }); + test("clears conflicting duplicate legacy placements while preserving the raw value", async () => { const duplicatePlacementFixtureDir = fs.mkdtempSync( path.join(os.tmpdir(), "mm-final-scores-duplicate-placement-fixture-") @@ -326,6 +485,270 @@ describe("importHistoricalMarathonMatches final score import", () => { ]); }); + test("backfills submission final score summary when final summation already exists", async () => { + const rowsByRoundId = await loadLegacyFinalRowsByRoundId({ + dataDir: fixtureDir, + longComponentStateFile: "long_component_state_1.json", + longCompResultPattern: "^long_comp_result_\\d+\\.json$", + roundIds: ["9892"], + }); + const alphaFinalRow = (rowsByRoundId.get("9892") || []).find( + (row) => row.coderId === "1" + ); + + const summaryUpdates = []; + const finalScoreStore = { + listImportedNonExampleSubmissionsByChallenge: async () => [ + { + id: "sub-1", + memberId: "1", + legacySubmissionId: "10010001", + submittedDate: new Date("2020-01-01T01:00:00.000Z"), + createdAt: new Date("2020-01-01T01:00:00.000Z"), + finalScore: null, + placement: null, + userRank: null, + }, + ], + listExistingFinalSummationsBySubmissionId: async () => + new Map([ + [ + "sub-1", + [{ id: "final-1", submissionId: "sub-1", aggregateScore: 100 }], + ], + ]), + createFinalSummation: async () => { + throw new Error("createFinalSummation should not be called for existing scores."); + }, + updateSubmissionFinalScoreSummary: async (payload) => { + summaryUpdates.push(payload); + }, + }; + + const result = await reconcileRoundFinalScores({ + roundId: "9892", + challengeId: "challenge-1", + finalRowsByRoundId: new Map([["9892", [alphaFinalRow]]]), + normalizedIdentityByCoderId: new Map([ + ["1", { coderId: "1", memberId: 1, memberHandle: "alpha" }], + ]), + finalScoreStore, + }); + + expect(result).toEqual({ + legacyFinalCandidates: 1, + importedFinalScores: 1, + alreadyPresentFinalScores: 1, + createdFinalScores: 0, + missingMemberSkippedFinalScores: 0, + explicitSkippedFinalScores: 0, + runtimeSkipRecords: [], + updatedSubmissionFinalScoreSummaries: 1, + alreadyMatchedSubmissionFinalScoreSummaries: 0, + unsupportedSubmissionFinalScoreSummaries: 0, + }); + expect(summaryUpdates).toEqual([ + { + submissionId: "sub-1", + finalScore: 100, + placement: 1, + userRank: 1, + }, + ]); + }); + + test("updates mismatched existing final scores when targeted rerun update mode is enabled", async () => { + const rowsByRoundId = await loadLegacyFinalRowsByRoundId({ + dataDir: fixtureDir, + longComponentStateFile: "long_component_state_1.json", + longCompResultPattern: "^long_comp_result_\\d+\\.json$", + roundIds: ["9892"], + }); + + const updated = []; + const finalScoreStore = { + listImportedNonExampleSubmissionsByChallenge: async () => [ + { + id: "sub-1", + memberId: "1", + legacySubmissionId: "10010001", + submittedDate: new Date("2020-01-01T01:00:00.000Z"), + createdAt: new Date("2020-01-01T01:00:00.000Z"), + }, + ], + listExistingFinalSummationsBySubmissionId: async () => + new Map([ + [ + "sub-1", + [{ id: "final-1", submissionId: "sub-1", aggregateScore: 999 }], + ], + ]), + createFinalSummation: async () => { + throw new Error("createFinalSummation should not be called"); + }, + updateFinalSummation: async (payload) => { + updated.push(payload); + }, + }; + + const result = await reconcileRoundFinalScores({ + roundId: "9892", + challengeId: "challenge-1", + finalRowsByRoundId: new Map([ + [ + "9892", + [(rowsByRoundId.get("9892") || []).find((row) => row.coderId === "1")], + ], + ]), + normalizedIdentityByCoderId: new Map([ + ["1", { coderId: "1", memberId: 1, memberHandle: "alpha" }], + ]), + finalScoreStore, + updateExistingScores: true, + }); + + expect(result).toEqual({ + legacyFinalCandidates: 1, + importedFinalScores: 1, + alreadyPresentFinalScores: 0, + createdFinalScores: 0, + updatedFinalScores: 1, + missingMemberSkippedFinalScores: 0, + explicitSkippedFinalScores: 0, + runtimeSkipRecords: [], + }); + expect(updated).toEqual([ + expect.objectContaining({ + reviewSummationId: "final-1", + submissionId: "sub-1", + aggregateScore: 100, + legacySubmissionId: "10010001", + isFinal: true, + isExample: false, + }), + ]); + }); + + test("moves final scores to the explicit legacy final submission during targeted rerun", async () => { + const explicitFinalSubmissionFixtureDir = fs.mkdtempSync( + path.join(os.tmpdir(), "mm-final-scores-explicit-submission-fixture-") + ); + try { + writeJson( + explicitFinalSubmissionFixtureDir, + "long_component_state_1.json", + "long_component_state", + [ + { + long_component_state_id: "2664738", + round_id: "10015", + coder_id: "16064986", + points: "7186.79", + submission_number: "5", + }, + ] + ); + writeJson( + explicitFinalSubmissionFixtureDir, + "long_comp_result_1.json", + "long_comp_result", + [ + { + round_id: "10015", + coder_id: "16064986", + system_point_total: "7186.79", + point_total: "7186.79", + attended: "Y", + placed: "1", + }, + ] + ); + + const rowsByRoundId = await loadLegacyFinalRowsByRoundId({ + dataDir: explicitFinalSubmissionFixtureDir, + longComponentStateFile: "long_component_state_1.json", + longCompResultPattern: "^long_comp_result_\\d+\\.json$", + roundIds: ["10015"], + }); + const updated = []; + const created = []; + + const result = await reconcileRoundFinalScores({ + roundId: "10015", + challengeId: "challenge-1", + finalRowsByRoundId: rowsByRoundId, + normalizedIdentityByCoderId: new Map([ + ["16064986", { coderId: "16064986", memberId: 16064986, memberHandle: "ctrucza" }], + ]), + finalScoreStore: { + listImportedNonExampleSubmissionsByChallenge: async () => [ + { + id: "sub-1", + memberId: "16064986", + legacySubmissionId: "26647380001", + submittedDate: new Date("2006-05-17T10:00:00.000Z"), + createdAt: new Date("2026-04-09T05:00:55.000Z"), + }, + { + id: "sub-5", + memberId: "16064986", + legacySubmissionId: "26647380005", + submittedDate: new Date("2006-05-16T10:31:42.790Z"), + createdAt: new Date("2026-04-09T05:00:55.279Z"), + }, + ], + listExistingFinalSummationsBySubmissionId: async () => + new Map([ + [ + "sub-1", + [{ id: "final-wrong", submissionId: "sub-1", aggregateScore: 7186.79 }], + ], + ]), + createFinalSummation: async (payload) => { + created.push(payload); + }, + updateFinalSummation: async (payload) => { + updated.push(payload); + }, + }, + updateExistingScores: true, + }); + + expect(rowsByRoundId.get("10015")).toEqual([ + expect.objectContaining({ + coderId: "16064986", + longComponentStateId: "2664738", + submissionNumber: 5, + legacySubmissionId: "26647380005", + aggregateScore: 7186.79, + }), + ]); + expect(result).toEqual({ + legacyFinalCandidates: 1, + importedFinalScores: 1, + alreadyPresentFinalScores: 0, + createdFinalScores: 0, + updatedFinalScores: 1, + missingMemberSkippedFinalScores: 0, + explicitSkippedFinalScores: 0, + runtimeSkipRecords: [], + }); + expect(created).toEqual([]); + expect(updated).toEqual([ + expect.objectContaining({ + reviewSummationId: "final-wrong", + submissionId: "sub-5", + aggregateScore: 7186.79, + legacySubmissionId: "26647380005", + isFinal: true, + isExample: false, + }), + ]); + } finally { + fs.rmSync(explicitFinalSubmissionFixtureDir, { recursive: true, force: true }); + } + }); + test("records runtime unattachable-finalist skip when no attachable submission exists unexpectedly", async () => { const rowsByRoundId = await loadLegacyFinalRowsByRoundId({ dataDir: fixtureDir, @@ -386,4 +809,48 @@ describe("importHistoricalMarathonMatches final score import", () => { }), ]); }); + + test("writes isProvisional false for final review summations", async () => { + const columnRows = [ + { tableName: "submission", columnName: "id" }, + { tableName: "submission", columnName: "challengeId" }, + { tableName: "submission", columnName: "legacySubmissionId" }, + { tableName: "reviewSummation", columnName: "id" }, + { tableName: "reviewSummation", columnName: "submissionId" }, + { tableName: "reviewSummation", columnName: "aggregateScore" }, + { tableName: "reviewSummation", columnName: "isPassing" }, + { tableName: "reviewSummation", columnName: "isFinal" }, + { tableName: "reviewSummation", columnName: "isExample" }, + { tableName: "reviewSummation", columnName: "isProvisional" }, + ]; + const reviewClient = { + $queryRawUnsafe: jest.fn().mockResolvedValueOnce(columnRows).mockResolvedValue([]), + }; + const store = await createReviewFinalScoreStore({ + reviewClient, + reviewSchema: "reviews", + actor: "importer", + }); + + await store.createFinalSummation({ + submissionId: "sub-1", + aggregateScore: 100, + isPassing: true, + reviewedDate: new Date("2020-01-01T00:00:00.000Z"), + legacySubmissionId: "10010001", + isFinal: true, + isExample: false, + }); + + const insertCall = reviewClient.$queryRawUnsafe.mock.calls.find(([sql]) => + sql.includes("INSERT INTO") + ); + const insertColumns = insertCall[0] + .match(/INSERT INTO [^(]+\(([^)]+)\)/s)[1] + .split(",") + .map((column) => column.trim()); + const insertProvisionalIndex = insertColumns.indexOf('"isProvisional"'); + expect(insertProvisionalIndex).toBeGreaterThan(-1); + expect(insertCall[insertProvisionalIndex + 1]).toBe(false); + }); }); diff --git a/data-migration/test/importHistoricalMarathonMatches.plan.test.js b/data-migration/test/importHistoricalMarathonMatches.plan.test.js index fe3401e..9f8a272 100644 --- a/data-migration/test/importHistoricalMarathonMatches.plan.test.js +++ b/data-migration/test/importHistoricalMarathonMatches.plan.test.js @@ -2,6 +2,12 @@ const fs = require("fs"); const os = require("os"); const path = require("path"); const { spawnSync } = require("child_process"); +const { + buildDryRunPlan, +} = require("../src/scripts/importHistoricalMarathonMatches/planning"); +const { + TARGET_MEMBER_RESOLUTION_UNAVAILABLE_REASON, +} = require("../src/scripts/importHistoricalMarathonMatches/targetMemberResolution"); const scriptPath = path.resolve( __dirname, @@ -152,6 +158,17 @@ describe("importHistoricalMarathonMatches CLI planning behavior", () => { expect(result.stderr).toContain("Invalid round id value \"abc\""); }); + test("targeted rerun mode requires an explicit challenge-id override", () => { + const result = runImporter( + ["--data-dir", fixtureDir, "--apply", "--targeted-rerun", "--round-id", "9892"], + fixtureDir + ); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("--targeted-rerun requires --challenge-id"); + expect(result.stderr).not.toContain("RESOURCES_API_URL must be set"); + }); + test("dry-run emits one deterministic parseable record per selected round including unmatched", () => { const args = [ "--data-dir", @@ -218,6 +235,52 @@ describe("importHistoricalMarathonMatches CLI planning behavior", () => { expect(record.createPathPhasePlan).toBe(null); }); + test("round data tracks finalists from long component state points even when long_comp_result omits them", async () => { + writeJson(fixtureDir, "long_component_state_1.json", "long_component_state", [ + { + long_component_state_id: "lcs-1", + round_id: "9892", + coder_id: "1", + component_id: "5503", + points: "98.1", + }, + { + long_component_state_id: "lcs-2", + round_id: "9892", + coder_id: "2", + component_id: "5504", + points: "91.5", + }, + { long_component_state_id: "lcs-3", round_id: "7000", coder_id: "8", component_id: "7777" }, + ]); + writeJson(fixtureDir, "long_comp_result_1.json", "long_comp_result", [ + { round_id: "9892", coder_id: "1", system_point_total: "98.1", point_total: null, placed: "1" }, + { round_id: "7000", coder_id: "8", system_point_total: "77.0", point_total: null, placed: "1" }, + ]); + + const plan = await buildDryRunPlan( + { + dataDir: fixtureDir, + roundFile: "round_1.json", + roundComponentFile: "round_component_1.json", + componentFile: "component_1.json", + problemFile: "problem_1.json", + longComponentStateFile: "long_component_state_1.json", + roundRegistrationPattern: "^round_registration_\\d+\\.json$", + userPattern: "^user_\\d+\\.json$", + longSubmissionPattern: "^long_submission_\\d+\\.json$", + longCompResultPattern: "^long_comp_result_\\d+\\.json$", + roundIds: ["9892"], + cwd: fixtureDir, + }, + new Map() + ); + + expect(plan.roundDataById.get("9892").finalCandidateCoderIds).toEqual( + new Set(["1", "2"]) + ); + }); + test("dry-run with broken DATABASE_URL still emits unresolved instead of create", () => { const result = runImporter( [ @@ -278,4 +341,57 @@ describe("importHistoricalMarathonMatches CLI planning behavior", () => { expect(record.reason).toBe("authoritative-existing-v6-discovery-unavailable"); expect(record.matchedChallengeId).toBe(null); }); + + test("matched existing challenges stay traceable when only member resolution is unavailable", async () => { + const plan = await buildDryRunPlan( + { + dataDir: fixtureDir, + cwd: fixtureDir, + roundIds: ["9892"], + roundFile: "round_1.json", + roundComponentFile: "round_component_1.json", + componentFile: "component_1.json", + problemFile: "problem_1.json", + longComponentStateFile: "long_component_state_1.json", + roundRegistrationPattern: "^round_registration_\\d+\\.json$", + userPattern: "^user_\\d+\\.json$", + longSubmissionPattern: "^long_submission_\\d+\\.json$", + longCompResultPattern: "^long_comp_result_\\d+\\.json$", + }, + new Map([ + [ + "9892", + { + legacyRoundId: "9892", + matchStatus: "safe", + reason: "existing-v6-challenge-found", + challengeId: "challenge-1", + existing: { + phases: 3, + resources: 0, + submissions: 0, + finalScores: 0, + provisionalScores: 0, + }, + }, + ], + ]), + { + authoritativeDiscovery: { available: true }, + canonicalTimelineTemplate: { + resolved: true, + timelineTemplateId: "timeline-1", + }, + memberResolution: { + available: false, + reason: TARGET_MEMBER_RESOLUTION_UNAVAILABLE_REASON, + }, + } + ); + + const [record] = plan.records; + expect(record.decision).toBe("unresolved"); + expect(record.reason).toBe(TARGET_MEMBER_RESOLUTION_UNAVAILABLE_REASON); + expect(record.matchedChallengeId).toBe("challenge-1"); + }); }); diff --git a/data-migration/test/importHistoricalMarathonMatches.planningPrerequisites.test.js b/data-migration/test/importHistoricalMarathonMatches.planningPrerequisites.test.js index 55a9e96..9bbde2e 100644 --- a/data-migration/test/importHistoricalMarathonMatches.planningPrerequisites.test.js +++ b/data-migration/test/importHistoricalMarathonMatches.planningPrerequisites.test.js @@ -24,9 +24,16 @@ const buildFixtureDataDirectory = () => { { round_id: "9892", component_id: "5503" }, ]); writeJson(baseDir, "component_1.json", "component", [ - { component_id: "5503", problem_id: "9001" }, + { + component_id: "5503", + problem_id: "9001", + component_text: + "

Robot Routing

Public summary.

", + }, + ]); + writeJson(baseDir, "problem_1.json", "problem", [ + { problem_id: "9001", problem_text: "

Legacy problem text

" }, ]); - writeJson(baseDir, "problem_1.json", "problem", [{ problem_id: "9001" }]); writeJson(baseDir, "long_component_state_1.json", "long_component_state", [ { long_component_state_id: "lcs-1", round_id: "9892", coder_id: "1", component_id: "5503" }, ]); @@ -122,4 +129,103 @@ describe("importHistoricalMarathonMatches planning prerequisites", () => { timelineTemplateId: "timeline-mm", }); }); + + test("captures mapped raw legacy problem HTML for apply-mode description sourcing", async () => { + const plan = await buildDryRunPlan( + buildOptions(fixtureDir), + new Map(), + { + authoritativeDiscovery: { available: true }, + canonicalTimelineTemplate: { + resolved: true, + timelineTemplateId: "timeline-mm", + }, + memberResolution: { + available: true, + resolvedMemberIds: new Set(["1"]), + }, + } + ); + + const counters = plan.roundDataById.get("9892"); + expect(counters.descriptionProblemId).toBe("9001"); + expect(counters.descriptionProblemText).toBe( + "

Legacy problem text

" + ); + }); + + test("captures component_text markdown fallback when problem text is unusable", async () => { + writeJson(fixtureDir, "problem_1.json", "problem", [ + { problem_id: "9001", problem_text: " " }, + ]); + writeJson(fixtureDir, "component_1.json", "component", [ + { + component_id: "5503", + problem_id: "9001", + component_text: + "

Robot Routing

Public summary.

secret
", + }, + ]); + + const plan = await buildDryRunPlan( + buildOptions(fixtureDir), + new Map(), + { + authoritativeDiscovery: { available: true }, + canonicalTimelineTemplate: { + resolved: true, + timelineTemplateId: "timeline-mm", + }, + memberResolution: { + available: true, + resolvedMemberIds: new Set(["1"]), + }, + } + ); + + const counters = plan.roundDataById.get("9892"); + expect(counters.descriptionProblemId).toBe(null); + expect(counters.descriptionProblemText).toBe(null); + expect(counters.descriptionComponentId).toBe("5503"); + expect(counters.descriptionComponentTextMarkdown).toContain("Robot Routing"); + expect(counters.descriptionComponentTextMarkdown).toContain("Public summary."); + expect(counters.descriptionComponentTextMarkdown).not.toContain(""); + expect(counters.descriptionComponentTextMarkdown).not.toContain("secret"); + }); + + test("prefers component_text markdown when problem text is plain text without renderable HTML", async () => { + writeJson(fixtureDir, "problem_1.json", "problem", [ + { problem_id: "9001", problem_text: "Legacy plain text description" }, + ]); + writeJson(fixtureDir, "component_1.json", "component", [ + { + component_id: "5503", + problem_id: "9001", + component_text: + "

Robot Routing

Public summary.

", + }, + ]); + + const plan = await buildDryRunPlan( + buildOptions(fixtureDir), + new Map(), + { + authoritativeDiscovery: { available: true }, + canonicalTimelineTemplate: { + resolved: true, + timelineTemplateId: "timeline-mm", + }, + memberResolution: { + available: true, + resolvedMemberIds: new Set(["1"]), + }, + } + ); + + const counters = plan.roundDataById.get("9892"); + expect(counters.descriptionProblemId).toBe(null); + expect(counters.descriptionProblemText).toBe(null); + expect(counters.descriptionComponentId).toBe("5503"); + expect(counters.descriptionComponentTextMarkdown).toContain("Robot Routing"); + }); }); diff --git a/data-migration/test/importHistoricalMarathonMatches.provisionalScores.test.js b/data-migration/test/importHistoricalMarathonMatches.provisionalScores.test.js index 424f084..bf1e28e 100644 --- a/data-migration/test/importHistoricalMarathonMatches.provisionalScores.test.js +++ b/data-migration/test/importHistoricalMarathonMatches.provisionalScores.test.js @@ -5,6 +5,7 @@ const path = require("path"); const { loadLegacyProvisionalRowsByRoundId, reconcileRoundProvisionalScores, + createReviewProvisionalScoreStore, } = require("../src/scripts/importHistoricalMarathonMatches/provisionalScores"); const writeJson = (baseDir, fileName, rootKey, rows) => { @@ -112,6 +113,134 @@ describe("importHistoricalMarathonMatches provisional score import", () => { ]); }); + test("keeps non-example provisional rows when example and contest submissions reuse submission numbers", async () => { + const duplicateNumberFixtureDir = fs.mkdtempSync( + path.join(os.tmpdir(), "mm-provisional-duplicate-number-fixture-") + ); + try { + writeJson( + duplicateNumberFixtureDir, + "long_component_state_1.json", + "long_component_state", + [ + { + long_component_state_id: "2720455", + round_id: "10082", + coder_id: "10597114", + component_id: "5910", + }, + ] + ); + writeJson( + duplicateNumberFixtureDir, + "long_submission_1.json", + "long_submission", + [ + { + long_component_state_id: "2720455", + submission_number: "1", + example: "1", + submit_time: "1149722902515", + submission_points: "0.00", + }, + { + long_component_state_id: "2720455", + submission_number: "1", + example: "0", + submit_time: "1149724742959", + submission_points: "78.05", + }, + { + long_component_state_id: "2720455", + submission_number: "2", + example: "0", + submit_time: "1149854727339", + submission_points: "78.53", + }, + { + long_component_state_id: "2720455", + submission_number: "3", + example: "0", + submit_time: "1150020945504", + submission_points: "83.86", + }, + { + long_component_state_id: "2720455", + submission_number: "2", + example: "1", + submit_time: "1150021804459", + submission_points: "0.00", + }, + { + long_component_state_id: "2720455", + submission_number: "3", + example: "1", + submit_time: "1150032979378", + submission_points: "0.00", + }, + { + long_component_state_id: "2720455", + submission_number: "4", + example: "0", + submit_time: "1150037434143", + submission_points: "91.07", + }, + { + long_component_state_id: "2720455", + submission_number: "4", + example: "1", + submit_time: "1150293594688", + submission_points: "0.00", + }, + { + long_component_state_id: "2720455", + submission_number: "5", + example: "0", + submit_time: "1150294561706", + submission_points: "103.30", + }, + ] + ); + + const rowsByRoundId = await loadLegacyProvisionalRowsByRoundId({ + dataDir: duplicateNumberFixtureDir, + longComponentStateFile: "long_component_state_1.json", + longSubmissionPattern: "^long_submission_\\d+\\.json$", + roundIds: ["10082"], + }); + + expect(rowsByRoundId.get("10082")).toEqual([ + expect.objectContaining({ + coderId: "10597114", + legacySubmissionId: "27204550001", + aggregateScore: 78.05, + }), + expect.objectContaining({ + coderId: "10597114", + legacySubmissionId: "27204550002", + aggregateScore: 78.53, + }), + expect.objectContaining({ + coderId: "10597114", + legacySubmissionId: "27204550003", + aggregateScore: 83.86, + }), + expect.objectContaining({ + coderId: "10597114", + legacySubmissionId: "27204550004", + aggregateScore: 91.07, + }), + expect.objectContaining({ + coderId: "10597114", + legacySubmissionId: "27204550005", + aggregateScore: 103.3, + }), + ]); + } finally { + fs.rmSync(duplicateNumberFixtureDir, { recursive: true, force: true }); + } + }); + test("imports one provisional per imported submission, skips missing members, and is rerun-idempotent", async () => { const rowsByRoundId = await loadLegacyProvisionalRowsByRoundId({ dataDir: fixtureDir, @@ -194,6 +323,7 @@ describe("importHistoricalMarathonMatches provisional score import", () => { importedProvisionalScores: 2, alreadyPresentProvisionalScores: 1, createdProvisionalScores: 1, + malformedSkippedProvisionalScores: 0, missingMemberSkippedProvisionalScores: 1, importedDistinctSubmitters: 1, missingMemberDistinctSubmitters: 1, @@ -240,6 +370,7 @@ describe("importHistoricalMarathonMatches provisional score import", () => { importedProvisionalScores: 2, alreadyPresentProvisionalScores: 2, createdProvisionalScores: 0, + malformedSkippedProvisionalScores: 0, missingMemberSkippedProvisionalScores: 1, importedDistinctSubmitters: 1, missingMemberDistinctSubmitters: 1, @@ -261,6 +392,375 @@ describe("importHistoricalMarathonMatches provisional score import", () => { }); }); + test("updates mismatched existing provisional scores when targeted rerun update mode is enabled", async () => { + const rowsByRoundId = await loadLegacyProvisionalRowsByRoundId({ + dataDir: fixtureDir, + longComponentStateFile: "long_component_state_1.json", + longSubmissionPattern: "^long_submission_\\d+\\.json$", + roundIds: ["9892"], + }); + + const updated = []; + const provisionalScoreStore = { + listImportedNonExampleSubmissionsByLegacySubmissionId: async () => + new Map([ + [ + "10010001", + { + id: "sub-10010001", + memberId: "1", + legacySubmissionId: "10010001", + submittedDate: new Date("2020-01-01T01:00:00.000Z"), + createdAt: new Date("2020-01-01T01:00:00.000Z"), + }, + ], + ]), + listExistingProvisionalSummationsBySubmissionId: async () => + new Map([ + [ + "sub-10010001", + [{ id: "prov-1", submissionId: "sub-10010001", aggregateScore: 1 }], + ], + ]), + createProvisionalSummation: async () => { + throw new Error("createProvisionalSummation should not be called"); + }, + updateProvisionalSummation: async (payload) => { + updated.push(payload); + }, + }; + + const result = await reconcileRoundProvisionalScores({ + roundId: "9892", + challengeId: "challenge-1", + provisionalRowsByRoundId: new Map([ + [ + "9892", + [(rowsByRoundId.get("9892") || []).find((row) => row.legacySubmissionId === "10010001")], + ], + ]), + normalizedIdentityByCoderId: new Map([ + ["1", { coderId: "1", memberId: 1, memberHandle: "alpha" }], + ]), + provisionalScoreStore, + updateExistingScores: true, + }); + + expect(result).toEqual({ + legacyNonExampleProvisionalScores: 1, + legacyExampleOnlyFinalistProvisionalScores: 0, + importedProvisionalScores: 1, + alreadyPresentProvisionalScores: 0, + createdProvisionalScores: 0, + updatedProvisionalScores: 1, + demotedFinalScores: 0, + clearedSubmissionFinalScoreSummaries: 0, + malformedSkippedProvisionalScores: 0, + missingMemberSkippedProvisionalScores: 0, + importedDistinctSubmitters: 1, + missingMemberDistinctSubmitters: 0, + importedProvisionalCountsByMemberId: { + 1: 1, + }, + skippedProvisionalRecords: [], + }); + expect(updated).toEqual([ + expect.objectContaining({ + reviewSummationId: "prov-1", + submissionId: "sub-10010001", + aggregateScore: 9.5, + legacySubmissionId: "10010001", + isFinal: false, + isExample: false, + }), + ]); + }); + + test("clears stale submission final score summaries for non-final submissions during targeted rerun", async () => { + const rowsByRoundId = await loadLegacyProvisionalRowsByRoundId({ + dataDir: fixtureDir, + longComponentStateFile: "long_component_state_1.json", + longSubmissionPattern: "^long_submission_\\d+\\.json$", + roundIds: ["9892"], + }); + + const cleared = []; + const provisionalScoreStore = { + listImportedNonExampleSubmissionsByLegacySubmissionId: async () => + new Map([ + [ + "10010001", + { + id: "sub-10010001", + memberId: "1", + legacySubmissionId: "10010001", + submittedDate: new Date("2020-01-01T01:00:00.000Z"), + createdAt: new Date("2020-01-01T01:00:00.000Z"), + finalScore: 9.5, + placement: 1, + userRank: 1, + }, + ], + ]), + listExistingProvisionalSummationsBySubmissionId: async () => + new Map([ + [ + "sub-10010001", + [{ id: "prov-1", submissionId: "sub-10010001", aggregateScore: 9.5 }], + ], + ]), + listExistingFinalSummationsBySubmissionId: async () => new Map(), + createProvisionalSummation: async () => { + throw new Error("createProvisionalSummation should not be called"); + }, + updateProvisionalSummation: async () => { + throw new Error("updateProvisionalSummation should not be called"); + }, + clearSubmissionFinalScoreSummary: async (payload) => { + cleared.push(payload); + return true; + }, + }; + + const result = await reconcileRoundProvisionalScores({ + roundId: "9892", + challengeId: "challenge-1", + provisionalRowsByRoundId: new Map([ + [ + "9892", + [(rowsByRoundId.get("9892") || []).find((row) => row.legacySubmissionId === "10010001")], + ], + ]), + normalizedIdentityByCoderId: new Map([ + ["1", { coderId: "1", memberId: 1, memberHandle: "alpha" }], + ]), + provisionalScoreStore, + updateExistingScores: true, + finalLegacySubmissionIdsByRoundId: new Map([["9892", ["10010003"]]]), + }); + + expect(result).toEqual({ + legacyNonExampleProvisionalScores: 1, + legacyExampleOnlyFinalistProvisionalScores: 0, + importedProvisionalScores: 1, + alreadyPresentProvisionalScores: 0, + createdProvisionalScores: 0, + updatedProvisionalScores: 1, + demotedFinalScores: 0, + clearedSubmissionFinalScoreSummaries: 1, + malformedSkippedProvisionalScores: 0, + missingMemberSkippedProvisionalScores: 0, + importedDistinctSubmitters: 1, + missingMemberDistinctSubmitters: 0, + importedProvisionalCountsByMemberId: { + 1: 1, + }, + skippedProvisionalRecords: [], + }); + expect(cleared).toEqual([{ submissionId: "sub-10010001" }]); + }); + + test("demotes misclassified final summations on non-final submissions during targeted rerun", async () => { + const rowsByRoundId = await loadLegacyProvisionalRowsByRoundId({ + dataDir: fixtureDir, + longComponentStateFile: "long_component_state_1.json", + longSubmissionPattern: "^long_submission_\\d+\\.json$", + roundIds: ["9892"], + }); + rowsByRoundId.set( + "9892", + (rowsByRoundId.get("9892") || []).filter((row) => row.coderId === "1") + ); + + const created = []; + const updated = []; + const cleared = []; + const provisionalScoreStore = { + listImportedNonExampleSubmissionsByLegacySubmissionId: async () => + new Map([ + [ + "10010001", + { + id: "sub-10010001", + memberId: "1", + legacySubmissionId: "10010001", + submittedDate: new Date("2020-01-01T01:00:00.000Z"), + createdAt: new Date("2020-01-01T01:00:00.000Z"), + finalScore: 999, + placement: 1, + userRank: 1, + }, + ], + [ + "10010003", + { + id: "sub-10010003", + memberId: "1", + legacySubmissionId: "10010003", + submittedDate: new Date("2020-01-01T02:00:00.000Z"), + createdAt: new Date("2020-01-01T02:00:00.000Z"), + }, + ], + ]), + listExistingProvisionalSummationsBySubmissionId: async () => + new Map([ + [ + "sub-10010001", + [{ id: "prov-10010001", submissionId: "sub-10010001", aggregateScore: 9.5 }], + ], + [ + "sub-10010003", + [{ id: "prov-10010003", submissionId: "sub-10010003", aggregateScore: 8.25 }], + ], + ]), + listExistingFinalSummationsBySubmissionId: async () => + new Map([ + [ + "sub-10010001", + [{ id: "final-misclassified", submissionId: "sub-10010001", aggregateScore: 999 }], + ], + [ + "sub-10010003", + [{ id: "final-correct", submissionId: "sub-10010003", aggregateScore: 8.25 }], + ], + ]), + createProvisionalSummation: async (payload) => { + created.push(payload); + }, + updateProvisionalSummation: async (payload) => { + updated.push(payload); + }, + clearSubmissionFinalScoreSummary: async (payload) => { + cleared.push(payload); + return true; + }, + }; + + const result = await reconcileRoundProvisionalScores({ + roundId: "9892", + challengeId: "challenge-1", + provisionalRowsByRoundId: rowsByRoundId, + normalizedIdentityByCoderId: new Map([ + ["1", { coderId: "1", memberId: 1, memberHandle: "alpha" }], + ]), + provisionalScoreStore, + updateExistingScores: true, + finalLegacySubmissionIdsByRoundId: new Map([ + ["9892", [{ legacySubmissionId: "10010003" }]], + ]), + }); + + expect(result).toEqual({ + legacyNonExampleProvisionalScores: 2, + legacyExampleOnlyFinalistProvisionalScores: 0, + importedProvisionalScores: 2, + alreadyPresentProvisionalScores: 1, + createdProvisionalScores: 0, + updatedProvisionalScores: 1, + demotedFinalScores: 1, + clearedSubmissionFinalScoreSummaries: 1, + malformedSkippedProvisionalScores: 0, + missingMemberSkippedProvisionalScores: 0, + importedDistinctSubmitters: 1, + missingMemberDistinctSubmitters: 0, + importedProvisionalCountsByMemberId: { + 1: 2, + }, + skippedProvisionalRecords: [], + }); + expect(updated).toEqual([ + expect.objectContaining({ + reviewSummationId: "final-misclassified", + submissionId: "sub-10010001", + aggregateScore: 9.5, + legacySubmissionId: "10010001", + isFinal: false, + isExample: false, + }), + ]); + expect(cleared).toEqual([{ submissionId: "sub-10010001" }]); + expect(created).toEqual([]); + }); + + test("skips malformed provisional rows with missing numeric submission_points and continues importing valid rows", async () => { + const rowsByRoundId = await loadLegacyProvisionalRowsByRoundId({ + dataDir: fixtureDir, + longComponentStateFile: "long_component_state_1.json", + longSubmissionPattern: "^long_submission_\\d+\\.json$", + roundIds: ["9892"], + }); + + const malformedRows = rowsByRoundId.get("9892").map((row) => + row.legacySubmissionId === "10010001" + ? { ...row, aggregateScore: null } + : row + ); + rowsByRoundId.set("9892", malformedRows); + + const created = []; + const provisionalScoreStore = { + listImportedNonExampleSubmissionsByLegacySubmissionId: async () => + new Map([ + ["10010001", { id: "sub-10010001", memberId: "1", legacySubmissionId: "10010001" }], + ["10010003", { id: "sub-10010003", memberId: "1", legacySubmissionId: "10010003" }], + ["10020001", { id: "sub-10020001", memberId: "2", legacySubmissionId: "10020001" }], + ]), + listExistingProvisionalSummationsBySubmissionId: async () => new Map(), + createProvisionalSummation: async (payload) => { + created.push(payload); + }, + }; + + const result = await reconcileRoundProvisionalScores({ + roundId: "9892", + challengeId: "challenge-1", + provisionalRowsByRoundId: rowsByRoundId, + normalizedIdentityByCoderId: new Map([ + ["1", { coderId: "1", memberId: 1, memberHandle: "alpha" }], + ["2", { coderId: "2", memberId: 2, memberHandle: "bravo" }], + ]), + provisionalScoreStore, + }); + + expect(result).toEqual({ + legacyNonExampleProvisionalScores: 3, + legacyExampleOnlyFinalistProvisionalScores: 0, + importedProvisionalScores: 2, + alreadyPresentProvisionalScores: 0, + createdProvisionalScores: 2, + malformedSkippedProvisionalScores: 1, + missingMemberSkippedProvisionalScores: 0, + importedDistinctSubmitters: 2, + missingMemberDistinctSubmitters: 0, + importedProvisionalCountsByMemberId: { + 1: 1, + 2: 1, + }, + skippedProvisionalRecords: [ + expect.objectContaining({ + legacyRoundId: "9892", + memberId: "1", + reasonCode: "malformed-provisional-score", + affectedSurfaces: ["provisional-score"], + legacySubmissionId: "10010001", + counts: { + provisionalScore: 1, + }, + }), + ], + }); + expect(created).toEqual([ + expect.objectContaining({ + submissionId: "sub-10010003", + aggregateScore: 8.25, + }), + expect.objectContaining({ + submissionId: "sub-10020001", + aggregateScore: 7, + }), + ]); + }); + test("loads the latest example-only finalist provisional row when requested", async () => { const rowsByRoundId = await loadLegacyProvisionalRowsByRoundId({ dataDir: fixtureDir, @@ -300,4 +800,70 @@ describe("importHistoricalMarathonMatches provisional score import", () => { }), ]); }); + + test("writes isProvisional for non-final non-example review summations", async () => { + const columnRows = [ + { tableName: "submission", columnName: "id" }, + { tableName: "submission", columnName: "challengeId" }, + { tableName: "submission", columnName: "legacySubmissionId" }, + { tableName: "reviewSummation", columnName: "id" }, + { tableName: "reviewSummation", columnName: "submissionId" }, + { tableName: "reviewSummation", columnName: "aggregateScore" }, + { tableName: "reviewSummation", columnName: "isPassing" }, + { tableName: "reviewSummation", columnName: "isFinal" }, + { tableName: "reviewSummation", columnName: "isExample" }, + { tableName: "reviewSummation", columnName: "isProvisional" }, + ]; + const reviewClient = { + $queryRawUnsafe: jest.fn().mockResolvedValueOnce(columnRows).mockResolvedValue([]), + }; + const store = await createReviewProvisionalScoreStore({ + reviewClient, + reviewSchema: "reviews", + actor: "importer", + }); + + await store.createProvisionalSummation({ + submissionId: "sub-1", + aggregateScore: 9.5, + isPassing: true, + reviewedDate: new Date("2020-01-01T00:00:00.000Z"), + legacySubmissionId: "10010001", + isFinal: false, + isExample: false, + }); + await store.updateProvisionalSummation({ + reviewSummationId: "summary-1", + aggregateScore: 8.25, + isPassing: true, + reviewedDate: new Date("2020-01-02T00:00:00.000Z"), + legacySubmissionId: "10010003", + isFinal: false, + isExample: false, + }); + + const insertCall = reviewClient.$queryRawUnsafe.mock.calls.find(([sql]) => + sql.includes("INSERT INTO") + ); + const insertColumns = insertCall[0] + .match(/INSERT INTO [^(]+\(([^)]+)\)/s)[1] + .split(",") + .map((column) => column.trim()); + const insertProvisionalIndex = insertColumns.indexOf('"isProvisional"'); + expect(insertProvisionalIndex).toBeGreaterThan(-1); + expect(insertCall[insertProvisionalIndex + 1]).toBe(true); + + const updateCall = reviewClient.$queryRawUnsafe.mock.calls.find(([sql]) => + sql.includes('UPDATE "reviews"."reviewSummation"') + ); + const updateAssignments = updateCall[0] + .match(/SET ([\s\S]+?)\s+WHERE/)[1] + .split(",") + .map((assignment) => assignment.trim()); + const updateProvisionalIndex = updateAssignments.findIndex((assignment) => + assignment.startsWith('"isProvisional"') + ); + expect(updateProvisionalIndex).toBeGreaterThan(-1); + expect(updateCall[updateProvisionalIndex + 1]).toBe(true); + }); }); diff --git a/data-migration/test/importHistoricalMarathonMatches.submissionHistory.test.js b/data-migration/test/importHistoricalMarathonMatches.submissionHistory.test.js index 22b6882..7366b0f 100644 --- a/data-migration/test/importHistoricalMarathonMatches.submissionHistory.test.js +++ b/data-migration/test/importHistoricalMarathonMatches.submissionHistory.test.js @@ -3,9 +3,15 @@ const os = require("os"); const path = require("path"); const { + ACTIVE_SUBMISSION_STATUS, + CONTEST_SUBMISSION_TYPE, + createReviewSubmissionStore, loadNonExampleLegacySubmissionRowsByRoundId, reconcileRoundSubmissionHistory, } = require("../src/scripts/importHistoricalMarathonMatches/submissionHistory"); +const { + buildSubmissionArchiveFileName, +} = require("../src/scripts/importHistoricalMarathonMatches/submissionArchives"); const writeJson = (baseDir, fileName, rootKey, rows) => { fs.writeFileSync( @@ -255,4 +261,119 @@ describe("importHistoricalMarathonMatches submission history", () => { }), ]); }); + + test("review submission inserts include file submission metadata when the schema exposes those columns", async () => { + const reviewClient = { + $queryRawUnsafe: jest + .fn() + .mockResolvedValueOnce([ + { columnName: "id", dataType: "character varying", udtName: "varchar" }, + { columnName: "challengeId", dataType: "character varying", udtName: "varchar" }, + { columnName: "legacySubmissionId", dataType: "character varying", udtName: "varchar" }, + { columnName: "memberId", dataType: "character varying", udtName: "varchar" }, + { columnName: "submitter", dataType: "character varying", udtName: "varchar" }, + { columnName: "submittedDate", dataType: "timestamp without time zone", udtName: "timestamp" }, + { columnName: "systemFileName", dataType: "character varying", udtName: "varchar" }, + { columnName: "virusScan", dataType: "boolean", udtName: "bool" }, + { columnName: "isFileSubmission", dataType: "boolean", udtName: "bool" }, + { columnName: "isExample", dataType: "boolean", udtName: "bool" }, + { columnName: "createdBy", dataType: "character varying", udtName: "varchar" }, + { columnName: "updatedBy", dataType: "character varying", udtName: "varchar" }, + { columnName: "type", dataType: "USER-DEFINED", udtName: "SubmissionType" }, + { columnName: "status", dataType: "USER-DEFINED", udtName: "SubmissionStatus" }, + ]) + .mockResolvedValueOnce([]), + }; + const submissionStore = await createReviewSubmissionStore({ + reviewClient, + actor: "importer", + }); + const submittedDate = new Date("2020-01-01T00:00:00.000Z"); + const challengeId = "challenge-1"; + const legacySubmissionId = "10010001"; + + await submissionStore.createSubmission({ + challengeId, + legacySubmissionId, + memberId: 1, + memberHandle: "alpha", + submittedDate, + }); + + const [insertSql, ...insertValues] = reviewClient.$queryRawUnsafe.mock.calls[1]; + expect(insertSql).toContain('INSERT INTO "reviews"."submission"'); + expect(insertSql).toContain('"systemFileName"'); + expect(insertSql).toContain('"virusScan"'); + expect(insertSql).toContain('"isFileSubmission"'); + expect(insertValues).toEqual([ + expect.any(String), + challengeId, + legacySubmissionId, + "1", + "alpha", + submittedDate, + buildSubmissionArchiveFileName({ challengeId, legacySubmissionId }), + true, + true, + false, + "importer", + "importer", + CONTEST_SUBMISSION_TYPE, + ACTIVE_SUBMISSION_STATUS, + ]); + }); + + test("review submission updates include file submission metadata when an existing row is missing it", async () => { + const reviewClient = { + $queryRawUnsafe: jest + .fn() + .mockResolvedValueOnce([ + { columnName: "id", dataType: "character varying", udtName: "varchar" }, + { columnName: "challengeId", dataType: "character varying", udtName: "varchar" }, + { columnName: "legacySubmissionId", dataType: "character varying", udtName: "varchar" }, + { columnName: "memberId", dataType: "character varying", udtName: "varchar" }, + { columnName: "submitter", dataType: "character varying", udtName: "varchar" }, + { columnName: "submittedDate", dataType: "timestamp without time zone", udtName: "timestamp" }, + { columnName: "systemFileName", dataType: "character varying", udtName: "varchar" }, + { columnName: "virusScan", dataType: "boolean", udtName: "bool" }, + { columnName: "isFileSubmission", dataType: "boolean", udtName: "bool" }, + { columnName: "updatedBy", dataType: "character varying", udtName: "varchar" }, + ]) + .mockResolvedValueOnce([]), + }; + const submissionStore = await createReviewSubmissionStore({ + reviewClient, + actor: "importer", + }); + const challengeId = "challenge-1"; + const legacySubmissionId = "10010001"; + + const updated = await submissionStore.updateSubmissionMetadata({ + challengeId, + legacySubmissionId, + existingSubmission: { + legacySubmissionId, + memberId: "1", + systemFileName: null, + virusScan: false, + isFileSubmission: false, + }, + }); + + expect(updated).toBe(true); + const [updateSql, ...updateValues] = reviewClient.$queryRawUnsafe.mock.calls[1]; + expect(updateSql).toContain('UPDATE "reviews"."submission"'); + expect(updateSql).toContain('"systemFileName" = $1'); + expect(updateSql).toContain('"virusScan" = $2'); + expect(updateSql).toContain('"isFileSubmission" = $3'); + expect(updateSql).toContain('"updatedBy" = $4'); + expect(updateValues).toEqual([ + buildSubmissionArchiveFileName({ challengeId, legacySubmissionId }), + true, + true, + "importer", + challengeId, + legacySubmissionId, + ]); + }); }); diff --git a/src/services/ChallengeService.js b/src/services/ChallengeService.js index 1bd1ece..079ee88 100644 --- a/src/services/ChallengeService.js +++ b/src/services/ChallengeService.js @@ -2505,6 +2505,30 @@ function isBillingAccountExpired(active, endDate) { return Date.now() >= endDateTimestamp; } +/** + * Determines whether challenge activation should skip expiry/funds validation + * for a billing account. + * + * Missing, inactive, and not-found checks still apply. The bypass is intended + * for specific accounts that must remain launchable despite expired dates or + * depleted remaining budget. + * + * @param {string|number|null|undefined} billingAccountId Billing-account identifier. + * @returns {boolean} `true` when expiry/funds validation should be skipped. + */ +function shouldIgnoreChallengeActivationBillingValidation(billingAccountId) { + const normalizedBillingAccountId = normalizeOptionalString(billingAccountId); + + if (!normalizedBillingAccountId) { + return false; + } + + return _.includes( + _.map(config.IGNORED_CHALLENGE_ACTIVATION_BILLING_ACCOUNT_IDS, normalizeOptionalString), + normalizedBillingAccountId, + ); +} + /** * Validates the project billing account before activating a challenge. * @@ -2514,7 +2538,8 @@ function isBillingAccountExpired(active, endDate) { * @param {object} params.challenge Existing challenge model. * @param {string|undefined|null} params.endDate Billing-account end date returned by Projects API. * @returns {Promise} Resolves when the billing account can be used for launch. - * @throws {errors.BadRequestError} When the billing account is missing, inactive, expired, or depleted. + * @throws {errors.BadRequestError} When the billing account is missing, inactive, or not found, or + * for non-ignored billing accounts, expired or depleted. */ async function validateChallengeActivationBillingAccount({ active, @@ -2527,6 +2552,9 @@ async function validateChallengeActivationBillingAccount({ } const normalizedBillingAccountId = normalizeOptionalString(billingAccountId); + const shouldIgnoreExpiryAndFundsValidation = shouldIgnoreChallengeActivationBillingValidation( + normalizedBillingAccountId, + ); if (!normalizedBillingAccountId) { throw new errors.BadRequestError( @@ -2543,7 +2571,10 @@ async function validateChallengeActivationBillingAccount({ ); } - if (isBillingAccountExpired(resolvedProjectActive, resolvedProjectEndDate)) { + if ( + !shouldIgnoreExpiryAndFundsValidation && + isBillingAccountExpired(resolvedProjectActive, resolvedProjectEndDate) + ) { throw new errors.BadRequestError( "Cannot activate challenge because the project billing account is expired.", ); @@ -2567,7 +2598,10 @@ async function validateChallengeActivationBillingAccount({ ); } - if (isBillingAccountExpired(resolvedActive, resolvedEndDate)) { + if ( + !shouldIgnoreExpiryAndFundsValidation && + isBillingAccountExpired(resolvedActive, resolvedEndDate) + ) { throw new errors.BadRequestError( "Cannot activate challenge because the project billing account is expired.", ); @@ -2575,7 +2609,7 @@ async function validateChallengeActivationBillingAccount({ const remainingBudget = normalizeOptionalNumber(billingAccountDetails.totalBudgetRemaining); - if (!_.isNil(remainingBudget) && remainingBudget <= 0) { + if (!shouldIgnoreExpiryAndFundsValidation && !_.isNil(remainingBudget) && remainingBudget <= 0) { throw new errors.BadRequestError( "Cannot activate challenge because the project billing account has insufficient remaining funds.", ); diff --git a/test/unit/ChallengeService.test.js b/test/unit/ChallengeService.test.js index adc0d74..6908fbc 100644 --- a/test/unit/ChallengeService.test.js +++ b/test/unit/ChallengeService.test.js @@ -2073,6 +2073,42 @@ describe("challenge service unit tests", () => { } }); + it("update challenge - allow activating with an ignored project billing account that is expired and out of funds", async () => { + const activationChallenge = await createProjectActivationChallenge(ChallengeStatusEnum.DRAFT); + const originalGetProjectBillingInformation = projectHelper.getProjectBillingInformation; + const originalGetBillingAccountDetails = projectHelper.getBillingAccountDetails; + + projectHelper.getProjectBillingInformation = async () => ({ + active: true, + billingAccountId: "80000062", + endDate: "2000-01-01T00:00:00.000Z", + markup: 0.25, + }); + projectHelper.getBillingAccountDetails = async () => ({ + active: true, + billingAccountId: "80000062", + endDate: "2000-01-01T00:00:00.000Z", + status: "ACTIVE", + totalBudgetRemaining: 0, + }); + + try { + const updated = await service.updateChallenge( + { isMachine: true, sub: "sub-activate", userId: 22838965 }, + activationChallenge.id, + { + status: ChallengeStatusEnum.ACTIVE, + reviewers: buildActivationReviewers(), + }, + ); + should.equal(updated.status, ChallengeStatusEnum.ACTIVE); + } finally { + projectHelper.getProjectBillingInformation = originalGetProjectBillingInformation; + projectHelper.getBillingAccountDetails = originalGetBillingAccountDetails; + await prisma.challenge.delete({ where: { id: activationChallenge.id } }); + } + }); + it("update challenge - prevent activating when reviewer is missing required fields", async () => { const activationChallenge = await createActivationChallenge(); await prisma.challengeReviewer.create({ diff --git a/test/unit/challenge-activation-billing.test.js b/test/unit/challenge-activation-billing.test.js index b5bb32f..eadcc58 100644 --- a/test/unit/challenge-activation-billing.test.js +++ b/test/unit/challenge-activation-billing.test.js @@ -30,7 +30,10 @@ describe("challenge activation billing validation unit tests", () => { challenge: projectChallenge, }); } catch (error) { - should.equal(error.message, "Cannot activate challenge because the project has no billing account."); + should.equal( + error.message, + "Cannot activate challenge because the project has no billing account.", + ); return; } @@ -47,7 +50,7 @@ describe("challenge activation billing validation unit tests", () => { } catch (error) { should.equal( error.message, - "Cannot activate challenge because the project billing account is inactive." + "Cannot activate challenge because the project billing account is inactive.", ); return; } @@ -66,7 +69,7 @@ describe("challenge activation billing validation unit tests", () => { } catch (error) { should.equal( error.message, - "Cannot activate challenge because the project billing account is expired." + "Cannot activate challenge because the project billing account is expired.", ); return; } @@ -93,7 +96,7 @@ describe("challenge activation billing validation unit tests", () => { } catch (error) { should.equal( error.message, - "Cannot activate challenge because the project billing account has insufficient remaining funds." + "Cannot activate challenge because the project billing account has insufficient remaining funds.", ); return; } @@ -117,4 +120,21 @@ describe("challenge activation billing validation unit tests", () => { endDate: "2099-01-01T00:00:00.000Z", }); }); + + it("allows activation when an ignored billing account is expired and out of funds", async () => { + projectHelper.getBillingAccountDetails = async () => ({ + active: true, + billingAccountId: "80000062", + endDate: "2000-01-01T00:00:00.000Z", + status: "ACTIVE", + totalBudgetRemaining: 0, + }); + + await validateChallengeActivationBillingAccount({ + active: true, + billingAccountId: "80000062", + challenge: projectChallenge, + endDate: "2000-01-01T00:00:00.000Z", + }); + }); });