From 6e0586b1ca23768f2939198c1cd8e52dd78e71ed Mon Sep 17 00:00:00 2001 From: jmgasper Date: Wed, 13 May 2026 10:11:08 +1000 Subject: [PATCH] Fix to stats and how we calculate rankings for MMs --- CUTOVER.md | 2 +- Verification.md | 2 +- package.json | 3 +- prisma/generated/client/edge.js | 4 +- prisma/generated/client/index.js | 4 +- prisma/generated/client/package.json | 2 +- prisma/generated/client/schema.prisma | 2 + prisma/generated/client/wasm.js | 4 +- src/common/prisma.js | 4 +- src/common/prismaHelper.js | 48 +- src/ratings/developRatingEngine.js | 190 ++++- src/scripts/recalculateMemberStats.js | 6 +- src/scripts/rerateDevelopmentChallenge.js | 757 +++++++++++++++++++ src/scripts/rerateMarathonMatches.js | 2 +- src/scripts/rerateRatingPath.js | 2 +- src/services/StatisticsService.js | 131 +++- test/unit/DevelopRatingEngine.test.js | 125 ++- test/unit/PrismaManager.test.js | 43 ++ test/unit/RerateDevelopmentChallenge.test.js | 220 ++++++ test/unit/StatisticsService.test.js | 143 +++- 20 files changed, 1643 insertions(+), 51 deletions(-) create mode 100644 src/scripts/rerateDevelopmentChallenge.js create mode 100644 test/unit/PrismaManager.test.js create mode 100644 test/unit/RerateDevelopmentChallenge.test.js diff --git a/CUTOVER.md b/CUTOVER.md index 980b045..f3a021d 100644 --- a/CUTOVER.md +++ b/CUTOVER.md @@ -37,7 +37,7 @@ Recommended rollout: ### 1. Pre-flight checklist -- Confirm all three database env vars are set: `DATABASE_URL`, `CHALLENGES_DB_URL`, `REVIEW_DB_URL` +- Confirm all three database env vars are set: `DATABASE_URL`, `CHALLENGES_DB_URL` or `CHALLENGE_DB_URL`, `REVIEW_DB_URL` - Confirm `REVIEW_DB_URL` points to the review-api database that contains `challengeResult`; this is separate from the member Prisma schema deployment - Confirm the `challenge-api-v6` migration that adds hidden legacy challenge types has been deployed so legacy subtracks such as `ARCHITECTURE`, `ASSEMBLY_COMPETITION`, and `SRM` can be resolved during stats backfill - Confirm `STATS_READ_SOURCE=legacy` is set so reads stay on legacy tables during backfill diff --git a/Verification.md b/Verification.md index eec8548..86ac595 100644 --- a/Verification.md +++ b/Verification.md @@ -64,7 +64,7 @@ There are some changes to prisma schema. - Required env vars: - `DATABASE_URL` for the member database - - `CHALLENGES_DB_URL` for the challenges database + - `CHALLENGES_DB_URL` or `CHALLENGE_DB_URL` for the challenges database - `REVIEW_DB_URL` for the review database; `recalculateMemberStats.js` now reads review-api `challengeResult` rows during aggregate backfill and history supplementation, not just during rerates - Challenge catalog prerequisite: - Deploy the `challenge-api-v6` migration that seeds hidden legacy `ChallengeType` rows for historical subtracks like `ARCHITECTURE`, `ASSEMBLY_COMPETITION`, and `SRM` diff --git a/package.json b/package.json index 1d605ac..b3fcfe9 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,8 @@ "migrate-dynamo-data": "node src/scripts/migrate-dynamo-data.js", "recalculate-member-stats": "node src/scripts/recalculateMemberStats.js", "rerate-rating-path": "node src/scripts/rerateRatingPath.js", - "rerate-marathon-matches": "node src/scripts/rerateMarathonMatches.js" + "rerate-marathon-matches": "node src/scripts/rerateMarathonMatches.js", + "rerate-development-challenge": "node src/scripts/rerateDevelopmentChallenge.js" }, "author": "TCSCODER", "license": "none", diff --git a/prisma/generated/client/edge.js b/prisma/generated/client/edge.js index 086205a..073dcdc 100644 --- a/prisma/generated/client/edge.js +++ b/prisma/generated/client/edge.js @@ -527,8 +527,8 @@ const config = { } } }, - "inlineSchema": "generator client {\n provider = \"prisma-client-js\"\n // Generate a package-scoped client to avoid monorepo conflicts\n output = \"./generated/client\"\n}\n\ndatasource db {\n provider = \"postgresql\"\n url = env(\"DATABASE_URL\")\n schemas = [\"members\"]\n}\n\nenum MemberStatus {\n UNVERIFIED\n ACTIVE\n INACTIVE_USER_REQUEST\n INACTIVE_DUPLICATE_ACCOUNT\n INACTIVE_IRREGULAR_ACCOUNT\n UNKNOWN\n\n @@schema(\"members\")\n}\n\nenum FinancialStatus {\n PENDING\n PAID\n FAILED\n CANCELLED\n\n @@schema(\"members\")\n}\n\nmodel member {\n userId BigInt @id\n handle String\n handleLower String @unique\n email String @unique\n verified Boolean?\n skillScore Float?\n memberRatingId BigInt?\n maxRating memberMaxRating?\n firstName String?\n lastName String?\n description String?\n otherLangName String?\n status MemberStatus?\n newEmail String?\n emailVerifyToken String?\n emailVerifyTokenDate DateTime?\n newEmailVerifyToken String?\n newEmailVerifyTokenDate DateTime?\n addresses memberAddress[]\n phones memberPhone[]\n\n country String?\n homeCountryCode String?\n competitionCountryCode String?\n photoURL String?\n tracks String[]\n loginCount Int?\n lastLoginDate DateTime?\n availableForGigs Boolean?\n availableForGigsLastUpdateDate DateTime?\n lastProfileConfirmationDate DateTime?\n skillScoreDeduction Float?\n namesAndHandleAppearance String?\n aggregatedSkills Json?\n enteredSkills Json?\n\n financial memberFinancial?\n memberStats memberStats[]\n memberStatsHistory memberStatsHistory[]\n memberTraits memberTraits?\n\n createdAt DateTime @default(now())\n createdBy String\n updatedAt DateTime? @updatedAt\n updatedBy String?\n\n @@index([handleLower])\n @@index([email])\n @@index([status, handleLower])\n @@index([lastLoginDate])\n @@index([availableForGigs])\n @@schema(\"members\")\n}\n\nmodel memberAddress {\n id BigInt @id @default(autoincrement())\n userId BigInt\n streetAddr1 String?\n streetAddr2 String?\n city String?\n zip String?\n stateCode String?\n type String\n\n member member @relation(fields: [userId], references: [userId], onDelete: Cascade)\n\n createdAt DateTime @default(now())\n createdBy String\n updatedAt DateTime? @updatedAt\n updatedBy String?\n\n @@index([userId])\n @@index([userId, type])\n @@schema(\"members\")\n}\n\nmodel memberPhone {\n id String @id @default(uuid())\n userId BigInt\n type String\n number String\n\n member member @relation(fields: [userId], references: [userId], onDelete: Cascade)\n\n createdAt DateTime @default(now())\n createdBy String\n updatedAt DateTime? @updatedAt\n updatedBy String?\n\n @@unique([userId, number])\n @@index([userId])\n @@schema(\"members\")\n}\n\nmodel memberMaxRating {\n id BigInt @id @default(autoincrement())\n userId BigInt\n rating Int\n track String?\n subTrack String?\n ratingColor String\n\n createdAt DateTime @default(now())\n createdBy String\n updatedAt DateTime? @updatedAt\n updatedBy String?\n\n member member @relation(fields: [userId], references: [userId], onDelete: Cascade)\n\n @@unique([userId])\n @@index([userId])\n @@schema(\"members\")\n}\n\nmodel memberFinancial {\n userId BigInt @id\n amount Float\n status FinancialStatus\n\n member member @relation(fields: [userId], references: [userId], onDelete: Cascade)\n\n createdAt DateTime @default(now())\n createdBy String\n updatedAt DateTime? @updatedAt\n updatedBy String?\n\n @@schema(\"members\")\n}\n\nmodel memberStatsHistory {\n id BigInt @id @default(autoincrement())\n userId BigInt\n trackId String\n typeId String\n challengeId String\n mostRecent Boolean @default(false)\n oldRating Int?\n newRating Int?\n placement Int?\n percentile Float?\n oldVolatility Int?\n newVolatility Int?\n oldGlobalRank Int?\n newGlobalRank Int?\n oldCountryRank Int?\n newCountryRank Int?\n oldSchoolRank Int?\n newSchoolRank Int?\n eventDate DateTime\n\n member member @relation(fields: [userId], references: [userId], onDelete: Cascade)\n\n createdAt DateTime @default(now())\n createdBy String\n updatedAt DateTime? @updatedAt\n updatedBy String?\n\n @@index([userId])\n @@index([userId, trackId, typeId])\n @@index([userId, trackId, typeId, mostRecent])\n @@index([challengeId])\n @@index([eventDate])\n @@schema(\"members\")\n}\n\nmodel memberStats {\n id BigInt @id @default(autoincrement())\n userId BigInt\n trackId String?\n typeId String?\n challenges Int?\n wins Int?\n mostRecentSubmission DateTime?\n mostRecentEventDate DateTime?\n rating Int?\n avgRank Float?\n avgNumSubmissions Int?\n bestRank Int?\n globalRank Int?\n countryRank Int?\n schoolRank Int?\n volatility Int?\n maxRating Int?\n minRating Int?\n topFiveFinishes Int?\n topTenFinishes Int?\n isPrivate Boolean @default(false)\n\n member member @relation(fields: [userId], references: [userId], onDelete: Cascade)\n\n createdAt DateTime @default(now())\n createdBy String\n updatedAt DateTime? @updatedAt\n updatedBy String?\n\n @@unique([userId, trackId, typeId])\n @@index([userId])\n @@index([trackId])\n @@index([typeId])\n @@index([trackId, typeId, rating])\n @@index([userId, trackId, typeId])\n @@index([globalRank])\n @@index([countryRank])\n @@index([schoolRank])\n @@schema(\"members\")\n}\n\nmodel memberTraits {\n id BigInt @id @default(autoincrement())\n userId BigInt\n\n device memberTraitDevice[]\n software memberTraitSoftware[]\n serviceProvider memberTraitServiceProvider[]\n subscriptions String[]\n hobby String[]\n work memberTraitWork[]\n education memberTraitEducation[]\n basicInfo memberTraitBasicInfo[]\n language memberTraitLanguage[]\n checklist memberTraitOnboardChecklist[]\n personalization memberTraitPersonalization[]\n community memberTraitCommunity[]\n\n member member @relation(fields: [userId], references: [userId])\n\n createdAt DateTime @default(now())\n createdBy String\n updatedAt DateTime? @updatedAt\n updatedBy String?\n\n @@unique([userId])\n @@index([userId])\n @@schema(\"members\")\n}\n\nenum DeviceType {\n Console\n Desktop\n Laptop\n Smartphone\n Tablet\n Wearable\n Other\n\n @@schema(\"members\")\n}\n\nmodel memberTraitDevice {\n id BigInt @id @default(autoincrement())\n\n memberTraitId BigInt\n\n deviceType DeviceType\n manufacturer String\n model String\n operatingSystem String\n osVersion String?\n osLanguage String?\n\n memberTraits memberTraits @relation(fields: [memberTraitId], references: [id], onDelete: Cascade)\n\n createdAt DateTime @default(now())\n createdBy String\n updatedAt DateTime? @updatedAt\n updatedBy String?\n\n @@index([memberTraitId])\n @@schema(\"members\")\n}\n\nenum SoftwareType {\n DeveloperTools\n Browser\n Productivity\n GraphAndDesign\n Utilities\n\n @@schema(\"members\")\n}\n\nmodel memberTraitSoftware {\n id BigInt @id @default(autoincrement())\n\n memberTraitId BigInt\n\n softwareType SoftwareType\n name String\n\n memberTraits memberTraits @relation(fields: [memberTraitId], references: [id], onDelete: Cascade)\n\n createdAt DateTime @default(now())\n createdBy String\n updatedAt DateTime? @updatedAt\n updatedBy String?\n\n @@index([memberTraitId])\n @@schema(\"members\")\n}\n\nenum ServiceProviderType {\n InternetServiceProvider\n MobileCarrier\n Television\n FinancialInstitution\n Other\n\n @@schema(\"members\")\n}\n\nmodel memberTraitServiceProvider {\n id BigInt @id @default(autoincrement())\n\n memberTraitId BigInt\n\n type ServiceProviderType\n name String\n\n memberTraits memberTraits @relation(fields: [memberTraitId], references: [id], onDelete: Cascade)\n\n createdAt DateTime @default(now())\n createdBy String\n updatedAt DateTime? @updatedAt\n updatedBy String?\n\n @@index([memberTraitId])\n @@schema(\"members\")\n}\n\nenum WorkIndustryType {\n Banking\n ConsumerGoods\n Energy\n Entertainment\n HealthCare\n Pharma\n PublicSector\n TechAndTechnologyService\n Telecoms\n TravelAndHospitality\n Other\n\n @@schema(\"members\")\n}\n\nmodel memberTraitWork {\n id BigInt @id @default(autoincrement())\n\n memberTraitId BigInt\n\n industry WorkIndustryType?\n otherIndustry String?\n companyName String\n position String\n startDate DateTime?\n endDate DateTime?\n working Boolean?\n description String?\n associatedSkills String[]\n\n memberTraits memberTraits @relation(fields: [memberTraitId], references: [id], onDelete: Cascade)\n\n createdAt DateTime @default(now())\n createdBy String\n updatedAt DateTime? @updatedAt\n updatedBy String?\n\n @@index([memberTraitId])\n @@schema(\"members\")\n}\n\nmodel memberTraitEducation {\n id BigInt @id @default(autoincrement())\n\n memberTraitId BigInt\n\n collegeName String\n degree String\n endYear Int?\n\n memberTraits memberTraits @relation(fields: [memberTraitId], references: [id], onDelete: Cascade)\n\n createdAt DateTime @default(now())\n createdBy String\n updatedAt DateTime? @updatedAt\n updatedBy String?\n\n @@index([memberTraitId])\n @@schema(\"members\")\n}\n\nmodel memberTraitBasicInfo {\n id BigInt @id @default(autoincrement())\n\n memberTraitId BigInt\n\n userId BigInt\n country String\n primaryInterestInTopcoder String\n tshirtSize String?\n gender String?\n shortBio String\n birthDate DateTime?\n currentLocation String?\n\n memberTraits memberTraits @relation(fields: [memberTraitId], references: [id], onDelete: Cascade)\n\n createdAt DateTime @default(now())\n createdBy String\n updatedAt DateTime? @updatedAt\n updatedBy String?\n\n @@unique([memberTraitId])\n @@index([memberTraitId])\n @@schema(\"members\")\n}\n\nmodel memberTraitLanguage {\n id BigInt @id @default(autoincrement())\n\n memberTraitId BigInt\n\n language String\n spokenLevel String?\n writtenLevel String?\n\n memberTraits memberTraits @relation(fields: [memberTraitId], references: [id], onDelete: Cascade)\n\n createdAt DateTime @default(now())\n createdBy String\n updatedAt DateTime? @updatedAt\n updatedBy String?\n\n @@index([memberTraitId])\n @@schema(\"members\")\n}\n\n// This model is used to send messages when user login. When profile not complete, it will show up.\nmodel memberTraitOnboardChecklist {\n id BigInt @id @default(autoincrement())\n\n memberTraitId BigInt\n\n listItemType String // Like 'profile_completed'\n date DateTime\n message String\n status String\n skip Boolean?\n metadata Json?\n\n memberTraits memberTraits @relation(fields: [memberTraitId], references: [id], onDelete: Cascade)\n\n createdAt DateTime @default(now())\n createdBy String\n updatedAt DateTime? @updatedAt\n updatedBy String?\n\n @@index([memberTraitId])\n @@schema(\"members\")\n}\n\nmodel memberTraitPersonalization {\n id BigInt @id @default(autoincrement())\n\n memberTraitId BigInt\n\n key String?\n value Json?\n private Boolean @default(false)\n\n memberTraits memberTraits @relation(fields: [memberTraitId], references: [id], onDelete: Cascade)\n\n createdAt DateTime @default(now())\n createdBy String\n updatedAt DateTime? @updatedAt\n updatedBy String?\n\n @@index([memberTraitId])\n @@index([memberTraitId, key], map: \"memberTraitPersonalization_memberTraitId_key_idx\")\n @@schema(\"members\")\n}\n\nmodel memberTraitCommunity {\n id BigInt @id @default(autoincrement())\n\n memberTraitId BigInt\n\n communityName String\n status Boolean\n\n memberTraits memberTraits @relation(fields: [memberTraitId], references: [id], onDelete: Cascade)\n\n createdAt DateTime @default(now())\n createdBy String\n updatedAt DateTime? @updatedAt\n updatedBy String?\n\n @@index([memberTraitId])\n @@schema(\"members\")\n}\n", - "inlineSchemaHash": "3b941175f5c4e897804e0ac45e1f2d1a22ce34ad478dc7c1c4ee21efe2886192", + "inlineSchema": "generator client {\n provider = \"prisma-client-js\"\n // Generate a package-scoped client to avoid monorepo conflicts\n output = \"./generated/client\"\n}\n\ndatasource db {\n provider = \"postgresql\"\n url = env(\"DATABASE_URL\")\n schemas = [\"members\"]\n}\n\nenum MemberStatus {\n UNVERIFIED\n ACTIVE\n INACTIVE_USER_REQUEST\n INACTIVE_DUPLICATE_ACCOUNT\n INACTIVE_IRREGULAR_ACCOUNT\n UNKNOWN\n\n @@schema(\"members\")\n}\n\nenum FinancialStatus {\n PENDING\n PAID\n FAILED\n CANCELLED\n\n @@schema(\"members\")\n}\n\nmodel member {\n userId BigInt @id\n handle String\n handleLower String @unique\n email String @unique\n verified Boolean?\n skillScore Float?\n memberRatingId BigInt?\n maxRating memberMaxRating?\n firstName String?\n lastName String?\n description String?\n otherLangName String?\n status MemberStatus?\n newEmail String?\n emailVerifyToken String?\n emailVerifyTokenDate DateTime?\n newEmailVerifyToken String?\n newEmailVerifyTokenDate DateTime?\n addresses memberAddress[]\n phones memberPhone[]\n\n country String?\n homeCountryCode String?\n competitionCountryCode String?\n photoURL String?\n tracks String[]\n loginCount Int?\n lastLoginDate DateTime?\n availableForGigs Boolean?\n availableForGigsLastUpdateDate DateTime?\n lastProfileConfirmationDate DateTime?\n skillScoreDeduction Float?\n namesAndHandleAppearance String?\n aggregatedSkills Json?\n enteredSkills Json?\n\n financial memberFinancial?\n memberStats memberStats[]\n memberStatsHistory memberStatsHistory[]\n memberTraits memberTraits?\n\n createdAt DateTime @default(now())\n createdBy String\n updatedAt DateTime? @updatedAt\n updatedBy String?\n\n @@index([handleLower])\n @@index([email])\n @@index([status, handleLower])\n @@index([lastLoginDate])\n @@index([availableForGigs])\n @@index([status, availableForGigs])\n @@schema(\"members\")\n}\n\nmodel memberAddress {\n id BigInt @id @default(autoincrement())\n userId BigInt\n streetAddr1 String?\n streetAddr2 String?\n city String?\n zip String?\n stateCode String?\n type String\n\n member member @relation(fields: [userId], references: [userId], onDelete: Cascade)\n\n createdAt DateTime @default(now())\n createdBy String\n updatedAt DateTime? @updatedAt\n updatedBy String?\n\n @@index([userId])\n @@index([userId, type])\n @@index([userId, type, id(sort: Desc)], map: \"idx_member_address_user_type\")\n @@schema(\"members\")\n}\n\nmodel memberPhone {\n id String @id @default(uuid())\n userId BigInt\n type String\n number String\n\n member member @relation(fields: [userId], references: [userId], onDelete: Cascade)\n\n createdAt DateTime @default(now())\n createdBy String\n updatedAt DateTime? @updatedAt\n updatedBy String?\n\n @@unique([userId, number])\n @@index([userId])\n @@schema(\"members\")\n}\n\nmodel memberMaxRating {\n id BigInt @id @default(autoincrement())\n userId BigInt\n rating Int\n track String?\n subTrack String?\n ratingColor String\n\n createdAt DateTime @default(now())\n createdBy String\n updatedAt DateTime? @updatedAt\n updatedBy String?\n\n member member @relation(fields: [userId], references: [userId], onDelete: Cascade)\n\n @@unique([userId])\n @@index([userId])\n @@schema(\"members\")\n}\n\nmodel memberFinancial {\n userId BigInt @id\n amount Float\n status FinancialStatus\n\n member member @relation(fields: [userId], references: [userId], onDelete: Cascade)\n\n createdAt DateTime @default(now())\n createdBy String\n updatedAt DateTime? @updatedAt\n updatedBy String?\n\n @@schema(\"members\")\n}\n\nmodel memberStatsHistory {\n id BigInt @id @default(autoincrement())\n userId BigInt\n trackId String\n typeId String\n challengeId String\n mostRecent Boolean @default(false)\n oldRating Int?\n newRating Int?\n placement Int?\n percentile Float?\n oldVolatility Int?\n newVolatility Int?\n oldGlobalRank Int?\n newGlobalRank Int?\n oldCountryRank Int?\n newCountryRank Int?\n oldSchoolRank Int?\n newSchoolRank Int?\n eventDate DateTime\n\n member member @relation(fields: [userId], references: [userId], onDelete: Cascade)\n\n createdAt DateTime @default(now())\n createdBy String\n updatedAt DateTime? @updatedAt\n updatedBy String?\n\n @@index([userId])\n @@index([userId, trackId, typeId])\n @@index([userId, trackId, typeId, mostRecent])\n @@index([challengeId])\n @@index([eventDate])\n @@schema(\"members\")\n}\n\nmodel memberStats {\n id BigInt @id @default(autoincrement())\n userId BigInt\n trackId String?\n typeId String?\n challenges Int?\n wins Int?\n mostRecentSubmission DateTime?\n mostRecentEventDate DateTime?\n rating Int?\n avgRank Float?\n avgNumSubmissions Int?\n bestRank Int?\n globalRank Int?\n countryRank Int?\n schoolRank Int?\n volatility Int?\n maxRating Int?\n minRating Int?\n topFiveFinishes Int?\n topTenFinishes Int?\n isPrivate Boolean @default(false)\n\n member member @relation(fields: [userId], references: [userId], onDelete: Cascade)\n\n createdAt DateTime @default(now())\n createdBy String\n updatedAt DateTime? @updatedAt\n updatedBy String?\n\n @@unique([userId, trackId, typeId])\n @@index([userId])\n @@index([trackId])\n @@index([typeId])\n @@index([trackId, typeId, rating])\n @@index([userId, trackId, typeId])\n @@index([globalRank])\n @@index([countryRank])\n @@index([schoolRank])\n @@schema(\"members\")\n}\n\nmodel memberTraits {\n id BigInt @id @default(autoincrement())\n userId BigInt\n\n device memberTraitDevice[]\n software memberTraitSoftware[]\n serviceProvider memberTraitServiceProvider[]\n subscriptions String[]\n hobby String[]\n work memberTraitWork[]\n education memberTraitEducation[]\n basicInfo memberTraitBasicInfo[]\n language memberTraitLanguage[]\n checklist memberTraitOnboardChecklist[]\n personalization memberTraitPersonalization[]\n community memberTraitCommunity[]\n\n member member @relation(fields: [userId], references: [userId])\n\n createdAt DateTime @default(now())\n createdBy String\n updatedAt DateTime? @updatedAt\n updatedBy String?\n\n @@unique([userId])\n @@index([userId])\n @@schema(\"members\")\n}\n\nenum DeviceType {\n Console\n Desktop\n Laptop\n Smartphone\n Tablet\n Wearable\n Other\n\n @@schema(\"members\")\n}\n\nmodel memberTraitDevice {\n id BigInt @id @default(autoincrement())\n\n memberTraitId BigInt\n\n deviceType DeviceType\n manufacturer String\n model String\n operatingSystem String\n osVersion String?\n osLanguage String?\n\n memberTraits memberTraits @relation(fields: [memberTraitId], references: [id], onDelete: Cascade)\n\n createdAt DateTime @default(now())\n createdBy String\n updatedAt DateTime? @updatedAt\n updatedBy String?\n\n @@index([memberTraitId])\n @@schema(\"members\")\n}\n\nenum SoftwareType {\n DeveloperTools\n Browser\n Productivity\n GraphAndDesign\n Utilities\n\n @@schema(\"members\")\n}\n\nmodel memberTraitSoftware {\n id BigInt @id @default(autoincrement())\n\n memberTraitId BigInt\n\n softwareType SoftwareType\n name String\n\n memberTraits memberTraits @relation(fields: [memberTraitId], references: [id], onDelete: Cascade)\n\n createdAt DateTime @default(now())\n createdBy String\n updatedAt DateTime? @updatedAt\n updatedBy String?\n\n @@index([memberTraitId])\n @@schema(\"members\")\n}\n\nenum ServiceProviderType {\n InternetServiceProvider\n MobileCarrier\n Television\n FinancialInstitution\n Other\n\n @@schema(\"members\")\n}\n\nmodel memberTraitServiceProvider {\n id BigInt @id @default(autoincrement())\n\n memberTraitId BigInt\n\n type ServiceProviderType\n name String\n\n memberTraits memberTraits @relation(fields: [memberTraitId], references: [id], onDelete: Cascade)\n\n createdAt DateTime @default(now())\n createdBy String\n updatedAt DateTime? @updatedAt\n updatedBy String?\n\n @@index([memberTraitId])\n @@schema(\"members\")\n}\n\nenum WorkIndustryType {\n Banking\n ConsumerGoods\n Energy\n Entertainment\n HealthCare\n Pharma\n PublicSector\n TechAndTechnologyService\n Telecoms\n TravelAndHospitality\n Other\n\n @@schema(\"members\")\n}\n\nmodel memberTraitWork {\n id BigInt @id @default(autoincrement())\n\n memberTraitId BigInt\n\n industry WorkIndustryType?\n otherIndustry String?\n companyName String\n position String\n startDate DateTime?\n endDate DateTime?\n working Boolean?\n description String?\n associatedSkills String[]\n\n memberTraits memberTraits @relation(fields: [memberTraitId], references: [id], onDelete: Cascade)\n\n createdAt DateTime @default(now())\n createdBy String\n updatedAt DateTime? @updatedAt\n updatedBy String?\n\n @@index([memberTraitId])\n @@schema(\"members\")\n}\n\nmodel memberTraitEducation {\n id BigInt @id @default(autoincrement())\n\n memberTraitId BigInt\n\n collegeName String\n degree String\n endYear Int?\n\n memberTraits memberTraits @relation(fields: [memberTraitId], references: [id], onDelete: Cascade)\n\n createdAt DateTime @default(now())\n createdBy String\n updatedAt DateTime? @updatedAt\n updatedBy String?\n\n @@index([memberTraitId])\n @@schema(\"members\")\n}\n\nmodel memberTraitBasicInfo {\n id BigInt @id @default(autoincrement())\n\n memberTraitId BigInt\n\n userId BigInt\n country String\n primaryInterestInTopcoder String\n tshirtSize String?\n gender String?\n shortBio String\n birthDate DateTime?\n currentLocation String?\n\n memberTraits memberTraits @relation(fields: [memberTraitId], references: [id], onDelete: Cascade)\n\n createdAt DateTime @default(now())\n createdBy String\n updatedAt DateTime? @updatedAt\n updatedBy String?\n\n @@unique([memberTraitId])\n @@index([memberTraitId])\n @@schema(\"members\")\n}\n\nmodel memberTraitLanguage {\n id BigInt @id @default(autoincrement())\n\n memberTraitId BigInt\n\n language String\n spokenLevel String?\n writtenLevel String?\n\n memberTraits memberTraits @relation(fields: [memberTraitId], references: [id], onDelete: Cascade)\n\n createdAt DateTime @default(now())\n createdBy String\n updatedAt DateTime? @updatedAt\n updatedBy String?\n\n @@index([memberTraitId])\n @@schema(\"members\")\n}\n\n// This model is used to send messages when user login. When profile not complete, it will show up.\nmodel memberTraitOnboardChecklist {\n id BigInt @id @default(autoincrement())\n\n memberTraitId BigInt\n\n listItemType String // Like 'profile_completed'\n date DateTime\n message String\n status String\n skip Boolean?\n metadata Json?\n\n memberTraits memberTraits @relation(fields: [memberTraitId], references: [id], onDelete: Cascade)\n\n createdAt DateTime @default(now())\n createdBy String\n updatedAt DateTime? @updatedAt\n updatedBy String?\n\n @@index([memberTraitId])\n @@schema(\"members\")\n}\n\nmodel memberTraitPersonalization {\n id BigInt @id @default(autoincrement())\n\n memberTraitId BigInt\n\n key String?\n value Json?\n private Boolean @default(false)\n\n memberTraits memberTraits @relation(fields: [memberTraitId], references: [id], onDelete: Cascade)\n\n createdAt DateTime @default(now())\n createdBy String\n updatedAt DateTime? @updatedAt\n updatedBy String?\n\n @@index([memberTraitId])\n @@index([memberTraitId, key], map: \"memberTraitPersonalization_memberTraitId_key_idx\")\n @@schema(\"members\")\n}\n\nmodel memberTraitCommunity {\n id BigInt @id @default(autoincrement())\n\n memberTraitId BigInt\n\n communityName String\n status Boolean\n\n memberTraits memberTraits @relation(fields: [memberTraitId], references: [id], onDelete: Cascade)\n\n createdAt DateTime @default(now())\n createdBy String\n updatedAt DateTime? @updatedAt\n updatedBy String?\n\n @@index([memberTraitId])\n @@schema(\"members\")\n}\n", + "inlineSchemaHash": "a4dae9404f7092f693d59206e2da0630cb1395ad7109841bc19141c7d9faeb6c", "copyEngine": true } config.dirname = '/' diff --git a/prisma/generated/client/index.js b/prisma/generated/client/index.js index f7da6a9..1bebbc0 100644 --- a/prisma/generated/client/index.js +++ b/prisma/generated/client/index.js @@ -528,8 +528,8 @@ const config = { } } }, - "inlineSchema": "generator client {\n provider = \"prisma-client-js\"\n // Generate a package-scoped client to avoid monorepo conflicts\n output = \"./generated/client\"\n}\n\ndatasource db {\n provider = \"postgresql\"\n url = env(\"DATABASE_URL\")\n schemas = [\"members\"]\n}\n\nenum MemberStatus {\n UNVERIFIED\n ACTIVE\n INACTIVE_USER_REQUEST\n INACTIVE_DUPLICATE_ACCOUNT\n INACTIVE_IRREGULAR_ACCOUNT\n UNKNOWN\n\n @@schema(\"members\")\n}\n\nenum FinancialStatus {\n PENDING\n PAID\n FAILED\n CANCELLED\n\n @@schema(\"members\")\n}\n\nmodel member {\n userId BigInt @id\n handle String\n handleLower String @unique\n email String @unique\n verified Boolean?\n skillScore Float?\n memberRatingId BigInt?\n maxRating memberMaxRating?\n firstName String?\n lastName String?\n description String?\n otherLangName String?\n status MemberStatus?\n newEmail String?\n emailVerifyToken String?\n emailVerifyTokenDate DateTime?\n newEmailVerifyToken String?\n newEmailVerifyTokenDate DateTime?\n addresses memberAddress[]\n phones memberPhone[]\n\n country String?\n homeCountryCode String?\n competitionCountryCode String?\n photoURL String?\n tracks String[]\n loginCount Int?\n lastLoginDate DateTime?\n availableForGigs Boolean?\n availableForGigsLastUpdateDate DateTime?\n lastProfileConfirmationDate DateTime?\n skillScoreDeduction Float?\n namesAndHandleAppearance String?\n aggregatedSkills Json?\n enteredSkills Json?\n\n financial memberFinancial?\n memberStats memberStats[]\n memberStatsHistory memberStatsHistory[]\n memberTraits memberTraits?\n\n createdAt DateTime @default(now())\n createdBy String\n updatedAt DateTime? @updatedAt\n updatedBy String?\n\n @@index([handleLower])\n @@index([email])\n @@index([status, handleLower])\n @@index([lastLoginDate])\n @@index([availableForGigs])\n @@schema(\"members\")\n}\n\nmodel memberAddress {\n id BigInt @id @default(autoincrement())\n userId BigInt\n streetAddr1 String?\n streetAddr2 String?\n city String?\n zip String?\n stateCode String?\n type String\n\n member member @relation(fields: [userId], references: [userId], onDelete: Cascade)\n\n createdAt DateTime @default(now())\n createdBy String\n updatedAt DateTime? @updatedAt\n updatedBy String?\n\n @@index([userId])\n @@index([userId, type])\n @@schema(\"members\")\n}\n\nmodel memberPhone {\n id String @id @default(uuid())\n userId BigInt\n type String\n number String\n\n member member @relation(fields: [userId], references: [userId], onDelete: Cascade)\n\n createdAt DateTime @default(now())\n createdBy String\n updatedAt DateTime? @updatedAt\n updatedBy String?\n\n @@unique([userId, number])\n @@index([userId])\n @@schema(\"members\")\n}\n\nmodel memberMaxRating {\n id BigInt @id @default(autoincrement())\n userId BigInt\n rating Int\n track String?\n subTrack String?\n ratingColor String\n\n createdAt DateTime @default(now())\n createdBy String\n updatedAt DateTime? @updatedAt\n updatedBy String?\n\n member member @relation(fields: [userId], references: [userId], onDelete: Cascade)\n\n @@unique([userId])\n @@index([userId])\n @@schema(\"members\")\n}\n\nmodel memberFinancial {\n userId BigInt @id\n amount Float\n status FinancialStatus\n\n member member @relation(fields: [userId], references: [userId], onDelete: Cascade)\n\n createdAt DateTime @default(now())\n createdBy String\n updatedAt DateTime? @updatedAt\n updatedBy String?\n\n @@schema(\"members\")\n}\n\nmodel memberStatsHistory {\n id BigInt @id @default(autoincrement())\n userId BigInt\n trackId String\n typeId String\n challengeId String\n mostRecent Boolean @default(false)\n oldRating Int?\n newRating Int?\n placement Int?\n percentile Float?\n oldVolatility Int?\n newVolatility Int?\n oldGlobalRank Int?\n newGlobalRank Int?\n oldCountryRank Int?\n newCountryRank Int?\n oldSchoolRank Int?\n newSchoolRank Int?\n eventDate DateTime\n\n member member @relation(fields: [userId], references: [userId], onDelete: Cascade)\n\n createdAt DateTime @default(now())\n createdBy String\n updatedAt DateTime? @updatedAt\n updatedBy String?\n\n @@index([userId])\n @@index([userId, trackId, typeId])\n @@index([userId, trackId, typeId, mostRecent])\n @@index([challengeId])\n @@index([eventDate])\n @@schema(\"members\")\n}\n\nmodel memberStats {\n id BigInt @id @default(autoincrement())\n userId BigInt\n trackId String?\n typeId String?\n challenges Int?\n wins Int?\n mostRecentSubmission DateTime?\n mostRecentEventDate DateTime?\n rating Int?\n avgRank Float?\n avgNumSubmissions Int?\n bestRank Int?\n globalRank Int?\n countryRank Int?\n schoolRank Int?\n volatility Int?\n maxRating Int?\n minRating Int?\n topFiveFinishes Int?\n topTenFinishes Int?\n isPrivate Boolean @default(false)\n\n member member @relation(fields: [userId], references: [userId], onDelete: Cascade)\n\n createdAt DateTime @default(now())\n createdBy String\n updatedAt DateTime? @updatedAt\n updatedBy String?\n\n @@unique([userId, trackId, typeId])\n @@index([userId])\n @@index([trackId])\n @@index([typeId])\n @@index([trackId, typeId, rating])\n @@index([userId, trackId, typeId])\n @@index([globalRank])\n @@index([countryRank])\n @@index([schoolRank])\n @@schema(\"members\")\n}\n\nmodel memberTraits {\n id BigInt @id @default(autoincrement())\n userId BigInt\n\n device memberTraitDevice[]\n software memberTraitSoftware[]\n serviceProvider memberTraitServiceProvider[]\n subscriptions String[]\n hobby String[]\n work memberTraitWork[]\n education memberTraitEducation[]\n basicInfo memberTraitBasicInfo[]\n language memberTraitLanguage[]\n checklist memberTraitOnboardChecklist[]\n personalization memberTraitPersonalization[]\n community memberTraitCommunity[]\n\n member member @relation(fields: [userId], references: [userId])\n\n createdAt DateTime @default(now())\n createdBy String\n updatedAt DateTime? @updatedAt\n updatedBy String?\n\n @@unique([userId])\n @@index([userId])\n @@schema(\"members\")\n}\n\nenum DeviceType {\n Console\n Desktop\n Laptop\n Smartphone\n Tablet\n Wearable\n Other\n\n @@schema(\"members\")\n}\n\nmodel memberTraitDevice {\n id BigInt @id @default(autoincrement())\n\n memberTraitId BigInt\n\n deviceType DeviceType\n manufacturer String\n model String\n operatingSystem String\n osVersion String?\n osLanguage String?\n\n memberTraits memberTraits @relation(fields: [memberTraitId], references: [id], onDelete: Cascade)\n\n createdAt DateTime @default(now())\n createdBy String\n updatedAt DateTime? @updatedAt\n updatedBy String?\n\n @@index([memberTraitId])\n @@schema(\"members\")\n}\n\nenum SoftwareType {\n DeveloperTools\n Browser\n Productivity\n GraphAndDesign\n Utilities\n\n @@schema(\"members\")\n}\n\nmodel memberTraitSoftware {\n id BigInt @id @default(autoincrement())\n\n memberTraitId BigInt\n\n softwareType SoftwareType\n name String\n\n memberTraits memberTraits @relation(fields: [memberTraitId], references: [id], onDelete: Cascade)\n\n createdAt DateTime @default(now())\n createdBy String\n updatedAt DateTime? @updatedAt\n updatedBy String?\n\n @@index([memberTraitId])\n @@schema(\"members\")\n}\n\nenum ServiceProviderType {\n InternetServiceProvider\n MobileCarrier\n Television\n FinancialInstitution\n Other\n\n @@schema(\"members\")\n}\n\nmodel memberTraitServiceProvider {\n id BigInt @id @default(autoincrement())\n\n memberTraitId BigInt\n\n type ServiceProviderType\n name String\n\n memberTraits memberTraits @relation(fields: [memberTraitId], references: [id], onDelete: Cascade)\n\n createdAt DateTime @default(now())\n createdBy String\n updatedAt DateTime? @updatedAt\n updatedBy String?\n\n @@index([memberTraitId])\n @@schema(\"members\")\n}\n\nenum WorkIndustryType {\n Banking\n ConsumerGoods\n Energy\n Entertainment\n HealthCare\n Pharma\n PublicSector\n TechAndTechnologyService\n Telecoms\n TravelAndHospitality\n Other\n\n @@schema(\"members\")\n}\n\nmodel memberTraitWork {\n id BigInt @id @default(autoincrement())\n\n memberTraitId BigInt\n\n industry WorkIndustryType?\n otherIndustry String?\n companyName String\n position String\n startDate DateTime?\n endDate DateTime?\n working Boolean?\n description String?\n associatedSkills String[]\n\n memberTraits memberTraits @relation(fields: [memberTraitId], references: [id], onDelete: Cascade)\n\n createdAt DateTime @default(now())\n createdBy String\n updatedAt DateTime? @updatedAt\n updatedBy String?\n\n @@index([memberTraitId])\n @@schema(\"members\")\n}\n\nmodel memberTraitEducation {\n id BigInt @id @default(autoincrement())\n\n memberTraitId BigInt\n\n collegeName String\n degree String\n endYear Int?\n\n memberTraits memberTraits @relation(fields: [memberTraitId], references: [id], onDelete: Cascade)\n\n createdAt DateTime @default(now())\n createdBy String\n updatedAt DateTime? @updatedAt\n updatedBy String?\n\n @@index([memberTraitId])\n @@schema(\"members\")\n}\n\nmodel memberTraitBasicInfo {\n id BigInt @id @default(autoincrement())\n\n memberTraitId BigInt\n\n userId BigInt\n country String\n primaryInterestInTopcoder String\n tshirtSize String?\n gender String?\n shortBio String\n birthDate DateTime?\n currentLocation String?\n\n memberTraits memberTraits @relation(fields: [memberTraitId], references: [id], onDelete: Cascade)\n\n createdAt DateTime @default(now())\n createdBy String\n updatedAt DateTime? @updatedAt\n updatedBy String?\n\n @@unique([memberTraitId])\n @@index([memberTraitId])\n @@schema(\"members\")\n}\n\nmodel memberTraitLanguage {\n id BigInt @id @default(autoincrement())\n\n memberTraitId BigInt\n\n language String\n spokenLevel String?\n writtenLevel String?\n\n memberTraits memberTraits @relation(fields: [memberTraitId], references: [id], onDelete: Cascade)\n\n createdAt DateTime @default(now())\n createdBy String\n updatedAt DateTime? @updatedAt\n updatedBy String?\n\n @@index([memberTraitId])\n @@schema(\"members\")\n}\n\n// This model is used to send messages when user login. When profile not complete, it will show up.\nmodel memberTraitOnboardChecklist {\n id BigInt @id @default(autoincrement())\n\n memberTraitId BigInt\n\n listItemType String // Like 'profile_completed'\n date DateTime\n message String\n status String\n skip Boolean?\n metadata Json?\n\n memberTraits memberTraits @relation(fields: [memberTraitId], references: [id], onDelete: Cascade)\n\n createdAt DateTime @default(now())\n createdBy String\n updatedAt DateTime? @updatedAt\n updatedBy String?\n\n @@index([memberTraitId])\n @@schema(\"members\")\n}\n\nmodel memberTraitPersonalization {\n id BigInt @id @default(autoincrement())\n\n memberTraitId BigInt\n\n key String?\n value Json?\n private Boolean @default(false)\n\n memberTraits memberTraits @relation(fields: [memberTraitId], references: [id], onDelete: Cascade)\n\n createdAt DateTime @default(now())\n createdBy String\n updatedAt DateTime? @updatedAt\n updatedBy String?\n\n @@index([memberTraitId])\n @@index([memberTraitId, key], map: \"memberTraitPersonalization_memberTraitId_key_idx\")\n @@schema(\"members\")\n}\n\nmodel memberTraitCommunity {\n id BigInt @id @default(autoincrement())\n\n memberTraitId BigInt\n\n communityName String\n status Boolean\n\n memberTraits memberTraits @relation(fields: [memberTraitId], references: [id], onDelete: Cascade)\n\n createdAt DateTime @default(now())\n createdBy String\n updatedAt DateTime? @updatedAt\n updatedBy String?\n\n @@index([memberTraitId])\n @@schema(\"members\")\n}\n", - "inlineSchemaHash": "3b941175f5c4e897804e0ac45e1f2d1a22ce34ad478dc7c1c4ee21efe2886192", + "inlineSchema": "generator client {\n provider = \"prisma-client-js\"\n // Generate a package-scoped client to avoid monorepo conflicts\n output = \"./generated/client\"\n}\n\ndatasource db {\n provider = \"postgresql\"\n url = env(\"DATABASE_URL\")\n schemas = [\"members\"]\n}\n\nenum MemberStatus {\n UNVERIFIED\n ACTIVE\n INACTIVE_USER_REQUEST\n INACTIVE_DUPLICATE_ACCOUNT\n INACTIVE_IRREGULAR_ACCOUNT\n UNKNOWN\n\n @@schema(\"members\")\n}\n\nenum FinancialStatus {\n PENDING\n PAID\n FAILED\n CANCELLED\n\n @@schema(\"members\")\n}\n\nmodel member {\n userId BigInt @id\n handle String\n handleLower String @unique\n email String @unique\n verified Boolean?\n skillScore Float?\n memberRatingId BigInt?\n maxRating memberMaxRating?\n firstName String?\n lastName String?\n description String?\n otherLangName String?\n status MemberStatus?\n newEmail String?\n emailVerifyToken String?\n emailVerifyTokenDate DateTime?\n newEmailVerifyToken String?\n newEmailVerifyTokenDate DateTime?\n addresses memberAddress[]\n phones memberPhone[]\n\n country String?\n homeCountryCode String?\n competitionCountryCode String?\n photoURL String?\n tracks String[]\n loginCount Int?\n lastLoginDate DateTime?\n availableForGigs Boolean?\n availableForGigsLastUpdateDate DateTime?\n lastProfileConfirmationDate DateTime?\n skillScoreDeduction Float?\n namesAndHandleAppearance String?\n aggregatedSkills Json?\n enteredSkills Json?\n\n financial memberFinancial?\n memberStats memberStats[]\n memberStatsHistory memberStatsHistory[]\n memberTraits memberTraits?\n\n createdAt DateTime @default(now())\n createdBy String\n updatedAt DateTime? @updatedAt\n updatedBy String?\n\n @@index([handleLower])\n @@index([email])\n @@index([status, handleLower])\n @@index([lastLoginDate])\n @@index([availableForGigs])\n @@index([status, availableForGigs])\n @@schema(\"members\")\n}\n\nmodel memberAddress {\n id BigInt @id @default(autoincrement())\n userId BigInt\n streetAddr1 String?\n streetAddr2 String?\n city String?\n zip String?\n stateCode String?\n type String\n\n member member @relation(fields: [userId], references: [userId], onDelete: Cascade)\n\n createdAt DateTime @default(now())\n createdBy String\n updatedAt DateTime? @updatedAt\n updatedBy String?\n\n @@index([userId])\n @@index([userId, type])\n @@index([userId, type, id(sort: Desc)], map: \"idx_member_address_user_type\")\n @@schema(\"members\")\n}\n\nmodel memberPhone {\n id String @id @default(uuid())\n userId BigInt\n type String\n number String\n\n member member @relation(fields: [userId], references: [userId], onDelete: Cascade)\n\n createdAt DateTime @default(now())\n createdBy String\n updatedAt DateTime? @updatedAt\n updatedBy String?\n\n @@unique([userId, number])\n @@index([userId])\n @@schema(\"members\")\n}\n\nmodel memberMaxRating {\n id BigInt @id @default(autoincrement())\n userId BigInt\n rating Int\n track String?\n subTrack String?\n ratingColor String\n\n createdAt DateTime @default(now())\n createdBy String\n updatedAt DateTime? @updatedAt\n updatedBy String?\n\n member member @relation(fields: [userId], references: [userId], onDelete: Cascade)\n\n @@unique([userId])\n @@index([userId])\n @@schema(\"members\")\n}\n\nmodel memberFinancial {\n userId BigInt @id\n amount Float\n status FinancialStatus\n\n member member @relation(fields: [userId], references: [userId], onDelete: Cascade)\n\n createdAt DateTime @default(now())\n createdBy String\n updatedAt DateTime? @updatedAt\n updatedBy String?\n\n @@schema(\"members\")\n}\n\nmodel memberStatsHistory {\n id BigInt @id @default(autoincrement())\n userId BigInt\n trackId String\n typeId String\n challengeId String\n mostRecent Boolean @default(false)\n oldRating Int?\n newRating Int?\n placement Int?\n percentile Float?\n oldVolatility Int?\n newVolatility Int?\n oldGlobalRank Int?\n newGlobalRank Int?\n oldCountryRank Int?\n newCountryRank Int?\n oldSchoolRank Int?\n newSchoolRank Int?\n eventDate DateTime\n\n member member @relation(fields: [userId], references: [userId], onDelete: Cascade)\n\n createdAt DateTime @default(now())\n createdBy String\n updatedAt DateTime? @updatedAt\n updatedBy String?\n\n @@index([userId])\n @@index([userId, trackId, typeId])\n @@index([userId, trackId, typeId, mostRecent])\n @@index([challengeId])\n @@index([eventDate])\n @@schema(\"members\")\n}\n\nmodel memberStats {\n id BigInt @id @default(autoincrement())\n userId BigInt\n trackId String?\n typeId String?\n challenges Int?\n wins Int?\n mostRecentSubmission DateTime?\n mostRecentEventDate DateTime?\n rating Int?\n avgRank Float?\n avgNumSubmissions Int?\n bestRank Int?\n globalRank Int?\n countryRank Int?\n schoolRank Int?\n volatility Int?\n maxRating Int?\n minRating Int?\n topFiveFinishes Int?\n topTenFinishes Int?\n isPrivate Boolean @default(false)\n\n member member @relation(fields: [userId], references: [userId], onDelete: Cascade)\n\n createdAt DateTime @default(now())\n createdBy String\n updatedAt DateTime? @updatedAt\n updatedBy String?\n\n @@unique([userId, trackId, typeId])\n @@index([userId])\n @@index([trackId])\n @@index([typeId])\n @@index([trackId, typeId, rating])\n @@index([userId, trackId, typeId])\n @@index([globalRank])\n @@index([countryRank])\n @@index([schoolRank])\n @@schema(\"members\")\n}\n\nmodel memberTraits {\n id BigInt @id @default(autoincrement())\n userId BigInt\n\n device memberTraitDevice[]\n software memberTraitSoftware[]\n serviceProvider memberTraitServiceProvider[]\n subscriptions String[]\n hobby String[]\n work memberTraitWork[]\n education memberTraitEducation[]\n basicInfo memberTraitBasicInfo[]\n language memberTraitLanguage[]\n checklist memberTraitOnboardChecklist[]\n personalization memberTraitPersonalization[]\n community memberTraitCommunity[]\n\n member member @relation(fields: [userId], references: [userId])\n\n createdAt DateTime @default(now())\n createdBy String\n updatedAt DateTime? @updatedAt\n updatedBy String?\n\n @@unique([userId])\n @@index([userId])\n @@schema(\"members\")\n}\n\nenum DeviceType {\n Console\n Desktop\n Laptop\n Smartphone\n Tablet\n Wearable\n Other\n\n @@schema(\"members\")\n}\n\nmodel memberTraitDevice {\n id BigInt @id @default(autoincrement())\n\n memberTraitId BigInt\n\n deviceType DeviceType\n manufacturer String\n model String\n operatingSystem String\n osVersion String?\n osLanguage String?\n\n memberTraits memberTraits @relation(fields: [memberTraitId], references: [id], onDelete: Cascade)\n\n createdAt DateTime @default(now())\n createdBy String\n updatedAt DateTime? @updatedAt\n updatedBy String?\n\n @@index([memberTraitId])\n @@schema(\"members\")\n}\n\nenum SoftwareType {\n DeveloperTools\n Browser\n Productivity\n GraphAndDesign\n Utilities\n\n @@schema(\"members\")\n}\n\nmodel memberTraitSoftware {\n id BigInt @id @default(autoincrement())\n\n memberTraitId BigInt\n\n softwareType SoftwareType\n name String\n\n memberTraits memberTraits @relation(fields: [memberTraitId], references: [id], onDelete: Cascade)\n\n createdAt DateTime @default(now())\n createdBy String\n updatedAt DateTime? @updatedAt\n updatedBy String?\n\n @@index([memberTraitId])\n @@schema(\"members\")\n}\n\nenum ServiceProviderType {\n InternetServiceProvider\n MobileCarrier\n Television\n FinancialInstitution\n Other\n\n @@schema(\"members\")\n}\n\nmodel memberTraitServiceProvider {\n id BigInt @id @default(autoincrement())\n\n memberTraitId BigInt\n\n type ServiceProviderType\n name String\n\n memberTraits memberTraits @relation(fields: [memberTraitId], references: [id], onDelete: Cascade)\n\n createdAt DateTime @default(now())\n createdBy String\n updatedAt DateTime? @updatedAt\n updatedBy String?\n\n @@index([memberTraitId])\n @@schema(\"members\")\n}\n\nenum WorkIndustryType {\n Banking\n ConsumerGoods\n Energy\n Entertainment\n HealthCare\n Pharma\n PublicSector\n TechAndTechnologyService\n Telecoms\n TravelAndHospitality\n Other\n\n @@schema(\"members\")\n}\n\nmodel memberTraitWork {\n id BigInt @id @default(autoincrement())\n\n memberTraitId BigInt\n\n industry WorkIndustryType?\n otherIndustry String?\n companyName String\n position String\n startDate DateTime?\n endDate DateTime?\n working Boolean?\n description String?\n associatedSkills String[]\n\n memberTraits memberTraits @relation(fields: [memberTraitId], references: [id], onDelete: Cascade)\n\n createdAt DateTime @default(now())\n createdBy String\n updatedAt DateTime? @updatedAt\n updatedBy String?\n\n @@index([memberTraitId])\n @@schema(\"members\")\n}\n\nmodel memberTraitEducation {\n id BigInt @id @default(autoincrement())\n\n memberTraitId BigInt\n\n collegeName String\n degree String\n endYear Int?\n\n memberTraits memberTraits @relation(fields: [memberTraitId], references: [id], onDelete: Cascade)\n\n createdAt DateTime @default(now())\n createdBy String\n updatedAt DateTime? @updatedAt\n updatedBy String?\n\n @@index([memberTraitId])\n @@schema(\"members\")\n}\n\nmodel memberTraitBasicInfo {\n id BigInt @id @default(autoincrement())\n\n memberTraitId BigInt\n\n userId BigInt\n country String\n primaryInterestInTopcoder String\n tshirtSize String?\n gender String?\n shortBio String\n birthDate DateTime?\n currentLocation String?\n\n memberTraits memberTraits @relation(fields: [memberTraitId], references: [id], onDelete: Cascade)\n\n createdAt DateTime @default(now())\n createdBy String\n updatedAt DateTime? @updatedAt\n updatedBy String?\n\n @@unique([memberTraitId])\n @@index([memberTraitId])\n @@schema(\"members\")\n}\n\nmodel memberTraitLanguage {\n id BigInt @id @default(autoincrement())\n\n memberTraitId BigInt\n\n language String\n spokenLevel String?\n writtenLevel String?\n\n memberTraits memberTraits @relation(fields: [memberTraitId], references: [id], onDelete: Cascade)\n\n createdAt DateTime @default(now())\n createdBy String\n updatedAt DateTime? @updatedAt\n updatedBy String?\n\n @@index([memberTraitId])\n @@schema(\"members\")\n}\n\n// This model is used to send messages when user login. When profile not complete, it will show up.\nmodel memberTraitOnboardChecklist {\n id BigInt @id @default(autoincrement())\n\n memberTraitId BigInt\n\n listItemType String // Like 'profile_completed'\n date DateTime\n message String\n status String\n skip Boolean?\n metadata Json?\n\n memberTraits memberTraits @relation(fields: [memberTraitId], references: [id], onDelete: Cascade)\n\n createdAt DateTime @default(now())\n createdBy String\n updatedAt DateTime? @updatedAt\n updatedBy String?\n\n @@index([memberTraitId])\n @@schema(\"members\")\n}\n\nmodel memberTraitPersonalization {\n id BigInt @id @default(autoincrement())\n\n memberTraitId BigInt\n\n key String?\n value Json?\n private Boolean @default(false)\n\n memberTraits memberTraits @relation(fields: [memberTraitId], references: [id], onDelete: Cascade)\n\n createdAt DateTime @default(now())\n createdBy String\n updatedAt DateTime? @updatedAt\n updatedBy String?\n\n @@index([memberTraitId])\n @@index([memberTraitId, key], map: \"memberTraitPersonalization_memberTraitId_key_idx\")\n @@schema(\"members\")\n}\n\nmodel memberTraitCommunity {\n id BigInt @id @default(autoincrement())\n\n memberTraitId BigInt\n\n communityName String\n status Boolean\n\n memberTraits memberTraits @relation(fields: [memberTraitId], references: [id], onDelete: Cascade)\n\n createdAt DateTime @default(now())\n createdBy String\n updatedAt DateTime? @updatedAt\n updatedBy String?\n\n @@index([memberTraitId])\n @@schema(\"members\")\n}\n", + "inlineSchemaHash": "a4dae9404f7092f693d59206e2da0630cb1395ad7109841bc19141c7d9faeb6c", "copyEngine": true } diff --git a/prisma/generated/client/package.json b/prisma/generated/client/package.json index 0ddffc1..982ffb9 100644 --- a/prisma/generated/client/package.json +++ b/prisma/generated/client/package.json @@ -1,5 +1,5 @@ { - "name": "prisma-client-3c7d51998a1033da8c173f082b2bfbd5503fb034d898b464250acf0907531759", + "name": "prisma-client-7a649551001de7cf62f62fb400ccb23bcaaa84f13014894b825c7925382d2c03", "main": "index.js", "types": "index.d.ts", "browser": "default.js", diff --git a/prisma/generated/client/schema.prisma b/prisma/generated/client/schema.prisma index c820341..40f5780 100644 --- a/prisma/generated/client/schema.prisma +++ b/prisma/generated/client/schema.prisma @@ -82,6 +82,7 @@ model member { @@index([status, handleLower]) @@index([lastLoginDate]) @@index([availableForGigs]) + @@index([status, availableForGigs]) @@schema("members") } @@ -104,6 +105,7 @@ model memberAddress { @@index([userId]) @@index([userId, type]) + @@index([userId, type, id(sort: Desc)], map: "idx_member_address_user_type") @@schema("members") } diff --git a/prisma/generated/client/wasm.js b/prisma/generated/client/wasm.js index 540a6c5..e0e5e52 100644 --- a/prisma/generated/client/wasm.js +++ b/prisma/generated/client/wasm.js @@ -527,8 +527,8 @@ const config = { } } }, - "inlineSchema": "generator client {\n provider = \"prisma-client-js\"\n // Generate a package-scoped client to avoid monorepo conflicts\n output = \"./generated/client\"\n}\n\ndatasource db {\n provider = \"postgresql\"\n url = env(\"DATABASE_URL\")\n schemas = [\"members\"]\n}\n\nenum MemberStatus {\n UNVERIFIED\n ACTIVE\n INACTIVE_USER_REQUEST\n INACTIVE_DUPLICATE_ACCOUNT\n INACTIVE_IRREGULAR_ACCOUNT\n UNKNOWN\n\n @@schema(\"members\")\n}\n\nenum FinancialStatus {\n PENDING\n PAID\n FAILED\n CANCELLED\n\n @@schema(\"members\")\n}\n\nmodel member {\n userId BigInt @id\n handle String\n handleLower String @unique\n email String @unique\n verified Boolean?\n skillScore Float?\n memberRatingId BigInt?\n maxRating memberMaxRating?\n firstName String?\n lastName String?\n description String?\n otherLangName String?\n status MemberStatus?\n newEmail String?\n emailVerifyToken String?\n emailVerifyTokenDate DateTime?\n newEmailVerifyToken String?\n newEmailVerifyTokenDate DateTime?\n addresses memberAddress[]\n phones memberPhone[]\n\n country String?\n homeCountryCode String?\n competitionCountryCode String?\n photoURL String?\n tracks String[]\n loginCount Int?\n lastLoginDate DateTime?\n availableForGigs Boolean?\n availableForGigsLastUpdateDate DateTime?\n lastProfileConfirmationDate DateTime?\n skillScoreDeduction Float?\n namesAndHandleAppearance String?\n aggregatedSkills Json?\n enteredSkills Json?\n\n financial memberFinancial?\n memberStats memberStats[]\n memberStatsHistory memberStatsHistory[]\n memberTraits memberTraits?\n\n createdAt DateTime @default(now())\n createdBy String\n updatedAt DateTime? @updatedAt\n updatedBy String?\n\n @@index([handleLower])\n @@index([email])\n @@index([status, handleLower])\n @@index([lastLoginDate])\n @@index([availableForGigs])\n @@schema(\"members\")\n}\n\nmodel memberAddress {\n id BigInt @id @default(autoincrement())\n userId BigInt\n streetAddr1 String?\n streetAddr2 String?\n city String?\n zip String?\n stateCode String?\n type String\n\n member member @relation(fields: [userId], references: [userId], onDelete: Cascade)\n\n createdAt DateTime @default(now())\n createdBy String\n updatedAt DateTime? @updatedAt\n updatedBy String?\n\n @@index([userId])\n @@index([userId, type])\n @@schema(\"members\")\n}\n\nmodel memberPhone {\n id String @id @default(uuid())\n userId BigInt\n type String\n number String\n\n member member @relation(fields: [userId], references: [userId], onDelete: Cascade)\n\n createdAt DateTime @default(now())\n createdBy String\n updatedAt DateTime? @updatedAt\n updatedBy String?\n\n @@unique([userId, number])\n @@index([userId])\n @@schema(\"members\")\n}\n\nmodel memberMaxRating {\n id BigInt @id @default(autoincrement())\n userId BigInt\n rating Int\n track String?\n subTrack String?\n ratingColor String\n\n createdAt DateTime @default(now())\n createdBy String\n updatedAt DateTime? @updatedAt\n updatedBy String?\n\n member member @relation(fields: [userId], references: [userId], onDelete: Cascade)\n\n @@unique([userId])\n @@index([userId])\n @@schema(\"members\")\n}\n\nmodel memberFinancial {\n userId BigInt @id\n amount Float\n status FinancialStatus\n\n member member @relation(fields: [userId], references: [userId], onDelete: Cascade)\n\n createdAt DateTime @default(now())\n createdBy String\n updatedAt DateTime? @updatedAt\n updatedBy String?\n\n @@schema(\"members\")\n}\n\nmodel memberStatsHistory {\n id BigInt @id @default(autoincrement())\n userId BigInt\n trackId String\n typeId String\n challengeId String\n mostRecent Boolean @default(false)\n oldRating Int?\n newRating Int?\n placement Int?\n percentile Float?\n oldVolatility Int?\n newVolatility Int?\n oldGlobalRank Int?\n newGlobalRank Int?\n oldCountryRank Int?\n newCountryRank Int?\n oldSchoolRank Int?\n newSchoolRank Int?\n eventDate DateTime\n\n member member @relation(fields: [userId], references: [userId], onDelete: Cascade)\n\n createdAt DateTime @default(now())\n createdBy String\n updatedAt DateTime? @updatedAt\n updatedBy String?\n\n @@index([userId])\n @@index([userId, trackId, typeId])\n @@index([userId, trackId, typeId, mostRecent])\n @@index([challengeId])\n @@index([eventDate])\n @@schema(\"members\")\n}\n\nmodel memberStats {\n id BigInt @id @default(autoincrement())\n userId BigInt\n trackId String?\n typeId String?\n challenges Int?\n wins Int?\n mostRecentSubmission DateTime?\n mostRecentEventDate DateTime?\n rating Int?\n avgRank Float?\n avgNumSubmissions Int?\n bestRank Int?\n globalRank Int?\n countryRank Int?\n schoolRank Int?\n volatility Int?\n maxRating Int?\n minRating Int?\n topFiveFinishes Int?\n topTenFinishes Int?\n isPrivate Boolean @default(false)\n\n member member @relation(fields: [userId], references: [userId], onDelete: Cascade)\n\n createdAt DateTime @default(now())\n createdBy String\n updatedAt DateTime? @updatedAt\n updatedBy String?\n\n @@unique([userId, trackId, typeId])\n @@index([userId])\n @@index([trackId])\n @@index([typeId])\n @@index([trackId, typeId, rating])\n @@index([userId, trackId, typeId])\n @@index([globalRank])\n @@index([countryRank])\n @@index([schoolRank])\n @@schema(\"members\")\n}\n\nmodel memberTraits {\n id BigInt @id @default(autoincrement())\n userId BigInt\n\n device memberTraitDevice[]\n software memberTraitSoftware[]\n serviceProvider memberTraitServiceProvider[]\n subscriptions String[]\n hobby String[]\n work memberTraitWork[]\n education memberTraitEducation[]\n basicInfo memberTraitBasicInfo[]\n language memberTraitLanguage[]\n checklist memberTraitOnboardChecklist[]\n personalization memberTraitPersonalization[]\n community memberTraitCommunity[]\n\n member member @relation(fields: [userId], references: [userId])\n\n createdAt DateTime @default(now())\n createdBy String\n updatedAt DateTime? @updatedAt\n updatedBy String?\n\n @@unique([userId])\n @@index([userId])\n @@schema(\"members\")\n}\n\nenum DeviceType {\n Console\n Desktop\n Laptop\n Smartphone\n Tablet\n Wearable\n Other\n\n @@schema(\"members\")\n}\n\nmodel memberTraitDevice {\n id BigInt @id @default(autoincrement())\n\n memberTraitId BigInt\n\n deviceType DeviceType\n manufacturer String\n model String\n operatingSystem String\n osVersion String?\n osLanguage String?\n\n memberTraits memberTraits @relation(fields: [memberTraitId], references: [id], onDelete: Cascade)\n\n createdAt DateTime @default(now())\n createdBy String\n updatedAt DateTime? @updatedAt\n updatedBy String?\n\n @@index([memberTraitId])\n @@schema(\"members\")\n}\n\nenum SoftwareType {\n DeveloperTools\n Browser\n Productivity\n GraphAndDesign\n Utilities\n\n @@schema(\"members\")\n}\n\nmodel memberTraitSoftware {\n id BigInt @id @default(autoincrement())\n\n memberTraitId BigInt\n\n softwareType SoftwareType\n name String\n\n memberTraits memberTraits @relation(fields: [memberTraitId], references: [id], onDelete: Cascade)\n\n createdAt DateTime @default(now())\n createdBy String\n updatedAt DateTime? @updatedAt\n updatedBy String?\n\n @@index([memberTraitId])\n @@schema(\"members\")\n}\n\nenum ServiceProviderType {\n InternetServiceProvider\n MobileCarrier\n Television\n FinancialInstitution\n Other\n\n @@schema(\"members\")\n}\n\nmodel memberTraitServiceProvider {\n id BigInt @id @default(autoincrement())\n\n memberTraitId BigInt\n\n type ServiceProviderType\n name String\n\n memberTraits memberTraits @relation(fields: [memberTraitId], references: [id], onDelete: Cascade)\n\n createdAt DateTime @default(now())\n createdBy String\n updatedAt DateTime? @updatedAt\n updatedBy String?\n\n @@index([memberTraitId])\n @@schema(\"members\")\n}\n\nenum WorkIndustryType {\n Banking\n ConsumerGoods\n Energy\n Entertainment\n HealthCare\n Pharma\n PublicSector\n TechAndTechnologyService\n Telecoms\n TravelAndHospitality\n Other\n\n @@schema(\"members\")\n}\n\nmodel memberTraitWork {\n id BigInt @id @default(autoincrement())\n\n memberTraitId BigInt\n\n industry WorkIndustryType?\n otherIndustry String?\n companyName String\n position String\n startDate DateTime?\n endDate DateTime?\n working Boolean?\n description String?\n associatedSkills String[]\n\n memberTraits memberTraits @relation(fields: [memberTraitId], references: [id], onDelete: Cascade)\n\n createdAt DateTime @default(now())\n createdBy String\n updatedAt DateTime? @updatedAt\n updatedBy String?\n\n @@index([memberTraitId])\n @@schema(\"members\")\n}\n\nmodel memberTraitEducation {\n id BigInt @id @default(autoincrement())\n\n memberTraitId BigInt\n\n collegeName String\n degree String\n endYear Int?\n\n memberTraits memberTraits @relation(fields: [memberTraitId], references: [id], onDelete: Cascade)\n\n createdAt DateTime @default(now())\n createdBy String\n updatedAt DateTime? @updatedAt\n updatedBy String?\n\n @@index([memberTraitId])\n @@schema(\"members\")\n}\n\nmodel memberTraitBasicInfo {\n id BigInt @id @default(autoincrement())\n\n memberTraitId BigInt\n\n userId BigInt\n country String\n primaryInterestInTopcoder String\n tshirtSize String?\n gender String?\n shortBio String\n birthDate DateTime?\n currentLocation String?\n\n memberTraits memberTraits @relation(fields: [memberTraitId], references: [id], onDelete: Cascade)\n\n createdAt DateTime @default(now())\n createdBy String\n updatedAt DateTime? @updatedAt\n updatedBy String?\n\n @@unique([memberTraitId])\n @@index([memberTraitId])\n @@schema(\"members\")\n}\n\nmodel memberTraitLanguage {\n id BigInt @id @default(autoincrement())\n\n memberTraitId BigInt\n\n language String\n spokenLevel String?\n writtenLevel String?\n\n memberTraits memberTraits @relation(fields: [memberTraitId], references: [id], onDelete: Cascade)\n\n createdAt DateTime @default(now())\n createdBy String\n updatedAt DateTime? @updatedAt\n updatedBy String?\n\n @@index([memberTraitId])\n @@schema(\"members\")\n}\n\n// This model is used to send messages when user login. When profile not complete, it will show up.\nmodel memberTraitOnboardChecklist {\n id BigInt @id @default(autoincrement())\n\n memberTraitId BigInt\n\n listItemType String // Like 'profile_completed'\n date DateTime\n message String\n status String\n skip Boolean?\n metadata Json?\n\n memberTraits memberTraits @relation(fields: [memberTraitId], references: [id], onDelete: Cascade)\n\n createdAt DateTime @default(now())\n createdBy String\n updatedAt DateTime? @updatedAt\n updatedBy String?\n\n @@index([memberTraitId])\n @@schema(\"members\")\n}\n\nmodel memberTraitPersonalization {\n id BigInt @id @default(autoincrement())\n\n memberTraitId BigInt\n\n key String?\n value Json?\n private Boolean @default(false)\n\n memberTraits memberTraits @relation(fields: [memberTraitId], references: [id], onDelete: Cascade)\n\n createdAt DateTime @default(now())\n createdBy String\n updatedAt DateTime? @updatedAt\n updatedBy String?\n\n @@index([memberTraitId])\n @@index([memberTraitId, key], map: \"memberTraitPersonalization_memberTraitId_key_idx\")\n @@schema(\"members\")\n}\n\nmodel memberTraitCommunity {\n id BigInt @id @default(autoincrement())\n\n memberTraitId BigInt\n\n communityName String\n status Boolean\n\n memberTraits memberTraits @relation(fields: [memberTraitId], references: [id], onDelete: Cascade)\n\n createdAt DateTime @default(now())\n createdBy String\n updatedAt DateTime? @updatedAt\n updatedBy String?\n\n @@index([memberTraitId])\n @@schema(\"members\")\n}\n", - "inlineSchemaHash": "3b941175f5c4e897804e0ac45e1f2d1a22ce34ad478dc7c1c4ee21efe2886192", + "inlineSchema": "generator client {\n provider = \"prisma-client-js\"\n // Generate a package-scoped client to avoid monorepo conflicts\n output = \"./generated/client\"\n}\n\ndatasource db {\n provider = \"postgresql\"\n url = env(\"DATABASE_URL\")\n schemas = [\"members\"]\n}\n\nenum MemberStatus {\n UNVERIFIED\n ACTIVE\n INACTIVE_USER_REQUEST\n INACTIVE_DUPLICATE_ACCOUNT\n INACTIVE_IRREGULAR_ACCOUNT\n UNKNOWN\n\n @@schema(\"members\")\n}\n\nenum FinancialStatus {\n PENDING\n PAID\n FAILED\n CANCELLED\n\n @@schema(\"members\")\n}\n\nmodel member {\n userId BigInt @id\n handle String\n handleLower String @unique\n email String @unique\n verified Boolean?\n skillScore Float?\n memberRatingId BigInt?\n maxRating memberMaxRating?\n firstName String?\n lastName String?\n description String?\n otherLangName String?\n status MemberStatus?\n newEmail String?\n emailVerifyToken String?\n emailVerifyTokenDate DateTime?\n newEmailVerifyToken String?\n newEmailVerifyTokenDate DateTime?\n addresses memberAddress[]\n phones memberPhone[]\n\n country String?\n homeCountryCode String?\n competitionCountryCode String?\n photoURL String?\n tracks String[]\n loginCount Int?\n lastLoginDate DateTime?\n availableForGigs Boolean?\n availableForGigsLastUpdateDate DateTime?\n lastProfileConfirmationDate DateTime?\n skillScoreDeduction Float?\n namesAndHandleAppearance String?\n aggregatedSkills Json?\n enteredSkills Json?\n\n financial memberFinancial?\n memberStats memberStats[]\n memberStatsHistory memberStatsHistory[]\n memberTraits memberTraits?\n\n createdAt DateTime @default(now())\n createdBy String\n updatedAt DateTime? @updatedAt\n updatedBy String?\n\n @@index([handleLower])\n @@index([email])\n @@index([status, handleLower])\n @@index([lastLoginDate])\n @@index([availableForGigs])\n @@index([status, availableForGigs])\n @@schema(\"members\")\n}\n\nmodel memberAddress {\n id BigInt @id @default(autoincrement())\n userId BigInt\n streetAddr1 String?\n streetAddr2 String?\n city String?\n zip String?\n stateCode String?\n type String\n\n member member @relation(fields: [userId], references: [userId], onDelete: Cascade)\n\n createdAt DateTime @default(now())\n createdBy String\n updatedAt DateTime? @updatedAt\n updatedBy String?\n\n @@index([userId])\n @@index([userId, type])\n @@index([userId, type, id(sort: Desc)], map: \"idx_member_address_user_type\")\n @@schema(\"members\")\n}\n\nmodel memberPhone {\n id String @id @default(uuid())\n userId BigInt\n type String\n number String\n\n member member @relation(fields: [userId], references: [userId], onDelete: Cascade)\n\n createdAt DateTime @default(now())\n createdBy String\n updatedAt DateTime? @updatedAt\n updatedBy String?\n\n @@unique([userId, number])\n @@index([userId])\n @@schema(\"members\")\n}\n\nmodel memberMaxRating {\n id BigInt @id @default(autoincrement())\n userId BigInt\n rating Int\n track String?\n subTrack String?\n ratingColor String\n\n createdAt DateTime @default(now())\n createdBy String\n updatedAt DateTime? @updatedAt\n updatedBy String?\n\n member member @relation(fields: [userId], references: [userId], onDelete: Cascade)\n\n @@unique([userId])\n @@index([userId])\n @@schema(\"members\")\n}\n\nmodel memberFinancial {\n userId BigInt @id\n amount Float\n status FinancialStatus\n\n member member @relation(fields: [userId], references: [userId], onDelete: Cascade)\n\n createdAt DateTime @default(now())\n createdBy String\n updatedAt DateTime? @updatedAt\n updatedBy String?\n\n @@schema(\"members\")\n}\n\nmodel memberStatsHistory {\n id BigInt @id @default(autoincrement())\n userId BigInt\n trackId String\n typeId String\n challengeId String\n mostRecent Boolean @default(false)\n oldRating Int?\n newRating Int?\n placement Int?\n percentile Float?\n oldVolatility Int?\n newVolatility Int?\n oldGlobalRank Int?\n newGlobalRank Int?\n oldCountryRank Int?\n newCountryRank Int?\n oldSchoolRank Int?\n newSchoolRank Int?\n eventDate DateTime\n\n member member @relation(fields: [userId], references: [userId], onDelete: Cascade)\n\n createdAt DateTime @default(now())\n createdBy String\n updatedAt DateTime? @updatedAt\n updatedBy String?\n\n @@index([userId])\n @@index([userId, trackId, typeId])\n @@index([userId, trackId, typeId, mostRecent])\n @@index([challengeId])\n @@index([eventDate])\n @@schema(\"members\")\n}\n\nmodel memberStats {\n id BigInt @id @default(autoincrement())\n userId BigInt\n trackId String?\n typeId String?\n challenges Int?\n wins Int?\n mostRecentSubmission DateTime?\n mostRecentEventDate DateTime?\n rating Int?\n avgRank Float?\n avgNumSubmissions Int?\n bestRank Int?\n globalRank Int?\n countryRank Int?\n schoolRank Int?\n volatility Int?\n maxRating Int?\n minRating Int?\n topFiveFinishes Int?\n topTenFinishes Int?\n isPrivate Boolean @default(false)\n\n member member @relation(fields: [userId], references: [userId], onDelete: Cascade)\n\n createdAt DateTime @default(now())\n createdBy String\n updatedAt DateTime? @updatedAt\n updatedBy String?\n\n @@unique([userId, trackId, typeId])\n @@index([userId])\n @@index([trackId])\n @@index([typeId])\n @@index([trackId, typeId, rating])\n @@index([userId, trackId, typeId])\n @@index([globalRank])\n @@index([countryRank])\n @@index([schoolRank])\n @@schema(\"members\")\n}\n\nmodel memberTraits {\n id BigInt @id @default(autoincrement())\n userId BigInt\n\n device memberTraitDevice[]\n software memberTraitSoftware[]\n serviceProvider memberTraitServiceProvider[]\n subscriptions String[]\n hobby String[]\n work memberTraitWork[]\n education memberTraitEducation[]\n basicInfo memberTraitBasicInfo[]\n language memberTraitLanguage[]\n checklist memberTraitOnboardChecklist[]\n personalization memberTraitPersonalization[]\n community memberTraitCommunity[]\n\n member member @relation(fields: [userId], references: [userId])\n\n createdAt DateTime @default(now())\n createdBy String\n updatedAt DateTime? @updatedAt\n updatedBy String?\n\n @@unique([userId])\n @@index([userId])\n @@schema(\"members\")\n}\n\nenum DeviceType {\n Console\n Desktop\n Laptop\n Smartphone\n Tablet\n Wearable\n Other\n\n @@schema(\"members\")\n}\n\nmodel memberTraitDevice {\n id BigInt @id @default(autoincrement())\n\n memberTraitId BigInt\n\n deviceType DeviceType\n manufacturer String\n model String\n operatingSystem String\n osVersion String?\n osLanguage String?\n\n memberTraits memberTraits @relation(fields: [memberTraitId], references: [id], onDelete: Cascade)\n\n createdAt DateTime @default(now())\n createdBy String\n updatedAt DateTime? @updatedAt\n updatedBy String?\n\n @@index([memberTraitId])\n @@schema(\"members\")\n}\n\nenum SoftwareType {\n DeveloperTools\n Browser\n Productivity\n GraphAndDesign\n Utilities\n\n @@schema(\"members\")\n}\n\nmodel memberTraitSoftware {\n id BigInt @id @default(autoincrement())\n\n memberTraitId BigInt\n\n softwareType SoftwareType\n name String\n\n memberTraits memberTraits @relation(fields: [memberTraitId], references: [id], onDelete: Cascade)\n\n createdAt DateTime @default(now())\n createdBy String\n updatedAt DateTime? @updatedAt\n updatedBy String?\n\n @@index([memberTraitId])\n @@schema(\"members\")\n}\n\nenum ServiceProviderType {\n InternetServiceProvider\n MobileCarrier\n Television\n FinancialInstitution\n Other\n\n @@schema(\"members\")\n}\n\nmodel memberTraitServiceProvider {\n id BigInt @id @default(autoincrement())\n\n memberTraitId BigInt\n\n type ServiceProviderType\n name String\n\n memberTraits memberTraits @relation(fields: [memberTraitId], references: [id], onDelete: Cascade)\n\n createdAt DateTime @default(now())\n createdBy String\n updatedAt DateTime? @updatedAt\n updatedBy String?\n\n @@index([memberTraitId])\n @@schema(\"members\")\n}\n\nenum WorkIndustryType {\n Banking\n ConsumerGoods\n Energy\n Entertainment\n HealthCare\n Pharma\n PublicSector\n TechAndTechnologyService\n Telecoms\n TravelAndHospitality\n Other\n\n @@schema(\"members\")\n}\n\nmodel memberTraitWork {\n id BigInt @id @default(autoincrement())\n\n memberTraitId BigInt\n\n industry WorkIndustryType?\n otherIndustry String?\n companyName String\n position String\n startDate DateTime?\n endDate DateTime?\n working Boolean?\n description String?\n associatedSkills String[]\n\n memberTraits memberTraits @relation(fields: [memberTraitId], references: [id], onDelete: Cascade)\n\n createdAt DateTime @default(now())\n createdBy String\n updatedAt DateTime? @updatedAt\n updatedBy String?\n\n @@index([memberTraitId])\n @@schema(\"members\")\n}\n\nmodel memberTraitEducation {\n id BigInt @id @default(autoincrement())\n\n memberTraitId BigInt\n\n collegeName String\n degree String\n endYear Int?\n\n memberTraits memberTraits @relation(fields: [memberTraitId], references: [id], onDelete: Cascade)\n\n createdAt DateTime @default(now())\n createdBy String\n updatedAt DateTime? @updatedAt\n updatedBy String?\n\n @@index([memberTraitId])\n @@schema(\"members\")\n}\n\nmodel memberTraitBasicInfo {\n id BigInt @id @default(autoincrement())\n\n memberTraitId BigInt\n\n userId BigInt\n country String\n primaryInterestInTopcoder String\n tshirtSize String?\n gender String?\n shortBio String\n birthDate DateTime?\n currentLocation String?\n\n memberTraits memberTraits @relation(fields: [memberTraitId], references: [id], onDelete: Cascade)\n\n createdAt DateTime @default(now())\n createdBy String\n updatedAt DateTime? @updatedAt\n updatedBy String?\n\n @@unique([memberTraitId])\n @@index([memberTraitId])\n @@schema(\"members\")\n}\n\nmodel memberTraitLanguage {\n id BigInt @id @default(autoincrement())\n\n memberTraitId BigInt\n\n language String\n spokenLevel String?\n writtenLevel String?\n\n memberTraits memberTraits @relation(fields: [memberTraitId], references: [id], onDelete: Cascade)\n\n createdAt DateTime @default(now())\n createdBy String\n updatedAt DateTime? @updatedAt\n updatedBy String?\n\n @@index([memberTraitId])\n @@schema(\"members\")\n}\n\n// This model is used to send messages when user login. When profile not complete, it will show up.\nmodel memberTraitOnboardChecklist {\n id BigInt @id @default(autoincrement())\n\n memberTraitId BigInt\n\n listItemType String // Like 'profile_completed'\n date DateTime\n message String\n status String\n skip Boolean?\n metadata Json?\n\n memberTraits memberTraits @relation(fields: [memberTraitId], references: [id], onDelete: Cascade)\n\n createdAt DateTime @default(now())\n createdBy String\n updatedAt DateTime? @updatedAt\n updatedBy String?\n\n @@index([memberTraitId])\n @@schema(\"members\")\n}\n\nmodel memberTraitPersonalization {\n id BigInt @id @default(autoincrement())\n\n memberTraitId BigInt\n\n key String?\n value Json?\n private Boolean @default(false)\n\n memberTraits memberTraits @relation(fields: [memberTraitId], references: [id], onDelete: Cascade)\n\n createdAt DateTime @default(now())\n createdBy String\n updatedAt DateTime? @updatedAt\n updatedBy String?\n\n @@index([memberTraitId])\n @@index([memberTraitId, key], map: \"memberTraitPersonalization_memberTraitId_key_idx\")\n @@schema(\"members\")\n}\n\nmodel memberTraitCommunity {\n id BigInt @id @default(autoincrement())\n\n memberTraitId BigInt\n\n communityName String\n status Boolean\n\n memberTraits memberTraits @relation(fields: [memberTraitId], references: [id], onDelete: Cascade)\n\n createdAt DateTime @default(now())\n createdBy String\n updatedAt DateTime? @updatedAt\n updatedBy String?\n\n @@index([memberTraitId])\n @@schema(\"members\")\n}\n", + "inlineSchemaHash": "a4dae9404f7092f693d59206e2da0630cb1395ad7109841bc19141c7d9faeb6c", "copyEngine": true } config.dirname = '/' diff --git a/src/common/prisma.js b/src/common/prisma.js index 28e187e..649c126 100644 --- a/src/common/prisma.js +++ b/src/common/prisma.js @@ -37,7 +37,7 @@ const extractSchemaFromUrl = (dbUrl) => { } const skillsDbUrl = process.env.SKILLS_DB_URL -const challengesDbUrl = process.env.CHALLENGES_DB_URL +const challengesDbUrl = process.env.CHALLENGES_DB_URL || process.env.CHALLENGE_DB_URL const academyDbUrl = process.env.ACADEMY_DB_URL const resourcesDbUrl = process.env.RESOURCES_DB_URL const engagementsDbUrl = process.env.ENGAGEMENTS_DB_URL @@ -94,7 +94,7 @@ const getChallengesClient = () => { if (!challengesClient) { if (!challengesDbUrl) { throw new Error( - 'CHALLENGES_DB_URL must be set for challenges Prisma client' + 'CHALLENGES_DB_URL or CHALLENGE_DB_URL must be set for challenges Prisma client' ) } challengesClient = new ChallengesPrismaClient({ diff --git a/src/common/prismaHelper.js b/src/common/prismaHelper.js index 2b16d68..614f9eb 100644 --- a/src/common/prismaHelper.js +++ b/src/common/prismaHelper.js @@ -83,6 +83,47 @@ function mergeTrackCounters (trackItem, stat) { } } +/** + * Merge duplicate unified stats items that normalize to the same API bucket. + * Counters are additive, latest activity dates win, and the latest non-empty + * rank snapshot is retained so stale aggregate rows cannot erase ratings. + * @param {Object|undefined} existingItem previously accumulated response item + * @param {Object} nextItem next response item for the same track/type bucket + * @returns {Object} merged response item for use in member stats responses + */ +function mergeUnifiedStatsItem (existingItem, nextItem) { + if (!existingItem) { + return nextItem + } + + const existingEventDate = toNumber(existingItem.mostRecentEventDate) + const nextEventDate = toNumber(nextItem.mostRecentEventDate) + const existingRank = existingItem.rank || {} + const nextRank = nextItem.rank || {} + const useNextRank = !_.isEmpty(nextRank) && + (_.isEmpty(existingRank) || nextEventDate >= existingEventDate) + const rank = useNextRank ? nextRank : existingRank + const mostRecentEventDate = Math.max(existingEventDate, nextEventDate) || null + const mostRecentSubmission = Math.max( + toNumber(existingItem.mostRecentSubmission), + toNumber(nextItem.mostRecentSubmission) + ) || null + const mostRecentEventName = nextEventDate >= existingEventDate + ? (nextItem.mostRecentEventName || existingItem.mostRecentEventName) + : (existingItem.mostRecentEventName || nextItem.mostRecentEventName) + + return _.omitBy({ + ...existingItem, + ...nextItem, + challenges: toNumber(existingItem.challenges) + toNumber(nextItem.challenges), + wins: toNumber(existingItem.wins) + toNumber(nextItem.wins), + mostRecentSubmission, + mostRecentEventDate, + mostRecentEventName, + rank + }, _.isNil) +} + /** * Build the maxRating response object while recomputing the rating color from * the canonical color-band helper instead of trusting persisted color data. @@ -574,7 +615,7 @@ function buildUnifiedStatsResponse (member, statsData, fields) { minimumRating: row.minRating }, _.isNil) } - item.DATA_SCIENCE.SRM = srmItem + item.DATA_SCIENCE.SRM = mergeUnifiedStatsItem(item.DATA_SCIENCE.SRM, srmItem) } else if (typeName === 'MARATHON_MATCH') { const marathonItem = { challenges: toNumber(row.challenges), @@ -596,9 +637,9 @@ function buildUnifiedStatsResponse (member, statsData, fields) { topTenFinishes: row.topTenFinishes }, _.isNil) } - item.DATA_SCIENCE.MARATHON_MATCH = marathonItem + item.DATA_SCIENCE.MARATHON_MATCH = mergeUnifiedStatsItem(item.DATA_SCIENCE.MARATHON_MATCH, marathonItem) } else if (typeName) { - item.DATA_SCIENCE[typeName] = { + const dataScienceItem = { challenges: toNumber(row.challenges), wins: toNumber(row.wins), mostRecentSubmission: toUnixTime(row.mostRecentSubmission), @@ -618,6 +659,7 @@ function buildUnifiedStatsResponse (member, statsData, fields) { topTenFinishes: row.topTenFinishes }, _.isNil) } + item.DATA_SCIENCE[typeName] = mergeUnifiedStatsItem(item.DATA_SCIENCE[typeName], dataScienceItem) } } else if (trackName === 'COPILOT') { item.COPILOT = _.omitBy({ diff --git a/src/ratings/developRatingEngine.js b/src/ratings/developRatingEngine.js index be79a29..9ad9727 100644 --- a/src/ratings/developRatingEngine.js +++ b/src/ratings/developRatingEngine.js @@ -12,6 +12,7 @@ const errors = require('../common/errors') const { resolveChallengeResultRelation } = require('../common/reviewDbHelper') const { + TYPE_NAMES, loadChallengeDimensionLookup, resolveTrackIdFromLookup, resolveTypeIdFromLookup @@ -25,7 +26,7 @@ const { const TRACK_NAME = 'DEVELOP' const TYPE_NAME = 'Challenge' const CHALLENGE_TRACK_NAME = 'DEVELOPMENT' -const CHALLENGE_TYPE_NAME = 'Challenge' +const CHALLENGE_TYPE_NAMES = [TYPE_NAMES.CHALLENGE, TYPE_NAMES.CODE] const RERATE_ACTOR = 'rerate-member-stats' function isBigIntValue (value) { @@ -64,6 +65,106 @@ function normalizeChallengeDimension (value) { .replace(/[\s-]+/g, '_') } +/** + * Add one non-empty challenge id candidate to the supplied set. + * @param {Set} candidates mutable challenge id candidate set + * @param {*} value raw challenge id candidate + * @returns {void} + */ +function addChallengeIdCandidate (candidates, value) { + if (value === null || value === undefined) { + return + } + + const normalized = String(value).trim() + if (normalized) { + candidates.add(normalized) + } +} + +/** + * Build every review-api challenge id that can identify a challenge. + * Challenge-api stores canonical UUID ids, while historical review rows may + * still use legacy numeric ids. + * @param {*} challengeRef challenge id, challenge metadata row, or history entry + * @returns {Array} unique challenge id candidates + */ +function buildChallengeIdCandidates (challengeRef) { + const candidates = new Set() + + if (Array.isArray(challengeRef)) { + challengeRef.forEach((candidate) => addChallengeIdCandidate(candidates, candidate)) + return Array.from(candidates) + } + + if (challengeRef && typeof challengeRef === 'object' && !(challengeRef instanceof Date)) { + addChallengeIdCandidate(candidates, challengeRef.challengeId) + addChallengeIdCandidate(candidates, challengeRef.id) + addChallengeIdCandidate(candidates, challengeRef.legacyId) + + if (Array.isArray(challengeRef.reviewChallengeIds)) { + challengeRef.reviewChallengeIds.forEach((candidate) => addChallengeIdCandidate(candidates, candidate)) + } + if (Array.isArray(challengeRef.challengeIds)) { + challengeRef.challengeIds.forEach((candidate) => addChallengeIdCandidate(candidates, candidate)) + } + if (Array.isArray(challengeRef.challengeIdCandidates)) { + challengeRef.challengeIdCandidates.forEach((candidate) => addChallengeIdCandidate(candidates, candidate)) + } + if (challengeRef.legacyRecord) { + addChallengeIdCandidate(candidates, challengeRef.legacyRecord.legacySystemId) + } + + return Array.from(candidates) + } + + addChallengeIdCandidate(candidates, challengeRef) + return Array.from(candidates) +} + +/** + * Merge challenge metadata aliases with the source id observed in review-api. + * @param {Object} challenge challenge-api metadata row + * @param {*} [sourceChallengeId] challenge id from the review-api row + * @returns {Array} unique review challenge id candidates + */ +function buildReviewChallengeIds (challenge, sourceChallengeId) { + const candidates = new Set() + addChallengeIdCandidate(candidates, sourceChallengeId) + buildChallengeIdCandidates(challenge).forEach((candidate) => candidates.add(candidate)) + return Array.from(candidates) +} + +/** + * Test whether a history entry can be addressed by the supplied challenge id. + * @param {Object} historyEntry rerate history entry + * @param {*} challengeId requested challenge id + * @returns {boolean} true when the entry has the challenge id as an alias + */ +function historyEntryMatchesChallengeId (historyEntry, challengeId) { + const normalizedChallengeId = String(challengeId).trim() + return buildChallengeIdCandidates(historyEntry).includes(normalizedChallengeId) +} + +/** + * Resolve whether challenge metadata belongs to the Development Challenge rating stream. + * Development CODE challenge rows are rated into the same DEVELOP / Challenge + * stream as standard Development Challenge rows. + * @param {Object} challenge challenge metadata record + * @returns {boolean} true when the challenge should be replayed by this engine + */ +function isDevelopmentRatingChallenge (challenge) { + if (!challenge || !challenge.track || !challenge.type) { + return false + } + + const normalizedTrackName = normalizeChallengeDimension(challenge.track.name) + const normalizedTypeName = normalizeChallengeDimension(challenge.type.name) + const supportedTypeNames = CHALLENGE_TYPE_NAMES.map(normalizeChallengeDimension) + + return normalizedTrackName === CHALLENGE_TRACK_NAME && supportedTypeNames.includes(normalizedTypeName) +} + function createDefaultState () { return { rating: 0, @@ -265,16 +366,22 @@ async function fetchReviewResultsForUser (reviewDbClient, userId) { return result.rows } -async function fetchParticipantsForChallenge (reviewDbClient, challengeId) { +async function fetchParticipantsForChallenge (reviewDbClient, challengeRef) { + const challengeIds = buildChallengeIdCandidates(challengeRef) + if (challengeIds.length === 0) { + return [] + } + const challengeResultRelation = await resolveChallengeResultRelation(reviewDbClient) + const placeholders = challengeIds.map((_, index) => `$${index + 1}`).join(', ') const result = await reviewDbClient.query( ` SELECT "challengeId", "userId", "finalScore", "placement", "rated", "passedReview", "createdAt" FROM ${challengeResultRelation} - WHERE "challengeId" = $1 + WHERE "challengeId" IN (${placeholders}) ORDER BY "placement" ASC, "finalScore" DESC, "createdAt" ASC `, - [String(challengeId)] + challengeIds ) return result.rows @@ -289,18 +396,44 @@ async function fetchParticipantsForChallenge (reviewDbClient, challengeId) { * @returns {Promise>} challenge metadata keyed by challenge id */ async function fetchChallengeMetadataMap (challengeClient, challengeIds) { - if (!challengeIds || challengeIds.length === 0) { + const normalizedChallengeIds = buildChallengeIdCandidates(challengeIds) + if (normalizedChallengeIds.length === 0) { return new Map() } - const challenges = await challengeClient.challenge.findMany({ - where: { - id: { - in: challengeIds + const numericChallengeIds = normalizedChallengeIds + .filter((challengeId) => /^\d+$/.test(challengeId)) + .map((challengeId) => Number(challengeId)) + .filter(Number.isSafeInteger) + + const whereClauses = [{ + id: { + in: normalizedChallengeIds + } + }] + + if (numericChallengeIds.length > 0) { + whereClauses.push({ + legacyId: { + in: numericChallengeIds } - }, + }) + whereClauses.push({ + legacyRecord: { + is: { + legacySystemId: { + in: numericChallengeIds + } + } + } + }) + } + + const challenges = await challengeClient.challenge.findMany({ + where: whereClauses.length === 1 ? whereClauses[0] : { OR: whereClauses }, select: { id: true, + legacyId: true, endDate: true, track: { select: { @@ -312,15 +445,27 @@ async function fetchChallengeMetadataMap (challengeClient, challengeIds) { name: true } }, - metadata: RATING_METADATA_SELECT + metadata: RATING_METADATA_SELECT, + legacyRecord: { + select: { + legacySystemId: true + } + } } }) - return new Map(challenges.map((challenge) => [challenge.id, challenge])) + const metadataByChallengeId = new Map() + challenges.forEach((challenge) => { + buildReviewChallengeIds(challenge).forEach((candidate) => { + metadataByChallengeId.set(candidate, challenge) + }) + }) + + return metadataByChallengeId } function buildTargetHistory (reviewRows, challengeMetadataById) { - const history = [] + const historyByChallengeId = new Map() reviewRows.forEach((row) => { if (!isParticipantEligibleForRating(row)) { @@ -340,8 +485,7 @@ function buildTargetHistory (reviewRows, challengeMetadataById) { return } - if (normalizeChallengeDimension(challenge.track.name) !== CHALLENGE_TRACK_NAME || - normalizeChallengeDimension(challenge.type.name) !== normalizeChallengeDimension(CHALLENGE_TYPE_NAME)) { + if (!isDevelopmentRatingChallenge(challenge)) { return } @@ -350,14 +494,18 @@ function buildTargetHistory (reviewRows, challengeMetadataById) { return } - history.push({ - challengeId: String(row.challengeId), + const canonicalChallengeId = String(challenge.id) + historyByChallengeId.set(canonicalChallengeId, { + challengeId: canonicalChallengeId, + reviewChallengeIds: buildReviewChallengeIds(challenge, row.challengeId), createdAt: normalizeDate(row.createdAt, challenge.endDate), endDate: normalizeDate(challenge.endDate, row.createdAt), eventDate }) }) + const history = Array.from(historyByChallengeId.values()) + history.sort((left, right) => { const leftEventDate = left.eventDate ? left.eventDate.getTime() : 0 const rightEventDate = right.eventDate ? right.eventDate.getTime() : 0 @@ -687,7 +835,7 @@ async function rerateDevTrack (membersClient, challengeClient, reviewDbClient, u let startIndex = 0 if (fromChallengeId) { - startIndex = targetHistory.findIndex((entry) => entry.challengeId === String(fromChallengeId)) + startIndex = targetHistory.findIndex((entry) => historyEntryMatchesChallengeId(entry, fromChallengeId)) if (startIndex < 0) { throw new errors.BadRequestError(`Challenge ${fromChallengeId} is not a rated ${TRACK_NAME}/${TYPE_NAME} event for this member`) } @@ -716,7 +864,7 @@ async function rerateDevTrack (membersClient, challengeClient, reviewDbClient, u for (let index = startIndex; index < targetHistory.length; index += 1) { const historyEntry = targetHistory[index] - const participantRows = (await fetchParticipantsForChallenge(reviewDbClient, historyEntry.challengeId)) + const participantRows = (await fetchParticipantsForChallenge(reviewDbClient, historyEntry)) .filter((row) => isParticipantEligibleForRating(row)) if (participantRows.length === 0) { continue @@ -810,5 +958,9 @@ async function rerateDevTrack (membersClient, challengeClient, reviewDbClient, u } module.exports = { + buildChallengeIdCandidates, + buildReviewChallengeIds, + fetchParticipantsForChallenge, + isDevelopmentRatingChallenge, rerateDevTrack } diff --git a/src/scripts/recalculateMemberStats.js b/src/scripts/recalculateMemberStats.js index 295c91a..a9db623 100644 --- a/src/scripts/recalculateMemberStats.js +++ b/src/scripts/recalculateMemberStats.js @@ -6,7 +6,7 @@ * * Required environment variables: * - DATABASE_URL (member database) - * - CHALLENGES_DB_URL (challenge database) + * - CHALLENGES_DB_URL or CHALLENGE_DB_URL (challenge database) * - REVIEW_DB_URL (review database, required for challengeResult aggregates and rerates) * * Optional environment variables: @@ -3516,8 +3516,8 @@ async function main () { throw new Error('DATABASE_URL is required') } - if (!process.env.CHALLENGES_DB_URL) { - throw new Error('CHALLENGES_DB_URL is required') + if (!process.env.CHALLENGES_DB_URL && !process.env.CHALLENGE_DB_URL) { + throw new Error('CHALLENGES_DB_URL or CHALLENGE_DB_URL is required') } if (!reviewDb) { diff --git a/src/scripts/rerateDevelopmentChallenge.js b/src/scripts/rerateDevelopmentChallenge.js new file mode 100644 index 0000000..cc5cf2c --- /dev/null +++ b/src/scripts/rerateDevelopmentChallenge.js @@ -0,0 +1,757 @@ +#!/usr/bin/env node +'use strict' + +/** + * Bulk re-rate Development Challenge history for every discovered competitor. + * + * Required environment variables: + * - DATABASE_URL (member database) + * - CHALLENGES_DB_URL or CHALLENGE_DB_URL (challenge database) + * - REVIEW_DB_URL (review database with challengeResult data) + * + * Usage examples: + * - Dry-run discovery: + * node src/scripts/rerateDevelopmentChallenge.js --dry-run + * - Re-rate every discovered member from the start of their Development history: + * node src/scripts/rerateDevelopmentChallenge.js --concurrency 5 + * - Re-rate a bounded sample: + * node src/scripts/rerateDevelopmentChallenge.js --limit 100 + * - Re-rate specific users by userId: + * node src/scripts/rerateDevelopmentChallenge.js --user-id 12345 --user-ids 67890,24680 + * + * Notes: + * - Challenge discovery uses the Development ChallengeTrack id and includes both + * ChallengeType Challenge and CODE rows. + * - Existing CODE rows are rated into the same DEVELOP / Challenge stream used + * by the Development rating engine. + * - Handles are not required. Participants are discovered from review-api + * challengeResult rows. + * - Each member is replayed from the beginning by calling rerateDevTrack with no + * starting challenge id, so complete Development history is persisted. + */ + +require('dotenv').config() + +const fs = require('fs') +const path = require('path') + +const reviewDb = require('../common/reviewDb') +const { + getMembersClient, + getChallengesClient +} = require('../common/prisma') +const { + TRACK_NAMES, + TYPE_NAMES, + loadChallengeDimensionLookup, + resolveTrackIdFromLookup, + resolveTypeIdFromLookup +} = require('../common/statsDimensionHelper') +const { + RATING_METADATA_SELECT, + isChallengeRated +} = require('../ratings/challengeRatingStatus') +const { + buildReviewChallengeIds, + fetchParticipantsForChallenge, + rerateDevTrack +} = require('../ratings/developRatingEngine') + +const DEFAULT_CONCURRENCY = 4 +const DEFAULT_PROCESSED_USER_IDS_PATH = 'rerateDevelopmentChallenge.processedUserIds.json' +const COMPLETED_CHALLENGE_STATUS = 'COMPLETED' +const MEMBER_LOOKUP_BATCH_SIZE = 5000 + +/** + * Write an informational message with a timestamp for long-running operator logs. + * @param {string} message message to print + * @returns {void} + */ +function logInfo (message) { + console.log(`[INFO] ${new Date().toISOString()} ${message}`) +} + +/** + * Write an error message with a timestamp and optional stack/detail object. + * @param {string} message message to print + * @param {Error} [error] optional error object to include + * @returns {void} + */ +function logError (message, error) { + if (error) { + console.error(`[ERROR] ${new Date().toISOString()} ${message}`, error) + return + } + + console.error(`[ERROR] ${new Date().toISOString()} ${message}`) +} + +/** + * Capture a monotonic high-resolution timer origin. + * @returns {bigint} timer origin in nanoseconds + */ +function startTimer () { + return process.hrtime.bigint() +} + +/** + * Compute elapsed milliseconds since a timer origin. + * @param {bigint} startedAt timer origin returned by startTimer + * @returns {number} elapsed duration in milliseconds + */ +function getElapsedMilliseconds (startedAt) { + return Number(process.hrtime.bigint() - startedAt) / 1e6 +} + +/** + * Format a millisecond duration for operator-facing logs. + * @param {number} durationMs elapsed duration in milliseconds + * @returns {string} human-readable duration string + */ +function formatDuration (durationMs) { + if (!Number.isFinite(durationMs) || durationMs < 0) { + return 'n/a' + } + + if (durationMs < 1000) { + return `${Math.round(durationMs)}ms` + } + + const totalSeconds = Math.round(durationMs / 1000) + const minutes = Math.floor(totalSeconds / 60) + const seconds = totalSeconds % 60 + if (minutes === 0) { + return `${seconds}s` + } + + return `${minutes}m ${seconds}s` +} + +/** + * Normalize a date-like value with invalid values represented as null. + * @param {*} value raw date value + * @returns {Date|null} parsed Date or null + */ +function toDateOrNull (value) { + if (!value) { + return null + } + + const date = value instanceof Date ? value : new Date(value) + return Number.isNaN(date.getTime()) ? null : date +} + +/** + * Parse a comma-separated or single user ID option value. + * @param {*} value raw command-line option value + * @returns {Array} normalized user ID strings + */ +function parseUserIds (value) { + return String(value || '') + .split(',') + .map(userId => userId.trim()) + .filter(Boolean) +} + +/** + * Parse a positive integer command-line option. + * @param {string} optionName option name used in error messages + * @param {*} value raw option value + * @returns {number} parsed positive integer + * @throws {Error} when the value is missing or not a positive integer + */ +function parsePositiveIntegerOption (optionName, value) { + if (!value) { + throw new Error(`${optionName} requires a value`) + } + + const parsed = Number.parseInt(value, 10) + if (!Number.isFinite(parsed) || parsed <= 0) { + throw new Error(`${optionName} must be a positive integer`) + } + + return parsed +} + +/** + * Parse rerateDevelopmentChallenge command-line arguments. + * @param {Array} argv process argv slice after the script path + * @returns {Object} normalized script options + * @throws {Error} when an option is unknown or invalid + */ +function parseArgs (argv) { + const options = { + concurrency: DEFAULT_CONCURRENCY, + limit: null, + userIds: [], + dryRun: false, + processedUserIdsPath: DEFAULT_PROCESSED_USER_IDS_PATH, + help: false + } + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index] + + if (arg === '--') { + continue + } + + if (arg === '--concurrency') { + options.concurrency = parsePositiveIntegerOption(arg, argv[index + 1]) + index += 1 + continue + } + + if (arg === '--limit') { + options.limit = parsePositiveIntegerOption(arg, argv[index + 1]) + index += 1 + continue + } + + if (arg === '--user-id') { + const next = argv[index + 1] + if (!next) { + throw new Error('--user-id requires a value') + } + + options.userIds.push(...parseUserIds(next)) + index += 1 + continue + } + + if (arg === '--user-ids') { + const next = argv[index + 1] + if (!next) { + throw new Error('--user-ids requires a value') + } + + options.userIds.push(...parseUserIds(next)) + index += 1 + continue + } + + if (arg === '--processed-user-ids-path') { + const next = argv[index + 1] + if (!next) { + throw new Error('--processed-user-ids-path requires a value') + } + + options.processedUserIdsPath = next + index += 1 + continue + } + + if (arg === '--dry-run') { + options.dryRun = true + continue + } + + if (arg === '--help' || arg === '-h') { + options.help = true + continue + } + + throw new Error(`Unknown option: ${arg}`) + } + + options.userIds = Array.from(new Set(options.userIds)) + return options +} + +/** + * Print command-line usage for the Development Challenge rerate helper. + * @returns {void} + */ +function printUsage () { + console.log(` +Usage: + node src/scripts/rerateDevelopmentChallenge.js [options] + +Options: + --user-id Re-rate a single user ID discovered in Development history (repeatable). + --user-ids Comma-separated user IDs discovered in Development history. + --limit Limit the number of discovered users processed. + --concurrency Process up to n users in parallel (default: ${DEFAULT_CONCURRENCY}). + --processed-user-ids-path + Write successfully processed user IDs to JSON (default: ${DEFAULT_PROCESSED_USER_IDS_PATH}). + --dry-run Discover Development challenges and users without writing ratings. + --help, -h Show this help. +`) +} + +/** + * Process items with bounded concurrency while preserving result order. + * @param {Array<*>} items items to process + * @param {number} concurrency maximum number of in-flight operations + * @param {Function} iteratee async item processor + * @returns {Promise>} processor results in input order + */ +async function mapWithConcurrency (items, concurrency, iteratee) { + const results = new Array(items.length) + let nextIndex = 0 + + async function worker () { + while (nextIndex < items.length) { + const currentIndex = nextIndex + nextIndex += 1 + results[currentIndex] = await iteratee(items[currentIndex], currentIndex) + } + } + + const workerCount = Math.min(Math.max(concurrency, 1), items.length) + await Promise.all(Array.from({ length: workerCount }, worker)) + return results +} + +/** + * Build a checkpoint writer for successfully processed user IDs. + * @param {string} outputPath relative or absolute output path + * @returns {Object} writer with appendUserId and end methods + */ +function buildProcessedUserIdsWriter (outputPath) { + const resolvedPath = path.resolve(process.cwd(), outputPath || DEFAULT_PROCESSED_USER_IDS_PATH) + const processedUserIds = new Set() + let writePromise = Promise.resolve() + + async function write () { + await fs.promises.writeFile( + resolvedPath, + `${JSON.stringify(Array.from(processedUserIds), null, 2)}\n`, + 'utf8' + ) + } + + function enqueueWrite () { + writePromise = writePromise.then(write) + return writePromise + } + + return { + outputPath: resolvedPath, + async appendUserId (userId) { + processedUserIds.add(String(userId)) + await enqueueWrite() + }, + async end () { + await enqueueWrite() + } + } +} + +/** + * Connect a Prisma-style client when it exposes $connect. + * @param {Object} client database client + * @returns {Promise} + */ +async function connectClient (client) { + if (client && typeof client.$connect === 'function') { + await client.$connect() + } +} + +/** + * Disconnect or close a database client when it exposes a close method. + * @param {Object} client database client + * @returns {Promise} + */ +async function disconnectClient (client) { + if (client && typeof client.$disconnect === 'function') { + await client.$disconnect() + return + } + + if (client && typeof client.end === 'function') { + await client.end() + } +} + +/** + * Resolve the ChallengeType ids that are included in Development Challenge rating. + * @param {Object} dimensionLookup cached challenge dimension lookup + * @returns {Array} unique ChallengeType ids for Challenge and CODE + */ +function resolveDevelopmentTypeIds (dimensionLookup) { + return Array.from(new Set([ + resolveTypeIdFromLookup(dimensionLookup, TYPE_NAMES.CHALLENGE), + resolveTypeIdFromLookup(dimensionLookup, TYPE_NAMES.CODE) + ].filter(Boolean))) +} + +/** + * Load completed, rated Development challenges with Challenge or CODE type. + * Challenge and CODE entries are both replayed by the Development rating engine + * into the existing DEVELOP / Challenge rating stream. + * @param {Object} challengeClient Prisma challenge client + * @returns {Promise} resolved ids and ordered challenge history + * @throws {Error} when required Development dimensions cannot be resolved + */ +async function fetchDevelopmentChallengeHistory (challengeClient) { + const dimensionLookup = await loadChallengeDimensionLookup(challengeClient) + const developmentTrackId = resolveTrackIdFromLookup(dimensionLookup, TRACK_NAMES.DEVELOP) + const developmentTypeIds = resolveDevelopmentTypeIds(dimensionLookup) + + if (!developmentTrackId) { + throw new Error('Unable to resolve Development ChallengeTrack id') + } + if (developmentTypeIds.length === 0) { + throw new Error('Unable to resolve Challenge or CODE ChallengeType ids') + } + + const challenges = await challengeClient.challenge.findMany({ + where: { + trackId: developmentTrackId, + typeId: { + in: developmentTypeIds + }, + status: COMPLETED_CHALLENGE_STATUS + }, + select: { + id: true, + legacyId: true, + endDate: true, + status: true, + trackId: true, + typeId: true, + track: { + select: { + name: true + } + }, + type: { + select: { + name: true + } + }, + metadata: RATING_METADATA_SELECT, + legacyRecord: { + select: { + legacySystemId: true + } + } + } + }) + + const history = [] + challenges.forEach((challenge) => { + if (!challenge || String(challenge.status || '').trim().toUpperCase() !== COMPLETED_CHALLENGE_STATUS) { + return + } + + if (!isChallengeRated(challenge)) { + return + } + + const eventDate = toDateOrNull(challenge.endDate) + if (!eventDate) { + return + } + + history.push({ + challengeId: String(challenge.id), + reviewChallengeIds: buildReviewChallengeIds(challenge), + eventDate, + typeId: String(challenge.typeId), + trackId: String(challenge.trackId) + }) + }) + + history.sort((left, right) => { + const leftEventDate = left.eventDate.getTime() + const rightEventDate = right.eventDate.getTime() + if (leftEventDate !== rightEventDate) { + return leftEventDate - rightEventDate + } + + return left.challengeId.localeCompare(right.challengeId) + }) + + return { + trackId: developmentTrackId, + typeIds: developmentTypeIds, + history + } +} + +/** + * Resolve a Development Challenge participant's member id. + * @param {Object} row challengeResult participant row + * @returns {BigInt} participant member id + */ +function resolveDevelopmentParticipantId (row) { + return global.BigInt(String(row.userId)) +} + +/** + * Discover distinct members who participated in Development Challenge events. + * @param {Object} reviewDbClient raw pg review database client + * @param {Array} developmentHistory ordered Development challenge history + * @param {Object} options discovery controls and optional test doubles + * @param {Array} options.userIds optional user ID allow-list + * @param {number|null} options.limit optional maximum discovered users + * @param {Function} options.fetchParticipants optional participant loader + * @param {Function} options.resolveParticipantId optional participant id resolver + * @returns {Promise} discovery summary containing members and scan counts + */ +async function discoverDevelopmentChallengeMembers (reviewDbClient, developmentHistory, options = {}) { + const fetchParticipants = options.fetchParticipants || fetchParticipantsForChallenge + const resolveParticipantId = options.resolveParticipantId || resolveDevelopmentParticipantId + const userFilter = new Set((options.userIds || []).map(userId => String(userId))) + const membersByUserId = new Map() + let challengesScanned = 0 + let participantRowsScanned = 0 + + for (const historyEntry of developmentHistory) { + const participantResult = await fetchParticipants(reviewDbClient, historyEntry) + const participantRows = participantResult.participantRows || participantResult || [] + challengesScanned += 1 + participantRowsScanned += participantRows.length + + for (const row of participantRows) { + const userId = resolveParticipantId(row) + const userKey = String(userId) + + if (userFilter.size > 0 && !userFilter.has(userKey)) { + continue + } + + if (!membersByUserId.has(userKey)) { + membersByUserId.set(userKey, { + userId, + firstChallengeId: historyEntry.challengeId, + firstEventDate: historyEntry.eventDate + }) + } + } + + if (options.limit && membersByUserId.size >= options.limit) { + break + } + } + + const members = Array.from(membersByUserId.values()) + return { + members: options.limit ? members.slice(0, options.limit) : members, + challengesScanned, + participantRowsScanned + } +} + +/** + * Filter discovered users down to members present in member-api storage. + * @param {Object} membersClient Prisma members client + * @param {Array} members discovered member descriptors + * @returns {Promise} existing and missing member descriptors + */ +async function filterExistingMembers (membersClient, members) { + if (!members || members.length === 0) { + return { + existingMembers: [], + skippedMembers: [] + } + } + + const existingUserIds = new Set() + for (let start = 0; start < members.length; start += MEMBER_LOOKUP_BATCH_SIZE) { + const batch = members.slice(start, start + MEMBER_LOOKUP_BATCH_SIZE) + const rows = await membersClient.member.findMany({ + where: { + userId: { + in: batch.map(member => member.userId) + } + }, + select: { + userId: true + } + }) + + rows.forEach((row) => { + existingUserIds.add(String(row.userId)) + }) + } + + return { + existingMembers: members.filter(member => existingUserIds.has(String(member.userId))), + skippedMembers: members.filter(member => !existingUserIds.has(String(member.userId))) + } +} + +/** + * Run the Development Challenge bulk rerate workflow. + * @param {Object} options parsed script options + * @param {Object} dependencies optional clients and functions for tests + * @returns {Promise} bulk rerate summary + */ +async function run (options, dependencies = {}) { + const membersClient = dependencies.membersClient || getMembersClient() + const challengeClient = dependencies.challengeClient || getChallengesClient() + const reviewDbClient = Object.prototype.hasOwnProperty.call(dependencies, 'reviewDbClient') + ? dependencies.reviewDbClient + : reviewDb + const shouldDisconnect = dependencies.disconnect !== false + const startedAt = startTimer() + + if (!reviewDbClient) { + throw new Error('REVIEW_DB_URL must be configured to rerate Development Challenge stats') + } + + try { + await connectClient(membersClient) + await connectClient(challengeClient) + + logInfo('Loading completed Development Challenge and CODE challenges') + const developmentHistoryResult = await (dependencies.fetchDevelopmentChallengeHistory || fetchDevelopmentChallengeHistory)(challengeClient) + const developmentHistory = developmentHistoryResult.history || developmentHistoryResult + logInfo(`Loaded ${developmentHistory.length} Development challenge(s)`) + + if (developmentHistory.length === 0) { + return { + dryRun: options.dryRun, + trackId: developmentHistoryResult.trackId, + typeIds: developmentHistoryResult.typeIds, + pathChallenges: 0, + usersDiscovered: 0, + usersProcessed: 0, + usersFailed: 0, + usersSkippedMissing: 0, + ratingsUpdated: 0, + durationMs: getElapsedMilliseconds(startedAt) + } + } + + const discovery = await discoverDevelopmentChallengeMembers(reviewDbClient, developmentHistory, { + userIds: options.userIds, + limit: options.limit, + fetchParticipants: dependencies.fetchParticipants, + resolveParticipantId: dependencies.resolveParticipantId + }) + const memberFilterResult = await (dependencies.filterExistingMembers || filterExistingMembers)( + membersClient, + discovery.members + ) + const members = memberFilterResult.existingMembers + const skippedMembers = memberFilterResult.skippedMembers + + logInfo(`Scanned ${discovery.challengesScanned} challenge(s) and ${discovery.participantRowsScanned} participant row(s)`) + logInfo(`Discovered ${discovery.members.length} member(s) to rerate; ${skippedMembers.length} missing from member storage`) + + if (options.dryRun) { + members.slice(0, 10).forEach((member) => { + logInfo(`Dry-run member ${String(member.userId)} first appears in challenge ${member.firstChallengeId}`) + }) + if (members.length > 10) { + logInfo(`Dry-run output truncated to 10 of ${members.length} existing member(s)`) + } + + return { + dryRun: true, + trackId: developmentHistoryResult.trackId, + typeIds: developmentHistoryResult.typeIds, + pathChallenges: developmentHistory.length, + challengesScanned: discovery.challengesScanned, + participantRowsScanned: discovery.participantRowsScanned, + usersDiscovered: discovery.members.length, + usersProcessable: members.length, + usersSkippedMissing: skippedMembers.length, + usersProcessed: 0, + usersFailed: 0, + ratingsUpdated: 0, + durationMs: getElapsedMilliseconds(startedAt) + } + } + + const processedUserIdsWriter = buildProcessedUserIdsWriter(options.processedUserIdsPath) + await processedUserIdsWriter.end() + logInfo(`Writing successfully processed user IDs to ${processedUserIdsWriter.outputPath}`) + + const rerateResults = await mapWithConcurrency(members, options.concurrency, async (member, index) => { + const userStartedAt = startTimer() + try { + const result = await (dependencies.rerateDevTrack || rerateDevTrack)( + membersClient, + challengeClient, + reviewDbClient, + member.userId, + null + ) + + await processedUserIdsWriter.appendUserId(member.userId) + logInfo(`Rerated ${index + 1}/${members.length} userId=${String(member.userId)} ratingsUpdated=${result.ratingsUpdated} duration=${formatDuration(getElapsedMilliseconds(userStartedAt))}`) + return { + userId: String(member.userId), + ok: true, + ...result, + durationMs: getElapsedMilliseconds(userStartedAt) + } + } catch (error) { + logError(`Failed to rerate userId=${String(member.userId)} after ${formatDuration(getElapsedMilliseconds(userStartedAt))}`, error) + return { + userId: String(member.userId), + ok: false, + error: error.message, + durationMs: getElapsedMilliseconds(userStartedAt) + } + } + }) + + const usersProcessed = rerateResults.filter(result => result.ok).length + const usersFailed = rerateResults.length - usersProcessed + const ratingsUpdated = rerateResults.reduce((sum, result) => sum + (result.ok ? result.ratingsUpdated || 0 : 0), 0) + const challengesProcessed = rerateResults.reduce((sum, result) => sum + (result.ok ? result.challengesProcessed || 0 : 0), 0) + const durationMs = getElapsedMilliseconds(startedAt) + + logInfo(`Completed Development Challenge rerate: usersProcessed=${usersProcessed}, usersFailed=${usersFailed}, usersSkippedMissing=${skippedMembers.length}, ratingsUpdated=${ratingsUpdated}, duration=${formatDuration(durationMs)}`) + + return { + dryRun: false, + trackId: developmentHistoryResult.trackId, + typeIds: developmentHistoryResult.typeIds, + pathChallenges: developmentHistory.length, + challengesScanned: discovery.challengesScanned, + participantRowsScanned: discovery.participantRowsScanned, + usersDiscovered: discovery.members.length, + usersProcessable: members.length, + usersSkippedMissing: skippedMembers.length, + usersProcessed, + usersFailed, + challengesProcessed, + ratingsUpdated, + durationMs + } + } finally { + if (shouldDisconnect) { + await disconnectClient(membersClient) + await disconnectClient(challengeClient) + await disconnectClient(reviewDbClient) + } + } +} + +if (require.main === module) { + try { + const options = parseArgs(process.argv.slice(2)) + if (options.help) { + printUsage() + } else { + run(options) + .then((summary) => { + console.log(JSON.stringify(summary, null, 2)) + }) + .catch((error) => { + logError('Development Challenge rerate failed', error) + process.exitCode = 1 + }) + } + } catch (error) { + logError('Development Challenge rerate failed', error) + process.exitCode = 1 + } +} + +module.exports = { + parseArgs, + printUsage, + fetchDevelopmentChallengeHistory, + discoverDevelopmentChallengeMembers, + filterExistingMembers, + run +} diff --git a/src/scripts/rerateMarathonMatches.js b/src/scripts/rerateMarathonMatches.js index 7a0be79..4e9f31e 100644 --- a/src/scripts/rerateMarathonMatches.js +++ b/src/scripts/rerateMarathonMatches.js @@ -6,7 +6,7 @@ * * Required environment variables: * - DATABASE_URL (member database) - * - CHALLENGES_DB_URL (challenge database) + * - CHALLENGES_DB_URL or CHALLENGE_DB_URL (challenge database) * - REVIEW_DB_URL (review database with reviewSummation/submission data) * * Usage examples: diff --git a/src/scripts/rerateRatingPath.js b/src/scripts/rerateRatingPath.js index 0dfc77f..ad26094 100644 --- a/src/scripts/rerateRatingPath.js +++ b/src/scripts/rerateRatingPath.js @@ -6,7 +6,7 @@ * * Required environment variables: * - DATABASE_URL (member database) - * - CHALLENGES_DB_URL (challenge database) + * - CHALLENGES_DB_URL or CHALLENGE_DB_URL (challenge database) * - REVIEW_DB_URL (review database) * * Usage examples: diff --git a/src/services/StatisticsService.js b/src/services/StatisticsService.js index 634cc54..2e5642b 100644 --- a/src/services/StatisticsService.js +++ b/src/services/StatisticsService.js @@ -845,11 +845,17 @@ async function fetchChallengeWinnerResultsForMember (challengeClient, userId) { challenge: { select: { id: true, + legacyId: true, name: true, status: true, trackId: true, typeId: true, endDate: true, + legacyRecord: { + select: { + legacySystemId: true + } + }, winners: { where: { type: CHALLENGE_WINNER_PLACEMENT_TYPE @@ -1086,6 +1092,47 @@ function historyRowsNeedPlacementEnrichment (rows) { return _.some(rows || [], row => !_.isNil(row.challengeId) && !toVisiblePlacement(row.placement)) } +/** + * Determine whether any history rows are missing a challenge display name. + * @param {Array} rows history rows already shaped for response building + * @returns {boolean} true when a row still needs challenge name enrichment + */ +function historyRowsNeedChallengeNameEnrichment (rows) { + return _.some(rows || [], row => !_.isNil(row.challengeId) && !row.challengeName) +} + +/** + * Normalize one challenge lookup key for metadata maps. + * @param {*} value challenge identifier candidate + * @returns {string|null} trimmed challenge identifier, or null when unavailable + */ +function normalizeChallengeLookupKey (value) { + if (_.isNil(value)) { + return null + } + + const normalized = String(value).trim() + return normalized || null +} + +/** + * Build all challenge id aliases exposed by one challenge winner row. + * @param {Object} row challenge winner row with embedded challenge metadata + * @returns {Array} unique challenge identifier candidates + */ +function buildChallengeWinnerChallengeIdCandidates (row) { + return _.chain([ + row && row.challengeId, + _.get(row, 'challenge.id'), + _.get(row, 'challenge.legacyId'), + _.get(row, 'challenge.legacyRecord.legacySystemId') + ]) + .map(normalizeChallengeLookupKey) + .filter(Boolean) + .uniq() + .value() +} + /** * Build a canonical challengeId -> placement lookup from challenge winner rows. * When duplicate winner rows exist, keep the best available placement. @@ -1097,17 +1144,18 @@ function buildChallengeWinnerPlacementLookup (winnerRows) { _.forEach(winnerRows || [], (row) => { const placement = toVisibleChallengeWinnerPlacement(row) - const challengeId = _.get(row, 'challenge.id') || row.challengeId - const challengeKey = _.isNil(challengeId) ? null : String(challengeId).trim() + const challengeKeys = buildChallengeWinnerChallengeIdCandidates(row) - if (!placement || !challengeKey) { + if (!placement || challengeKeys.length === 0) { return } - const existingPlacement = placementByChallengeId.get(challengeKey) - if (_.isNil(existingPlacement) || placement < existingPlacement) { - placementByChallengeId.set(challengeKey, placement) - } + _.forEach(challengeKeys, (challengeKey) => { + const existingPlacement = placementByChallengeId.get(challengeKey) + if (_.isNil(existingPlacement) || placement < existingPlacement) { + placementByChallengeId.set(challengeKey, placement) + } + }) }) return placementByChallengeId @@ -1129,7 +1177,7 @@ function mergeHistoryPlacementsFromChallengeWinners (rows, winnerRows) { } return _.map(rows || [], (row) => { - const challengeKey = _.isNil(row.challengeId) ? null : String(row.challengeId).trim() + const challengeKey = normalizeChallengeLookupKey(row.challengeId) const placement = challengeKey ? placementByChallengeId.get(challengeKey) : undefined if (toVisiblePlacement(row.placement) || !placement) { @@ -1143,6 +1191,66 @@ function mergeHistoryPlacementsFromChallengeWinners (rows, winnerRows) { }) } +/** + * Build challenge metadata keyed by all aliases exposed in challenge winner rows. + * @param {Array} winnerRows placement winner rows from challenge-api + * @returns {Map} challenge metadata keyed by canonical and legacy ids + */ +function buildChallengeWinnerMetadataLookup (winnerRows) { + const metadataByChallengeId = new Map() + + _.forEach(winnerRows || [], (row) => { + const challenge = row && row.challenge + const challengeName = _.get(row, 'challenge.name') + const canonicalChallengeId = normalizeChallengeLookupKey(_.get(row, 'challenge.id') || row.challengeId) + const challengeKeys = buildChallengeWinnerChallengeIdCandidates(row) + + if (!challenge || !isCompletedChallenge(challenge) || !challengeName || + !canonicalChallengeId || challengeKeys.length === 0) { + return + } + + const metadata = { + challengeId: canonicalChallengeId, + challengeName + } + _.forEach(challengeKeys, (challengeKey) => { + metadataByChallengeId.set(challengeKey, metadata) + }) + }) + + return metadataByChallengeId +} + +/** + * Fill missing challenge names from challenge-api winner rows. + * @param {Array} rows persisted and/or synthesized history rows + * @param {Array} winnerRows placement winner rows from challenge-api + * @returns {Array} history rows with names and canonical ids when available + */ +function mergeHistoryChallengeMetadataFromChallengeWinners (rows, winnerRows) { + const metadataByChallengeId = buildChallengeWinnerMetadataLookup(winnerRows) + + if (metadataByChallengeId.size === 0) { + return rows || [] + } + + return _.map(rows || [], (row) => { + const challengeKey = normalizeChallengeLookupKey(row.challengeId) + const metadata = challengeKey ? metadataByChallengeId.get(challengeKey) : null + + if (!metadata) { + return row + } + + return { + ...row, + challengeId: metadata.challengeId, + challengeName: row.challengeName || metadata.challengeName + } + }) +} + /** * Aggregate completed review-api results into unified stats rows. * @param {Array} reviewRows review-api challenge result rows @@ -2436,9 +2544,14 @@ async function getHistoryStats (currentUser, handle, query) { unresolvedPairKeys = getUnresolvedHistoryPairKeys(unresolvedPairKeys, reviewFallbackRows) } - if (missingPairKeys.size > 0 || historyRowsNeedPlacementEnrichment(annotatedRows)) { + if ( + missingPairKeys.size > 0 || + historyRowsNeedPlacementEnrichment(annotatedRows) || + historyRowsNeedChallengeNameEnrichment(annotatedRows) + ) { const winnerRows = await fetchChallengeWinnerResultsForMember(challengeClient, member.userId) + annotatedRows = dedupeUnifiedHistoryRows(mergeHistoryChallengeMetadataFromChallengeWinners(annotatedRows, winnerRows)) annotatedRows = mergeHistoryPlacementsFromChallengeWinners(annotatedRows, winnerRows) const winnerFallbackPairKeys = new Set( Array.from(visiblePairKeys).concat( diff --git a/test/unit/DevelopRatingEngine.test.js b/test/unit/DevelopRatingEngine.test.js index d8b6703..89efcc2 100644 --- a/test/unit/DevelopRatingEngine.test.js +++ b/test/unit/DevelopRatingEngine.test.js @@ -18,6 +18,7 @@ const should = chai.should() const DEVELOP_TRACK_ID = 'track-develop-id' const DATA_SCIENCE_TRACK_ID = 'track-data-science-id' const CHALLENGE_TYPE_ID = 'type-challenge-id' +const CODE_TYPE_ID = 'type-code-id' const MARATHON_MATCH_TYPE_ID = 'type-marathon-match-id' function isBigIntValue (value) { @@ -257,6 +258,27 @@ function createReviewDbClient (rows) { } } + if (sql.includes('WHERE "challengeId" IN')) { + const challengeIds = new Set(params.map((challengeId) => String(challengeId))) + return { + rows: resultRows + .filter((row) => challengeIds.has(String(row.challengeId))) + .sort((left, right) => { + const placementComparison = compareValues(left.placement, right.placement) + if (placementComparison !== 0) { + return placementComparison + } + + const scoreComparison = compareValues(right.finalScore, left.finalScore) + if (scoreComparison !== 0) { + return scoreComparison + } + + return compareValues(left.createdAt, right.createdAt) + }) + } + } + if (sql.includes('WHERE "challengeId" = $1')) { return { rows: resultRows @@ -297,6 +319,7 @@ function createChallengeClient (metadataById) { if (sql.includes('FROM "ChallengeType"')) { return [ { id: CHALLENGE_TYPE_ID, name: 'Challenge', abbreviation: 'CH', legacyId: null, isTask: false }, + { id: CODE_TYPE_ID, name: 'Code', abbreviation: 'CODE', legacyId: null, isTask: false }, { id: MARATHON_MATCH_TYPE_ID, name: 'Marathon Match', abbreviation: 'MM', legacyId: null, isTask: false } ] } @@ -305,8 +328,29 @@ function createChallengeClient (metadataById) { }, challenge: { async findMany (args) { - return args.where.id.in - .map((challengeId) => metadataById[String(challengeId)]) + const idCandidates = new Set() + const legacyIdCandidates = new Set() + const whereClauses = args.where && args.where.OR ? args.where.OR : [args.where] + + whereClauses.forEach((where) => { + if (where && where.id && Array.isArray(where.id.in)) { + where.id.in.forEach((challengeId) => idCandidates.add(String(challengeId))) + } + if (where && where.legacyId && Array.isArray(where.legacyId.in)) { + where.legacyId.in.forEach((challengeId) => legacyIdCandidates.add(String(challengeId))) + } + if (where && where.legacyRecord && where.legacyRecord.is && where.legacyRecord.is.legacySystemId && Array.isArray(where.legacyRecord.is.legacySystemId.in)) { + where.legacyRecord.is.legacySystemId.in.forEach((challengeId) => legacyIdCandidates.add(String(challengeId))) + } + }) + + return Object.keys(metadataById) + .map((challengeId) => metadataById[challengeId]) + .filter((challenge) => ( + idCandidates.has(String(challenge.id)) || + legacyIdCandidates.has(String(challenge.legacyId)) || + (challenge.legacyRecord && legacyIdCandidates.has(String(challenge.legacyRecord.legacySystemId))) + )) .filter(Boolean) .map(cloneRow) } @@ -896,6 +940,83 @@ describe('develop rating engine unit tests', () => { should.equal(historyRow.newRating, expectedTarget.rating) }) + it('rerateDevTrack should include Development CODE challenges in the Challenge rating stream', async () => { + const targetUserId = toBigInt(7101) + const opponentUserId = toBigInt(7102) + const canonicalChallengeId = 'code-canonical-dev' + const legacyChallengeId = 710123 + + const { client: membersClient, state } = createMembersClient({ + historyRows: [], + statsRows: [], + maxRatingRows: [] + }) + + const reviewDbClient = createReviewDbClient([ + { + challengeId: String(legacyChallengeId), + userId: targetUserId, + finalScore: 95, + placement: 1, + rated: true, + passedReview: true, + createdAt: new Date('2024-06-15T09:00:00.000Z') + }, + { + challengeId: String(legacyChallengeId), + userId: opponentUserId, + finalScore: 80, + placement: 2, + rated: true, + passedReview: true, + createdAt: new Date('2024-06-15T09:05:00.000Z') + } + ]) + + const challengeClient = createChallengeClient({ + [canonicalChallengeId]: { + id: canonicalChallengeId, + legacyId: legacyChallengeId, + legacyRecord: { legacySystemId: legacyChallengeId }, + endDate: new Date('2024-06-15T00:00:00.000Z'), + metadata: [], + track: { name: 'Development' }, + type: { name: 'CODE' } + } + }) + + const expectedParticipants = [ + createParticipant(targetUserId, 0, 0, 0, 95), + createParticipant(opponentUserId, 0, 0, 0, 80) + ] + runQubitsRating(expectedParticipants) + const expectedTarget = expectedParticipants.find((participant) => participant.coderId === String(targetUserId)) + + const result = await rerateDevTrack( + membersClient, + challengeClient, + reviewDbClient, + targetUserId, + legacyChallengeId + ) + + should.equal(result.challengesProcessed, 1) + should.equal(result.ratingsUpdated, 1) + + const statsRow = state.statsRows.find((row) => + String(row.userId) === String(targetUserId) && + row.trackId === DEVELOP_TRACK_ID && + row.typeId === CHALLENGE_TYPE_ID + ) + const historyRow = findHistoryRow(state.historyRows, targetUserId, canonicalChallengeId) + const legacyHistoryRow = findHistoryRow(state.historyRows, targetUserId, legacyChallengeId) + + should.equal(statsRow.rating, expectedTarget.rating) + should.equal(statsRow.volatility, expectedTarget.volatility) + should.equal(historyRow.newRating, expectedTarget.rating) + should.equal(legacyHistoryRow, undefined) + }) + it('rerateDevTrack should use passed review rows even when challengeResult.rated is false', async () => { const targetUserId = toBigInt(9009) const opponentUserId = toBigInt(9010) diff --git a/test/unit/PrismaManager.test.js b/test/unit/PrismaManager.test.js new file mode 100644 index 0000000..fba2540 --- /dev/null +++ b/test/unit/PrismaManager.test.js @@ -0,0 +1,43 @@ +/* + * Unit tests for Prisma client manager utilities. + */ + +const path = require('path') +const chai = require('chai') + +const should = chai.should() + +const prismaPath = path.resolve(__dirname, '../../src/common/prisma.js') + +describe('prisma manager unit tests', () => { + const originalChallengeDbUrl = process.env.CHALLENGE_DB_URL + const originalChallengesDbUrl = process.env.CHALLENGES_DB_URL + + afterEach(() => { + if (originalChallengeDbUrl === undefined) { + delete process.env.CHALLENGE_DB_URL + } else { + process.env.CHALLENGE_DB_URL = originalChallengeDbUrl + } + + if (originalChallengesDbUrl === undefined) { + delete process.env.CHALLENGES_DB_URL + } else { + process.env.CHALLENGES_DB_URL = originalChallengesDbUrl + } + + delete require.cache[prismaPath] + }) + + it('getChallengesClient should accept CHALLENGE_DB_URL as a fallback', async () => { + delete process.env.CHALLENGES_DB_URL + process.env.CHALLENGE_DB_URL = 'postgresql://user:password@localhost:5432/topcoder?schema=challenges' + delete require.cache[prismaPath] + + const prismaManager = require('../../src/common/prisma') + const challengesClient = prismaManager.getChallengesClient() + + should.exist(challengesClient) + await challengesClient.$disconnect() + }) +}) diff --git a/test/unit/RerateDevelopmentChallenge.test.js b/test/unit/RerateDevelopmentChallenge.test.js new file mode 100644 index 0000000..2c51f7a --- /dev/null +++ b/test/unit/RerateDevelopmentChallenge.test.js @@ -0,0 +1,220 @@ +/* + * Unit tests for Development Challenge bulk rerate helpers. + */ + +const chai = require('chai') +const fs = require('fs') +const os = require('os') +const path = require('path') + +const rerateDevelopmentChallenge = require('../../src/scripts/rerateDevelopmentChallenge') +const { clearChallengeDimensionLookupCache } = require('../../src/common/statsDimensionHelper') + +chai.should() + +/** + * Build a minimal challenge Prisma client test double for dimension lookups and + * challenge history queries. + * @param {Function} onFindMany handler for challenge.findMany calls + * @returns {Object} challenge client test double + */ +function createChallengeClient (onFindMany) { + return { + $queryRaw: async (strings) => { + const sql = Array.isArray(strings) ? strings.join('') : String(strings) + if (sql.includes('"ChallengeTrack"')) { + return [ + { id: 'track-dev-id', name: 'Development', abbreviation: 'DEV', legacyId: 1 }, + { id: 'track-ds-id', name: 'Data Science', abbreviation: 'DS', legacyId: 2 } + ] + } + if (sql.includes('"ChallengeType"')) { + return [ + { id: 'type-ch-id', name: 'Challenge', abbreviation: 'CH', legacyId: 3, isTask: false }, + { id: 'type-code-id', name: 'Code', abbreviation: 'CODE', legacyId: 4, isTask: false }, + { id: 'type-mm-id', name: 'Marathon Match', abbreviation: 'MM', legacyId: 5, isTask: false } + ] + } + return [] + }, + challenge: { + findMany: onFindMany + } + } +} + +describe('rerateDevelopmentChallenge unit tests', () => { + beforeEach(() => { + clearChallengeDimensionLookupCache() + }) + + it('should parse Development Challenge rerate options', () => { + const options = rerateDevelopmentChallenge.parseArgs([ + '--', + '--concurrency', + '5', + '--limit', + '10', + '--user-id', + '123', + '--user-ids', + '456,789,123', + '--dry-run', + '--processed-user-ids-path', + '/tmp/dev-processed.json' + ]) + + options.concurrency.should.equal(5) + options.limit.should.equal(10) + options.userIds.should.deep.equal(['123', '456', '789']) + options.dryRun.should.equal(true) + options.processedUserIdsPath.should.equal('/tmp/dev-processed.json') + }) + + it('should load completed Development Challenge and CODE history', async () => { + let capturedWhere + const challengeClient = createChallengeClient(async (args) => { + capturedWhere = args.where + return [ + { + id: 'dev-challenge', + legacyId: 2002, + legacyRecord: { legacySystemId: 22002 }, + status: 'COMPLETED', + trackId: 'track-dev-id', + typeId: 'type-ch-id', + endDate: new Date('2024-02-01T00:00:00.000Z'), + metadata: [] + }, + { + id: 'dev-code', + legacyId: 1001, + legacyRecord: { legacySystemId: 11001 }, + status: 'COMPLETED', + trackId: 'track-dev-id', + typeId: 'type-code-id', + endDate: new Date('2024-01-01T00:00:00.000Z'), + metadata: [] + }, + { + id: 'unrated-dev', + status: 'COMPLETED', + trackId: 'track-dev-id', + typeId: 'type-ch-id', + endDate: new Date('2024-03-01T00:00:00.000Z'), + metadata: [{ name: 'unrated', value: 'true' }] + } + ] + }) + + const result = await rerateDevelopmentChallenge.fetchDevelopmentChallengeHistory(challengeClient) + + capturedWhere.should.deep.equal({ + trackId: 'track-dev-id', + typeId: { + in: ['type-ch-id', 'type-code-id'] + }, + status: 'COMPLETED' + }) + result.trackId.should.equal('track-dev-id') + result.typeIds.should.deep.equal(['type-ch-id', 'type-code-id']) + result.history.map(row => row.challengeId).should.deep.equal(['dev-code', 'dev-challenge']) + result.history.map(row => row.reviewChallengeIds).should.deep.equal([ + ['dev-code', '1001', '11001'], + ['dev-challenge', '2002', '22002'] + ]) + }) + + it('should discover distinct Development Challenge members in path order', async () => { + const developmentHistory = [ + { challengeId: 'dev-1', reviewChallengeIds: ['dev-1', '101'], eventDate: new Date('2024-01-01T00:00:00Z') }, + { challengeId: 'dev-2', reviewChallengeIds: ['dev-2', '102'], eventDate: new Date('2024-02-01T00:00:00Z') } + ] + const participantsByChallengeId = { + 'dev-1': [{ userId: '1001' }, { userId: '1002' }], + 'dev-2': [{ userId: '1003' }, { userId: '1001' }] + } + const discoveredChallengeAliases = [] + + const result = await rerateDevelopmentChallenge.discoverDevelopmentChallengeMembers({}, developmentHistory, { + fetchParticipants: async (reviewDbClient, historyEntry) => { + discoveredChallengeAliases.push(historyEntry.reviewChallengeIds) + return participantsByChallengeId[historyEntry.challengeId] + }, + resolveParticipantId: row => global.BigInt(row.userId) + }) + + result.challengesScanned.should.equal(2) + result.participantRowsScanned.should.equal(4) + result.members.map(member => String(member.userId)).should.deep.equal(['1001', '1002', '1003']) + discoveredChallengeAliases.should.deep.equal([['dev-1', '101'], ['dev-2', '102']]) + result.members[0].firstChallengeId.should.equal('dev-1') + result.members[2].firstChallengeId.should.equal('dev-2') + }) + + it('should rerate each existing Development Challenge member from the beginning', async () => { + const rerated = [] + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rerate-development-challenge-')) + const processedUserIdsPath = path.join(tempDir, 'processed.json') + + try { + const summary = await rerateDevelopmentChallenge.run({ + concurrency: 2, + limit: null, + userIds: [], + dryRun: false, + processedUserIdsPath + }, { + membersClient: { + member: { + findMany: async ({ where }) => where.userId.in + .filter(userId => String(userId) !== '1003') + .map(userId => ({ userId })) + } + }, + challengeClient: {}, + reviewDbClient: {}, + disconnect: false, + fetchDevelopmentChallengeHistory: async () => ({ + trackId: 'track-dev-id', + typeIds: ['type-ch-id', 'type-code-id'], + history: [ + { challengeId: 'dev-1', eventDate: new Date('2024-01-01T00:00:00Z') }, + { challengeId: 'dev-2', eventDate: new Date('2024-02-01T00:00:00Z') } + ] + }), + fetchParticipants: async (reviewDbClient, historyEntry) => ( + historyEntry.challengeId === 'dev-1' + ? [{ userId: '1001' }, { userId: '1002' }] + : [{ userId: '1003' }, { userId: '1001' }] + ), + resolveParticipantId: row => global.BigInt(row.userId), + rerateDevTrack: async (membersClient, challengeClient, reviewDbClient, userId, fromChallengeId) => { + rerated.push({ + userId: String(userId), + fromChallengeId + }) + return { + challengesProcessed: 3, + ratingsUpdated: 3 + } + } + }) + + summary.usersDiscovered.should.equal(3) + summary.usersProcessable.should.equal(2) + summary.usersSkippedMissing.should.equal(1) + summary.usersProcessed.should.equal(2) + summary.usersFailed.should.equal(0) + summary.challengesProcessed.should.equal(6) + summary.ratingsUpdated.should.equal(6) + rerated.sort((left, right) => left.userId.localeCompare(right.userId)).should.deep.equal([ + { userId: '1001', fromChallengeId: null }, + { userId: '1002', fromChallengeId: null } + ]) + JSON.parse(fs.readFileSync(processedUserIdsPath, 'utf8')).sort().should.deep.equal(['1001', '1002']) + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }) + } + }) +}) diff --git a/test/unit/StatisticsService.test.js b/test/unit/StatisticsService.test.js index dc0aca5..9f66aef 100644 --- a/test/unit/StatisticsService.test.js +++ b/test/unit/StatisticsService.test.js @@ -246,7 +246,8 @@ function loadStatisticsService (options = {}) { getAllowedGroupIds: async () => options.groupIds || ['10'], canManageMember: () => true, hasAdminRole: () => true, - bigIntToNumber: (value) => (value ? Number(value) : null) + bigIntToNumber: (value) => (value ? Number(value) : null), + getRatingColor: () => '#EF3A3A' }) setStubModule(loggerPath, { debug: () => {}, @@ -397,6 +398,82 @@ describe('statistics service unit tests', () => { } }) + it('getMemberStats should merge duplicate Marathon Match rows normalized under Data Science', async () => { + const { service, restore } = loadStatisticsService({ + prismaStub: { + $queryRaw: async () => [], + memberStats: { + findMany: async () => [ + { + trackId: 'track-ds-id', + typeId: 'type-mm-id', + challenges: 458, + wins: 88, + rating: 2543, + globalRank: 3, + countryRank: 1, + schoolRank: 0, + volatility: 352, + maxRating: 2925, + minRating: 1641, + topFiveFinishes: 135, + topTenFinishes: 182, + bestRank: 1, + avgRank: 7, + mostRecentSubmission: new Date('2024-06-25T14:46:39.719Z'), + mostRecentEventDate: new Date('2024-09-17T00:00:00.000Z') + }, + { + trackId: 'track-dev-id', + typeId: 'type-mm-id', + challenges: 68, + wins: null, + mostRecentEventDate: new Date('2023-01-06T06:36:14.000Z') + }, + { + trackId: 'track-ds-id', + typeId: 'type-srm-id', + challenges: 880, + wins: 2, + rating: 1592, + globalRank: 185, + countryRank: 3, + schoolRank: 0, + volatility: 400, + maxRating: 2435, + minRating: 1301, + mostRecentEventDate: new Date('2023-01-04T00:00:00.000Z') + } + ] + }, + memberStatsHistory: { + findMany: async () => [] + } + } + }) + + try { + const result = await service.getMemberStats({ isMachine: true }, 'devtest1400', {}) + + result.should.have.length(1) + result[0].DATA_SCIENCE.challenges.should.equal(1406) + result[0].DATA_SCIENCE.wins.should.equal(90) + const marathon = result[0].DATA_SCIENCE.MARATHON_MATCH + marathon.challenges.should.equal(526) + marathon.wins.should.equal(88) + marathon.mostRecentEventDate.should.equal(new Date('2024-09-17T00:00:00.000Z').getTime()) + marathon.rank.rating.should.equal(2543) + marathon.rank.rank.should.equal(3) + marathon.rank.countryRank.should.equal(1) + marathon.rank.maximumRating.should.equal(2925) + marathon.rank.minimumRating.should.equal(1641) + marathon.rank.topFiveFinishes.should.equal(135) + marathon.rank.topTenFinishes.should.equal(182) + } finally { + restore() + } + }) + it('rerateMemberStats should route configured rating paths to the Marathon Match engine', async () => { let capturedOptions const { service, restore } = loadStatisticsService({ @@ -1240,6 +1317,70 @@ describe('statistics service unit tests', () => { } }) + it('getHistoryStats should hydrate persisted Marathon Match names from challenge winners', async () => { + const ratingDate = new Date('2025-08-27T17:05:00.000Z') + const challenge = { + id: 'mm-challenge-uuid', + legacyId: null, + name: 'Marathon Match 163', + status: 'COMPLETED', + trackId: 'track-ds-id', + typeId: 'type-mm-id', + endDate: ratingDate, + legacyRecord: null, + winners: [] + } + let winnerFindManyArgs + const { service, restore } = loadStatisticsService({ + prismaStub: { + $queryRaw: async () => [], + memberStats: { + findFirst: async () => null, + findMany: async () => [{ + trackId: 'track-ds-id', + typeId: 'type-mm-id' + }] + }, + memberStatsHistory: { + findMany: async () => [{ + trackId: 'track-ds-id', + typeId: 'type-mm-id', + challengeId: 'mm-challenge-uuid', + eventDate: ratingDate, + newRating: 2543, + placement: 1, + mostRecent: true + }] + } + }, + challengeRows: [], + reviewRows: [], + challengeWinnerRows: [{ + challengeId: 'mm-challenge-uuid', + type: 'PLACEMENT', + placement: 1, + createdAt: ratingDate, + challenge + }], + onChallengeWinnerFindMany: (args) => { + winnerFindManyArgs = args + } + }) + + try { + const result = await service.getHistoryStats({ isMachine: true }, 'devtest1400', {}) + + should.exist(winnerFindManyArgs) + result.should.have.length(1) + result[0].DATA_SCIENCE.MARATHON_MATCH.history.should.have.length(1) + result[0].DATA_SCIENCE.MARATHON_MATCH.history[0].challengeId.should.equal('mm-challenge-uuid') + result[0].DATA_SCIENCE.MARATHON_MATCH.history[0].challengeName.should.equal('Marathon Match 163') + result[0].DATA_SCIENCE.MARATHON_MATCH.history[0].placement.should.equal(1) + } finally { + restore() + } + }) + it('getHistoryStats should surface passed-review Marathon Match winners under Data Science', async () => { const olderDate = new Date('2024-06-18T00:00:00.000Z') const newerDate = new Date('2025-08-27T17:05:00.000Z')