From 626ae55d754950ff340ed3174011432961f7b74c Mon Sep 17 00:00:00 2001 From: himaniraghav3 Date: Thu, 23 Apr 2026 15:29:14 +0530 Subject: [PATCH 1/4] PM-4928 Change member status when deactivating member --- .circleci/config.yml | 2 +- src/api/user/user.service.ts | 45 +++++++++++++++++++++++++++++++++++- 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index cba9536..d5031a6 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -68,7 +68,7 @@ workflows: branches: only: - develop - - pm-2539 + - PM-4928 # Production builds are exectuted only on tagged commits to the # master branch. diff --git a/src/api/user/user.service.ts b/src/api/user/user.service.ts index 00279fc..12e40bb 100644 --- a/src/api/user/user.service.ts +++ b/src/api/user/user.service.ts @@ -44,6 +44,7 @@ import { Decimal } from '@prisma/client/runtime/library'; import { Constants, DefaultGroups } from '../../core/constant/constants'; import { MemberPrismaService } from '../../shared/member-prisma/member-prisma.service'; import { MemberStatus } from '../../dto/member'; +import { MemberStatus as MemberDbStatus } from '../../../prisma/member/generated/member'; import { CommonUtils } from '../../shared/util/common.utils'; import { getProviderDetails } from '../../core/constant/provider-type.enum'; import { addMinutes } from 'date-fns'; @@ -130,6 +131,25 @@ export class UserService { } } + private mapIdentityStatusToMemberStatus( + identityStatus: string, + ): MemberDbStatus | undefined { + switch (identityStatus) { + case MemberStatus.ACTIVE: + return MemberDbStatus.ACTIVE; + case MemberStatus.UNVERIFIED: + return MemberDbStatus.UNVERIFIED; + case MemberStatus.INACTIVE_USER_REQUEST: + return MemberDbStatus.INACTIVE_USER_REQUEST; + case MemberStatus.INACTIVE_DUPLICATE_ACCOUNT: + return MemberDbStatus.INACTIVE_DUPLICATE_ACCOUNT; + case MemberStatus.INACTIVE_IRREGULAR_ACCOUNT: + return MemberDbStatus.INACTIVE_IRREGULAR_ACCOUNT; + default: + return undefined; + } + } + // --- Core User Methods --- async findUsers( @@ -2092,6 +2112,29 @@ export class UserService { `Successfully updated status for user ${userId} from ${oldStatus} to ${normalizedNewStatus}`, ); + const mappedMemberStatus = + this.mapIdentityStatusToMemberStatus(normalizedNewStatus); + if (mappedMemberStatus) { + try { + await this.memberPrisma.member.update({ + where: { userId: Number(userId) }, + data: { status: mappedMemberStatus }, + }); + this.logger.log( + `Updated members.member for user ${userId}: status ${mappedMemberStatus}`, + ); + } catch (memberUpdateError) { + this.logger.error( + `Failed to sync members.member status for user ${userId}: ${memberUpdateError.message}`, + memberUpdateError.stack, + ); + } + } else { + this.logger.warn( + `No members.member status mapping for identity status '${normalizedNewStatus}'`, + ); + } + // create user achievement if (CommonUtils.validateString(comment)) { await this.createUserAchievement(userId, comment); @@ -2679,7 +2722,7 @@ export class UserService { from: { email: fromEmail }, version: 'v3', sendgrid_template_id: welcomeTemplateId, - recipients: [emailAddress], + recipients: [emailAddress], }; await this.eventService.postDirectBusMessage( 'external.action.email', From 9f02a0a2df97ea00d92e07814433ccc85ae09c50 Mon Sep 17 00:00:00 2001 From: himaniraghav3 Date: Thu, 23 Apr 2026 16:18:28 +0530 Subject: [PATCH 2/4] Add backfill script --- package.json | 1 + .../backfill-member-status-from-identity.ts | 237 ++++++++++++++++++ 2 files changed, 238 insertions(+) create mode 100644 scripts/backfill-member-status-from-identity.ts diff --git a/package.json b/package.json index 5929a33..5868828 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "db:migrate-to-identity": "npx ts-node scripts/migrate-to-identity.ts", "db:load-to-identity": "npx ts-node scripts/load-to-identity.ts", "db:backfill-security-user": "npx ts-node scripts/backfill-security-user.ts", + "db:backfill-member-status-from-identity": "npx ts-node scripts/backfill-member-status-from-identity.ts", "test:postman:roles": "newman run 'doc/roles api.postman_collection.json' -e doc/postman_environment.json -r cli,json --reporter-json-export roles-report.json", "test:postman:users": "newman run doc/users.postman_collection.json -e doc/postman_environment.json -r cli,json --reporter-json-export users-report.json", "test:postman:group": "newman run \"doc/User GroupResource - Topcoder.postman_collection.json\" -e doc/postman_environment.json -r cli,json --reporter-json-export GroupResource-report.json", diff --git a/scripts/backfill-member-status-from-identity.ts b/scripts/backfill-member-status-from-identity.ts new file mode 100644 index 0000000..b1f7029 --- /dev/null +++ b/scripts/backfill-member-status-from-identity.ts @@ -0,0 +1,237 @@ +/* eslint-disable no-console */ +import { PrismaClient } from '@prisma/client'; +import { + MemberStatus as MemberDbStatus, + PrismaClient as MemberPrismaClient, +} from '../prisma/member/generated/member'; + +type CliOptions = { + apply: boolean; + batchSize: number; + limit?: number; +}; + +type IdentityStatus = string; + +const DEFAULT_BATCH_SIZE = 500; + +function parseArgs(argv: string[]): CliOptions { + const options: CliOptions = { + apply: false, + batchSize: DEFAULT_BATCH_SIZE, + }; + + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + + if (arg === '--apply') { + options.apply = true; + continue; + } + + if (arg === '--batch-size') { + const raw = argv[i + 1]; + if (!raw) { + throw new Error('--batch-size expects a numeric value'); + } + options.batchSize = parsePositiveInt(raw, '--batch-size'); + i += 1; + continue; + } + + if (arg.startsWith('--batch-size=')) { + options.batchSize = parsePositiveInt( + arg.substring('--batch-size='.length), + '--batch-size', + ); + continue; + } + + if (arg === '--limit') { + const raw = argv[i + 1]; + if (!raw) { + throw new Error('--limit expects a numeric value'); + } + options.limit = parsePositiveInt(raw, '--limit'); + i += 1; + continue; + } + + if (arg.startsWith('--limit=')) { + options.limit = parsePositiveInt(arg.substring('--limit='.length), '--limit'); + continue; + } + + if (arg === '--help' || arg === '-h') { + printUsage(); + process.exit(0); + } + + throw new Error(`Unknown argument: ${arg}`); + } + + return options; +} + +function parsePositiveInt(value: string, flag: string): number { + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed) || parsed <= 0) { + throw new Error(`${flag} must be a positive integer`); + } + return parsed; +} + +function printUsage(): void { + console.log(`Backfill members.member.status from identity.user.status + +Usage: + npx ts-node scripts/backfill-member-status-from-identity.ts [--apply] [--batch-size N] [--limit N] + +Behavior: + - Select users where identity.user.status != 'A' (active=false in /v6/users semantics) + - Keep only rows where members.member.status is currently ACTIVE + - Map identity status to members status and update mismatches + +Modes: + - default: dry-run (prints summary, no writes) + - --apply: perform updates +`); +} + +function mapIdentityToMemberStatus( + identityStatus: IdentityStatus, +): MemberDbStatus | null { + switch (identityStatus) { + case 'U': + return MemberDbStatus.UNVERIFIED; + case '4': + return MemberDbStatus.INACTIVE_USER_REQUEST; + case '5': + return MemberDbStatus.INACTIVE_DUPLICATE_ACCOUNT; + case '6': + return MemberDbStatus.INACTIVE_IRREGULAR_ACCOUNT; + case 'I': + // identity has a generic inactive; member DB does not, pick the broad inactive bucket + return MemberDbStatus.INACTIVE_USER_REQUEST; + default: + return null; + } +} + +async function main(): Promise { + const options = parseArgs(process.argv.slice(2)); + const identityPrisma = new PrismaClient(); + const memberPrisma = new MemberPrismaClient(); + + let scanned = 0; + let candidates = 0; + let updates = 0; + let skippedUnknownIdentityStatus = 0; + let skippedAlreadySynced = 0; + + try { + await identityPrisma.$connect(); + await memberPrisma.$connect(); + + console.log( + `[config] mode=${options.apply ? 'apply' : 'dry-run'}, batchSize=${options.batchSize}, limit=${options.limit ?? 'none'}`, + ); + + let cursorUserId: number | undefined; + + while (true) { + const users = await identityPrisma.user.findMany({ + where: { status: { not: 'A' } }, + select: { user_id: true, status: true }, + orderBy: { user_id: 'asc' }, + take: options.batchSize, + ...(cursorUserId + ? { + cursor: { user_id: cursorUserId }, + skip: 1, + } + : {}), + }); + + if (users.length === 0) { + break; + } + + cursorUserId = Number(users[users.length - 1].user_id); + scanned += users.length; + + const userIds = users.map((u) => BigInt(String(u.user_id))); + const memberRows = await memberPrisma.member.findMany({ + where: { + userId: { in: userIds }, + status: MemberDbStatus.ACTIVE, + }, + select: { userId: true, status: true }, + }); + const activeMemberIds = new Set(memberRows.map((m) => m.userId.toString())); + + for (const user of users) { + if (options.limit && candidates >= options.limit) { + break; + } + + const idStr = String(user.user_id); + if (!activeMemberIds.has(idStr)) { + continue; + } + + const mappedStatus = mapIdentityToMemberStatus(user.status); + if (!mappedStatus) { + skippedUnknownIdentityStatus += 1; + continue; + } + + // We only selected members currently ACTIVE, so this is always a mismatch candidate. + candidates += 1; + + if (!options.apply) { + if (candidates <= 20) { + console.log(`[dry-run] userId=${idStr}, identity=${user.status} -> member=${mappedStatus}`); + } + continue; + } + + const result = await memberPrisma.member.updateMany({ + where: { + userId: BigInt(idStr), + status: MemberDbStatus.ACTIVE, + }, + data: { + status: mappedStatus, + }, + }); + + if (result.count > 0) { + updates += result.count; + } else { + skippedAlreadySynced += 1; + } + } + + if (options.limit && candidates >= options.limit) { + break; + } + } + + console.log('--- summary ---'); + console.log(`identity scanned (status != 'A'): ${scanned}`); + console.log(`candidate mismatches found: ${candidates}`); + console.log(`updated: ${updates}`); + console.log(`skipped unknown identity status: ${skippedUnknownIdentityStatus}`); + console.log(`skipped already synced/racing: ${skippedAlreadySynced}`); + console.log(`mode: ${options.apply ? 'apply' : 'dry-run'}`); + } finally { + await identityPrisma.$disconnect(); + await memberPrisma.$disconnect(); + } +} + +void main().catch((error) => { + console.error('[error] Backfill failed:', error); + process.exitCode = 1; +}); From 069f397a31fe3b1ac2d1696880007d294a7ac977 Mon Sep 17 00:00:00 2001 From: himaniraghav3 Date: Mon, 27 Apr 2026 15:10:15 +0530 Subject: [PATCH 3/4] Filter only Inactive status in identity db --- .../backfill-member-status-from-identity.ts | 31 ++++++++++++++----- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/scripts/backfill-member-status-from-identity.ts b/scripts/backfill-member-status-from-identity.ts index b1f7029..40d74f9 100644 --- a/scripts/backfill-member-status-from-identity.ts +++ b/scripts/backfill-member-status-from-identity.ts @@ -14,7 +14,7 @@ type CliOptions = { type IdentityStatus = string; const DEFAULT_BATCH_SIZE = 500; - +const TARGET_IDENTITY_STATUSES: IdentityStatus[] = ['I', '4', '5', '6']; function parseArgs(argv: string[]): CliOptions { const options: CliOptions = { apply: false, @@ -88,7 +88,7 @@ Usage: npx ts-node scripts/backfill-member-status-from-identity.ts [--apply] [--batch-size N] [--limit N] Behavior: - - Select users where identity.user.status != 'A' (active=false in /v6/users semantics) + - Select users where identity.user.status is one of: I, 4, 5, 6 - Keep only rows where members.member.status is currently ACTIVE - Map identity status to members status and update mismatches @@ -128,6 +128,9 @@ async function main(): Promise { let updates = 0; let skippedUnknownIdentityStatus = 0; let skippedAlreadySynced = 0; + let totalMemberRecords = 0; + let totalActiveMemberRecords = 0; + let totalIdentityRecords = 0; try { await identityPrisma.$connect(); @@ -137,11 +140,20 @@ async function main(): Promise { `[config] mode=${options.apply ? 'apply' : 'dry-run'}, batchSize=${options.batchSize}, limit=${options.limit ?? 'none'}`, ); + [totalMemberRecords, totalActiveMemberRecords, totalIdentityRecords] = + await Promise.all([ + memberPrisma.member.count(), + memberPrisma.member.count({ + where: { status: MemberDbStatus.ACTIVE }, + }), + identityPrisma.user.count(), + ]); + let cursorUserId: number | undefined; while (true) { const users = await identityPrisma.user.findMany({ - where: { status: { not: 'A' } }, + where: { status: { in: TARGET_IDENTITY_STATUSES } }, select: { user_id: true, status: true }, orderBy: { user_id: 'asc' }, take: options.batchSize, @@ -168,7 +180,9 @@ async function main(): Promise { }, select: { userId: true, status: true }, }); - const activeMemberIds = new Set(memberRows.map((m) => m.userId.toString())); + const memberStatusById = new Map( + memberRows.map((m) => [m.userId.toString(), m.status]), + ); for (const user of users) { if (options.limit && candidates >= options.limit) { @@ -176,7 +190,8 @@ async function main(): Promise { } const idStr = String(user.user_id); - if (!activeMemberIds.has(idStr)) { + const currentMemberStatus = memberStatusById.get(idStr); + if (!currentMemberStatus) { continue; } @@ -186,7 +201,6 @@ async function main(): Promise { continue; } - // We only selected members currently ACTIVE, so this is always a mismatch candidate. candidates += 1; if (!options.apply) { @@ -219,7 +233,10 @@ async function main(): Promise { } console.log('--- summary ---'); - console.log(`identity scanned (status != 'A'): ${scanned}`); + console.log(`identity total records: ${totalIdentityRecords}`); + console.log(`identity scanned (status in I,4,5,6): ${scanned}`); + console.log(`member total records: ${totalMemberRecords}`); + console.log(`member total active records: ${totalActiveMemberRecords}`); console.log(`candidate mismatches found: ${candidates}`); console.log(`updated: ${updates}`); console.log(`skipped unknown identity status: ${skippedUnknownIdentityStatus}`); From 3a3d2fe7f6d44338fd63b384a81c77b253812165 Mon Sep 17 00:00:00 2001 From: himaniraghav3 Date: Tue, 28 Apr 2026 21:39:10 +0530 Subject: [PATCH 4/4] PM-4928 Update backfill script --- .../backfill-member-status-from-identity.ts | 71 +++++++++++++++---- 1 file changed, 58 insertions(+), 13 deletions(-) diff --git a/scripts/backfill-member-status-from-identity.ts b/scripts/backfill-member-status-from-identity.ts index 40d74f9..191fd33 100644 --- a/scripts/backfill-member-status-from-identity.ts +++ b/scripts/backfill-member-status-from-identity.ts @@ -1,4 +1,5 @@ /* eslint-disable no-console */ +import 'dotenv/config'; import { PrismaClient } from '@prisma/client'; import { MemberStatus as MemberDbStatus, @@ -15,6 +16,13 @@ type IdentityStatus = string; const DEFAULT_BATCH_SIZE = 500; const TARGET_IDENTITY_STATUSES: IdentityStatus[] = ['I', '4', '5', '6']; +const APPLY_LOG_FIRST_N = 20; +const APPLY_LOG_EVERY_N = 500; + +type UpdateBucket = { + status: MemberDbStatus; + userIds: bigint[]; +}; function parseArgs(argv: string[]): CliOptions { const options: CliOptions = { apply: false, @@ -184,6 +192,9 @@ async function main(): Promise { memberRows.map((m) => [m.userId.toString(), m.status]), ); + const updateBucketsByStatus = new Map(); + let batchCandidates = 0; + for (const user of users) { if (options.limit && candidates >= options.limit) { break; @@ -202,6 +213,7 @@ async function main(): Promise { } candidates += 1; + batchCandidates += 1; if (!options.apply) { if (candidates <= 20) { @@ -210,20 +222,53 @@ async function main(): Promise { continue; } - const result = await memberPrisma.member.updateMany({ - where: { - userId: BigInt(idStr), - status: MemberDbStatus.ACTIVE, - }, - data: { - status: mappedStatus, - }, - }); - - if (result.count > 0) { + if (candidates <= APPLY_LOG_FIRST_N) { + console.log( + `[apply-candidate] userId=${idStr}, identity=${user.status} -> member=${mappedStatus}`, + ); + } + + const bucket = updateBucketsByStatus.get(mappedStatus) ?? []; + bucket.push(BigInt(idStr)); + updateBucketsByStatus.set(mappedStatus, bucket); + } + + if (options.apply) { + const updateBuckets: UpdateBucket[] = Array.from( + updateBucketsByStatus.entries(), + ).map(([status, userIds]) => ({ status, userIds })); + + let batchUpdated = 0; + for (const bucket of updateBuckets) { + if (bucket.userIds.length === 0) { + continue; + } + + const result = await memberPrisma.member.updateMany({ + where: { + userId: { in: bucket.userIds }, + status: MemberDbStatus.ACTIVE, + }, + data: { + status: bucket.status, + }, + }); + + batchUpdated += result.count; updates += result.count; - } else { - skippedAlreadySynced += 1; + if (updates % APPLY_LOG_EVERY_N === 0 || result.count > 0) { + console.log( + `[apply] batch status=${bucket.status}, attempted=${bucket.userIds.length}, updated=${result.count}, totalUpdated=${updates}`, + ); + } + } + + const batchSkipped = batchCandidates - batchUpdated; + if (batchSkipped > 0) { + skippedAlreadySynced += batchSkipped; + console.log( + `[apply-skip] batch skipped=${batchSkipped}, likely race/already synced`, + ); } }