Skip to content

Commit 96e89ac

Browse files
authored
Merge pull request #18 from topcoder-platform/migration
Update email in member DB when identity is also updated (PS-433)
2 parents 394113c + 47dccdf commit 96e89ac

File tree

4 files changed

+238
-13
lines changed

4 files changed

+238
-13
lines changed

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,13 @@ The following table summarizes the environment variables used by the application
128128
| `JWT_SECRET` | Secret key for signing/verifying internal JWTs (e.g., 2FA, one-time tokens). | `just-a-random-string` (example) |
129129
| `LEGACY_BLOWFISH_KEY` | Base64 encoded Blowfish key for legacy password encryption/decryption. | `dGhpc2lzRGVmYXVmZlZhbHVl` (example) |
130130

131+
### Migrating legacy social login data
132+
133+
- Run `npx ts-node scripts/migrate-user-social-login.ts` to copy legacy `user_social_login` rows into `identity.user_social_login`.
134+
- Set `SOURCE_IDENTITY_PG_URL` (legacy) and `IDENTITY_DB_URL` (target) before running; `USER_SOCIAL_LOGIN_BATCH_SIZE` tunes pagination.
135+
- Flags available: `--dry-run` (log only), `--truncate` (clear target before load; ignored during dry-run), and `--insert-missing-only` (skip rows that already exist in the target).
136+
- Ensure `identity.social_login_provider` is migrated first so foreign keys resolve during import.
137+
131138

