Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ workflows:
branches:
only:
- develop
- pm-2539
- PM-4928

# Production builds are exectuted only on tagged commits to the
# master branch.
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
237 changes: 237 additions & 0 deletions scripts/backfill-member-status-from-identity.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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;
});
45 changes: 44 additions & 1 deletion src/api/user/user.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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',
Expand Down
Loading