diff --git a/__tests__/users.ts b/__tests__/users.ts index 96a4d9b9c9..48269b0544 100644 --- a/__tests__/users.ts +++ b/__tests__/users.ts @@ -122,6 +122,7 @@ import { import { Company } from '../src/entity/Company'; import { UserCompany } from '../src/entity/UserCompany'; import { UserExperience } from '../src/entity/user/experiences/UserExperience'; +import { UserExperienceWork } from '../src/entity/user/experiences/UserExperienceWork'; import { UserExperienceType } from '../src/entity/user/experiences/types'; import { SourceReport } from '../src/entity/sources/SourceReport'; import { SourceMemberRoles } from '../src/roles'; @@ -2524,20 +2525,35 @@ describe('user company', () => { code: '654321', userId: loggedUser, }); - return testQueryError( - client, - { query: QUERY, variables: { email: 'u1@com3.com' } }, - (errors) => { - expect(errors[0].extensions!.code).toEqual( - 'GRAPHQL_VALIDATION_FAILED', - ); - expect(errors[0].message).toEqual( - 'This email has already been verified', - ); + const res = await client.query(QUERY, { + variables: { email: 'u1@com3.com' }, + }); + expect(res.errors).toBeFalsy(); + expect(res.data.addUserCompany._).toBe(true); + expect(sendEmail).not.toHaveBeenCalled(); + }); - expect(sendEmail).not.toHaveBeenCalled(); - }, - ); + it('should re-assert work experience verification when email is already verified', async () => { + loggedUser = '1'; + // beforeEach seeds a verified UserCompany (companyId 1) and an unverified + // Work experience for company 1. Re-submitting the already-verified email + // should idempotently mark that experience verified instead of erroring. + const before = await con + .getRepository(UserExperienceWork) + .findOneByOrFail({ userId: loggedUser, companyId: '1' }); + expect(before.verified).toBe(false); + + const res = await client.query(QUERY, { + variables: { email: 'u1@com1.com' }, + }); + expect(res.errors).toBeFalsy(); + expect(res.data.addUserCompany._).toBe(true); + expect(sendEmail).not.toHaveBeenCalled(); + + const after = await con + .getRepository(UserExperienceWork) + .findOneByOrFail({ userId: loggedUser, companyId: '1' }); + expect(after.verified).toBe(true); }); }); @@ -2698,6 +2714,29 @@ describe('user company', () => { }); expect(row.verified).toBeTruthy(); }); + + it('should mark the matching work experience as verified', async () => { + loggedUser = '1'; + // Seed an unverified Work experience for company 3 (email u1@com3.com). + await con.getRepository(UserExperienceWork).save({ + userId: loggedUser, + companyId: '3', + title: 'Staff Engineer', + type: UserExperienceType.Work, + startedAt: new Date('2024-01-01'), + endedAt: null, + }); + + const res = await client.query(QUERY, { + variables: { email: 'u1@com3.com', code: '123' }, + }); + expect(res.errors).toBeFalsy(); + + const experience = await con + .getRepository(UserExperienceWork) + .findOneByOrFail({ userId: loggedUser, companyId: '3' }); + expect(experience.verified).toBe(true); + }); }); }); diff --git a/src/schema/users.ts b/src/schema/users.ts index 27fd277fb6..23e4f92b64 100644 --- a/src/schema/users.ts +++ b/src/schema/users.ts @@ -160,6 +160,7 @@ import { } from '../entity/UserIntegration'; import { Company } from '../entity/Company'; import { UserCompany } from '../entity/UserCompany'; +import { UserExperienceWork } from '../entity/user/experiences/UserExperienceWork'; import { generateVerifyCode } from '../ids'; import { addClaimableItemsToUser, @@ -2156,6 +2157,28 @@ const getUserCompanies = async ( ); }; +/** + * Ensure the user's Work experiences for a given company are marked verified, + * mirroring a verified UserCompany record. This closes the gap where verifying + * a company email (a `verified` flip that does not change `companyId`) never + * propagated to `UserExperienceWork.verified` via CDC, leaving the profile UI + * showing the experience as unverified while the backend already considers the + * email verified. + */ +const syncVerifiedUserWorkExperiences = async ( + con: DataSource, + userId: string, + companyId: string | null, +): Promise => { + if (!companyId) { + return; + } + + await con + .getRepository(UserExperienceWork) + .update({ userId, companyId, verified: false }, { verified: true }); +}; + interface ClearImagePreset { con: ConnectionManager; preset: UploadPreset; @@ -3893,7 +3916,17 @@ export const resolvers: IResolvers = { } if (existingUserCompanyEmail.verified) { - throw new ValidationError('This email has already been verified'); + // The email is already verified for this user. Rather than throwing + // (which traps the user in a re-verify loop when the profile UI still + // shows the experience as unverified), re-assert the work-experience + // verification and return success idempotently. + await syncVerifiedUserWorkExperiences( + ctx.con, + ctx.userId, + existingUserCompanyEmail.companyId, + ); + + return { _: true }; } const updatedRecord = { ...existingUserCompanyEmail, code }; @@ -3959,6 +3992,16 @@ export const resolvers: IResolvers = { await ctx.con.getRepository(UserCompany).save(updatedRecord); + // Propagate the verification to the matching Work experiences. The CDC + // worker only reacts to `companyId` changes, so a plain `verified` flip + // (companyId unchanged) would otherwise never mark the experience as + // verified. + await syncVerifiedUserWorkExperiences( + ctx.con, + ctx.userId, + updatedRecord.companyId, + ); + return await graphorm.queryOneOrFail( ctx, info,