132139
**Downstream Usage**
133140
--------------------
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
import { PrismaClient as TargetIdentityClient } from '@prisma/client';
2+
import { PrismaClient as SourceIdentityClient } from '../legacy_migrate/generated/source-identity';
3+
4+
const DEFAULT_BATCH_SIZE = 1000;
5+
6+
const parseBoolFlag = (flag: string) => process.argv.includes(flag);
7+
8+
const requiredEnv = (key: string) => {
9+
const value = process.env[key];
10+
if (!value) {
11+
throw new Error(`Missing required environment variable: ${key}`);
12+
}
13+
return value;
14+
};
15+
16+
async function migrateUserSocialLogin() {
17+
const dryRun = parseBoolFlag('--dry-run');
18+
const truncate = parseBoolFlag('--truncate');
19+
const batchSize = Number(process.env.USER_SOCIAL_LOGIN_BATCH_SIZE ?? DEFAULT_BATCH_SIZE);
20+
const insertMissingOnly = parseBoolFlag('--insert-missing-only');
21+
22+
if (Number.isNaN(batchSize) || batchSize <= 0) {
23+
throw new Error(`Invalid USER_SOCIAL_LOGIN_BATCH_SIZE: ${process.env.USER_SOCIAL_LOGIN_BATCH_SIZE}`);
24+
}
25+
26+
if (insertMissingOnly && truncate) {
27+
throw new Error('Cannot use --insert-missing-only together with --truncate');
28+
}
29+
30+
const sourceDbUrl = requiredEnv('SOURCE_IDENTITY_PG_URL');
31+
const targetDbUrl = requiredEnv('IDENTITY_DB_URL');
32+
33+
const sourceDb = new SourceIdentityClient({
34+
datasources: {
35+
db: { url: sourceDbUrl },
36+
},
37+
});
38+
39+
const targetDb = new TargetIdentityClient({
40+
datasources: {
41+
db: { url: targetDbUrl },
42+
},
43+
});
44+
45+
console.log(
46+
`Starting user_social_login migration (dryRun=${dryRun}, truncate=${truncate}, insertMissingOnly=${insertMissingOnly}, batchSize=${batchSize})`,
47+
);
48+
49+
try {
50+
const totalRows = await sourceDb.user_social_login.count();
51+
if (!totalRows) {
52+
console.log('No rows found in source user_social_login. Nothing to migrate.');
53+
return;
54+
}
55+
56+
console.log(`Found ${totalRows} rows to migrate from source user_social_login`);
57+
58+
if (truncate) {
59+
if (dryRun) {
60+
console.log('[Dry Run] Would truncate target identity.user_social_login');
61+
} else {
62+
console.log('Truncating target identity.user_social_login before import...');
63+
await targetDb.$executeRaw`TRUNCATE TABLE identity.user_social_login RESTART IDENTITY CASCADE`;
64+
}
65+
}
66+
67+
for (let offset = 0; offset < totalRows; offset += batchSize) {
68+
const batch = await sourceDb.user_social_login.findMany({
69+
orderBy: [{ user_id: 'asc' }, { social_login_provider_id: 'asc' }],
70+
skip: offset,
71+
take: batchSize,
72+
});
73+
74+
if (!batch.length) {
75+
break;
76+
}
77+
78+
if (dryRun) {
79+
console.log(`[Dry Run] Would migrate ${batch.length} rows (offset ${offset})`);
80+
continue;
81+
}
82+
83+
const recordsToInsert = batch.map((record) => ({
84+
social_user_id: record.social_user_id ?? null,
85+
user_id: Number(record.user_id),
86+
social_login_provider_id: Number(record.social_login_provider_id),
87+
social_user_name: record.social_user_name,
88+
social_email: record.social_email ?? null,
89+
social_email_verified: record.social_email_verified ?? null,
90+
create_date: record.create_date ?? undefined,
91+
modify_date: record.modify_date ?? undefined,
92+
}));
93+
94+
if (insertMissingOnly) {
95+
const existing = await targetDb.user_social_login.findMany({
96+
where: {
97+
OR: recordsToInsert.map((rec) => ({
98+
user_id: rec.user_id,
99+
social_login_provider_id: rec.social_login_provider_id,
100+
})),
101+
},
102+
select: {
103+
user_id: true,
104+
social_login_provider_id: true,
105+
},
106+
});
107+
108+
const existingKey = new Set(existing.map((row) => `${row.user_id}-${row.social_login_provider_id}`));
109+
const missing = recordsToInsert.filter(
110+
(rec) => !existingKey.has(`${rec.user_id}-${rec.social_login_provider_id}`),
111+
);
112+
113+
if (!missing.length) {
114+
console.log(`Batch at offset ${offset} skipped (all ${recordsToInsert.length} rows already present)`);
115+
continue;
116+
}
117+
118+
await targetDb.user_social_login.createMany({
119+
data: missing,
120+
skipDuplicates: true,
121+
});
122+
123+
console.log(
124+
`Migrated ${Math.min(offset + batch.length, totalRows)} / ${totalRows} rows (inserted ${missing.length}, skipped ${
125+
recordsToInsert.length - missing.length
126+
})`,
127+
);
128+
continue;
129+
}
130+
131+
await targetDb.user_social_login.createMany({
132+
data: recordsToInsert,
133+
skipDuplicates: true,
134+
});
135+
136+
console.log(`Migrated ${Math.min(offset + batch.length, totalRows)} / ${totalRows} rows`);
137+
}
138+
139+
console.log('user_social_login migration completed');
140+
} finally {
141+
await Promise.allSettled([sourceDb.$disconnect(), targetDb.$disconnect()]);
142+
}
143+
}
144+
145+
migrateUserSocialLogin().catch((error) => {
146+
console.error('user_social_login migration failed:', error);
147+
process.exit(1);
148+
});

src/api/user/user.service.spec.ts

Lines changed: 59 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -138,11 +138,13 @@ const mockEventService: jest.Mocked<Partial<EventService>> = {
138138
postDirectBusMessage: jest.fn(),
139139
};
140140

141-
const mockMemberPrisma: Partial<MemberPrismaService> = {
141+
const memberUpdateMock = jest.fn();
142+
const mockMemberPrisma: any = {
142143
// Only the parts used by UserService need to be mocked
143144
member: {
144145
create: jest.fn(),
145-
} as any,
146+
update: memberUpdateMock,
147+
},
146148
};
147149

