From a2eccf9c77cec365dd742373c539400b688df2ea Mon Sep 17 00:00:00 2001 From: rebelchris Date: Fri, 3 Jul 2026 11:21:32 +0000 Subject: [PATCH] fix: sync company email verification to work experiences MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a user verifies their company email, the verified state lives on UserCompany, while the profile UI reads UserExperienceWork.verified. The two are only linked via the onUserCompanyCompanyChange CDC worker, which reacts to companyId changes (creation-with-company or companyId change) — not to a plain verified flip. As a result: - verifyUserCompanyCode flips verified false->true without changing companyId, so the matching work experience is never marked verified. - The profile still shows the experience as unverified, so the user re-submits addUserCompany and hits 'This email has already been verified', trapping them in a dead-end loop. Fixes: - Add syncVerifiedUserWorkExperiences helper that marks matching Work experiences verified for a (userId, companyId). - Call it from verifyUserCompanyCode after the verified flip. - Make addUserCompany idempotent: when the email is already verified for the same user, re-assert experience verification and return success instead of throwing. Adds tests for both resolvers. --- __tests__/users.ts | 65 ++++++++++++++++++++++++++++++++++++--------- src/schema/users.ts | 45 ++++++++++++++++++++++++++++++- 2 files changed, 96 insertions(+), 14 deletions(-) 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,