148150
const mockConfigService = {
@@ -1562,6 +1564,8 @@ describe('UserService', () => {
15621564
beforeEach(() => {
15631565
jest.clearAllMocks();
15641566

1567+
memberUpdateMock.mockResolvedValue(undefined);
1568+
15651569
// Mock checkEmailAvailabilityForUser
15661570
mockCheckEmail = jest
15671571
.spyOn(service, 'checkEmailAvailabilityForUser')
@@ -1652,6 +1656,10 @@ describe('UserService', () => {
16521656
{ userId: 1, handle: 'testuser' },
16531657
);
16541658
expect(result).toEqual(mockUser);
1659+
expect(memberUpdateMock).toHaveBeenCalledWith({
1660+
where: { userId },
1661+
data: { email: newEmail.toLowerCase() },
1662+
});
16551663
});
16561664

16571665
it('should throw BadRequestException for invalid user ID format', async () => {
@@ -1863,6 +1871,48 @@ describe('UserService', () => {
18631871
);
18641872
});
18651873

1874+
it('should log an error if members.member update fails but continue', async () => {
1875+
const txMock = {
1876+
user: {
1877+
findUnique: jest.fn().mockResolvedValue(mockUser),
1878+
update: jest.fn().mockResolvedValue(mockUser),
1879+
},
1880+
email: {
1881+
findFirst: jest
1882+
.fn()
1883+
.mockResolvedValueOnce(mockCurrentEmailRecord)
1884+
.mockResolvedValueOnce(null),
1885+
update: jest.fn().mockResolvedValue(mockUpdatedEmailRecord),
1886+
},
1887+
};
1888+
mockPrismaOltp.$transaction.mockImplementation(
1889+
<T>(callback): Promise<T> => {
1890+
const result = callback(txMock);
1891+
return result instanceof Promise ? result : Promise.resolve(result);
1892+
},
1893+
);
1894+
1895+
memberUpdateMock.mockRejectedValueOnce(
1896+
new Error('Member update failed'),
1897+
);
1898+
1899+
const result = await service.updatePrimaryEmail(
1900+
userIdString,
1901+
newEmail,
1902+
mockAuthUser,
1903+
);
1904+
1905+
expect(result).toEqual(mockUser);
1906+
expect(memberUpdateMock).toHaveBeenCalledWith({
1907+
where: { userId },
1908+
data: { email: newEmail.toLowerCase() },
1909+
});
1910+
expect(loggerErrorSpy).toHaveBeenCalledWith(
1911+
expect.stringContaining('Failed to update members.member email'),
1912+
expect.any(String),
1913+
);
1914+
});
1915+
18661916
it('should handle case when updated email record is not found after transaction', async () => {
18671917
// Set up transaction mock
18681918
const txMock = {
@@ -1965,6 +2015,7 @@ describe('UserService', () => {
19652015
const userIdString = '1';
19662016
const oldStatus = 'U';
19672017
const newStatus = 'A'; // Unverified to Active
2018+
const statusComment = 'Unit test comment';
19682019
const mockExistingUser = createMockUserModel({
19692020
user_id: new Decimal(userId),
19702021
status: oldStatus,
@@ -1992,6 +2043,7 @@ describe('UserService', () => {
19922043
userIdString,
19932044
newStatus,
19942045
mockUser,
2046+
statusComment,
19952047
);
19962048

19972049
expect(prismaOltp.user.update).toHaveBeenCalledWith({
@@ -2023,7 +2075,7 @@ describe('UserService', () => {
20232075
prismaOltp.user.findUnique.mockResolvedValue(fromActiveUser);
20242076
prismaOltp.user.update.mockResolvedValue(toInactiveUser);
20252077

2026-
await service.updateStatus(userIdString, 'I', mockUser);
2078+
await service.updateStatus(userIdString, 'I', mockUser, statusComment);
20272079
expect(mockEventService.postEnvelopedNotification).toHaveBeenCalledWith(
20282080
'event.user.deactivated',
20292081
service.toCamelCase(toInactiveUser),
@@ -2037,17 +2089,17 @@ describe('UserService', () => {
20372089

20382090
it('should throw BadRequest for invalid user ID or status code', async () => {
20392091
await expect(
2040-
service.updateStatus('abc', newStatus, mockUser),
2092+
service.updateStatus('abc', newStatus, mockUser, statusComment),
20412093
).rejects.toThrow(BadRequestException);
20422094
await expect(
2043-
service.updateStatus(userIdString, 'X', mockUser),
2095+
service.updateStatus(userIdString, 'X', mockUser, statusComment),
20442096
).rejects.toThrow(BadRequestException);
20452097
});
20462098

20472099
it('should throw NotFoundException if user not found', async () => {
20482100
prismaOltp.user.findUnique.mockResolvedValue(null);
20492101
await expect(
2050-
service.updateStatus(userIdString, newStatus, mockUser),
2102+
service.updateStatus(userIdString, newStatus, mockUser, statusComment),
20512103
).rejects.toThrow(NotFoundException);
20522104
});
20532105

@@ -2056,6 +2108,7 @@ describe('UserService', () => {
20562108
userIdString,
20572109
oldStatus,
20582110
mockUser,
2111+
statusComment,
20592112
);
20602113
expect(prismaOltp.user.update).not.toHaveBeenCalled();
20612114
expect(result).toEqual(mockExistingUser);

src/api/user/user.service.ts

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1451,6 +1451,8 @@ export class UserService {
14511451
if (isNaN(userId)) {
14521452
throw new BadRequestException('Invalid user ID format.');
14531453
}
1454+
const normalizedEmail = newEmail.toLowerCase();
1455+
let emailChanged = false;
14541456

14551457
this.logger.log(
14561458
`Attempting to update primary email for user ID: ${userId} to ${newEmail} by admin ${authUser.userId}`,
@@ -1512,10 +1514,11 @@ export class UserService {
15121514
await tx.email.update({
15131515
where: { email_id: currentPrimaryEmailRecord.email_id },
15141516
data: {
1515-
address: newEmail.toLowerCase(),
1517+
address: normalizedEmail,
15161518
modify_date: new Date(),
15171519
},
15181520
});
1521+
emailChanged = true;
15191522

15201523
this.logger.log(
15211524
`Updated existing primary email record ${currentPrimaryEmailRecord.email_id.toNumber()} from ${oldEmail} to ${newEmail} for user ${userId}`,
@@ -1554,6 +1557,23 @@ export class UserService {
15541557
);
15551558
}
15561559

1560+
if (emailChanged) {
1561+
try {
1562+
await this.memberPrisma.member.update({
1563+
where: { userId },
1564+
data: { email: normalizedEmail },
1565+
});
1566+
this.logger.log(
1567+
`Updated members.member email to ${normalizedEmail} for user ${userId}`,
1568+
);
1569+
} catch (error) {
1570+
this.logger.error(
1571+
`Failed to update members.member email for user ${userId}: ${error.message}`,
1572+
error.stack,
1573+
);
1574+
}
1575+
}
1576+
15571577
return updatedUserInTx;
15581578
}
15591579

@@ -2172,8 +2192,7 @@ export class UserService {
21722192

21732193
// Send Welcome Email directly, matching legacy Java behavior
21742194
if (emailAddress && user?.handle) {
2175-
const domain =
2176-
this.configService.get<string>('APP_DOMAIN') || 'topcoder-dev.com';
2195+
const domain = CommonUtils.getAppDomain(this.configService);
21772196
const fromEmail = `Topcoder <noreply@${domain}>`;
21782197
let welcomeTemplateId = this.configService.get<string>(
21792198
'SENDGRID_WELCOME_EMAIL_TEMPLATE_ID',
@@ -2289,8 +2308,7 @@ export class UserService {
22892308
email: string,
22902309
regSource?: string,
22912310
) {
2292-
const domain =
2293-
this.configService.get<string>('APP_DOMAIN') || 'topcoder-dev.com';
2311+
const domain = CommonUtils.getAppDomain(this.configService);
22942312
const fromEmail = `Topcoder <noreply@${domain}>`;
22952313
const sendGridTemplateId = this.configService.get<string>(
22962314
'SENDGRID_RESEND_ACTIVATION_EMAIL_TEMPLATE_ID',
@@ -2349,8 +2367,7 @@ export class UserService {
23492367
async resendActivationEmailEvent(userOtp: UserOtpDto, primaryEmail: string) {
23502368
try {
23512369
// For activation email (resend), use postDirectBusMessage to match legacy Java structure
2352-
const domain =
2353-
this.configService.get<string>('APP_DOMAIN') || 'topcoder-dev.com';
2370+
const domain = CommonUtils.getAppDomain(this.configService);
23542371
const fromEmail = `Topcoder <noreply@${domain}>`;
23552372
// Use the specific template ID for resending activation emails
23562373
const sendgridTemplateId = this.configService.get<string>(

0 commit comments

Comments
 (0)