From 802a37a5f59768118624abe484f1c7c1848a4153 Mon Sep 17 00:00:00 2001 From: Amar Trebinjac Date: Tue, 13 Jan 2026 16:51:18 +0100 Subject: [PATCH 1/8] feat: custom domain for experience --- __tests__/schema/profile.ts | 241 ++++++++++++++++++ src/common/companyEnrichment.ts | 2 +- src/common/schema/profile.ts | 6 +- src/entity/user/experiences/UserExperience.ts | 3 + src/graphorm/index.ts | 7 + src/schema/profile.ts | 122 +++++++-- src/workers/cdc/primary.ts | 17 +- 7 files changed, 363 insertions(+), 35 deletions(-) diff --git a/__tests__/schema/profile.ts b/__tests__/schema/profile.ts index 31f0c4b850..6e174d5071 100644 --- a/__tests__/schema/profile.ts +++ b/__tests__/schema/profile.ts @@ -12,6 +12,7 @@ import { import { User } from '../../src/entity'; import { usersFixture } from '../fixture/user'; 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 { Company } from '../../src/entity/Company'; import { UserExperienceSkill } from '../../src/entity/user/experiences/UserExperienceSkill'; @@ -1774,3 +1775,243 @@ describe('mutation removeUserExperience', () => { expect(res.errors).toBeFalsy(); }); }); + +describe('UserExperience image field', () => { + const USER_EXPERIENCE_IMAGE_QUERY = /* GraphQL */ ` + query UserExperienceById($id: ID!) { + userExperienceById(id: $id) { + id + image + customDomain + company { + id + image + } + } + } + `; + + it('should return company image when experience has companyId', async () => { + loggedUser = '1'; + + // exp-1 has companyId 'company-1' which has image 'https://daily.dev/logo.png' + const res = await client.query(USER_EXPERIENCE_IMAGE_QUERY, { + variables: { id: 'f47ac10b-58cc-4372-a567-0e02b2c3d479' }, + }); + + expect(res.errors).toBeFalsy(); + expect(res.data.userExperienceById.company.image).toBe( + 'https://daily.dev/logo.png', + ); + expect(res.data.userExperienceById.image).toBe( + 'https://daily.dev/logo.png', + ); + }); + + it('should return customImage from flags when no companyId', async () => { + loggedUser = '1'; + + // Create experience with customImage in flags but no companyId + const experienceId = 'e5f6a7b8-9abc-4ef0-1234-567890123456'; + await con.getRepository(UserExperience).save({ + id: experienceId, + userId: '1', + companyId: null, + customCompanyName: 'Custom Company', + title: 'Developer', + startedAt: new Date('2023-01-01'), + type: UserExperienceType.Work, + flags: { + customDomain: 'https://custom.com', + customImage: + 'https://www.google.com/s2/favicons?domain=custom.com&sz=128', + }, + }); + + const res = await client.query(USER_EXPERIENCE_IMAGE_QUERY, { + variables: { id: experienceId }, + }); + + expect(res.errors).toBeFalsy(); + expect(res.data.userExperienceById.company).toBeNull(); + expect(res.data.userExperienceById.customDomain).toBe('https://custom.com'); + expect(res.data.userExperienceById.image).toBe( + 'https://www.google.com/s2/favicons?domain=custom.com&sz=128', + ); + }); + + it('should prioritize company image over customImage when both exist', async () => { + loggedUser = '1'; + + // Create experience with both companyId AND customImage in flags + const experienceId = 'f6a7b8c9-abcd-4f01-2345-678901234567'; + await con.getRepository(UserExperience).save({ + id: experienceId, + userId: '1', + companyId: 'company-1', // Has image 'https://daily.dev/logo.png' + title: 'Engineer', + startedAt: new Date('2023-01-01'), + type: UserExperienceType.Work, + flags: { + customDomain: 'https://other.com', + customImage: + 'https://www.google.com/s2/favicons?domain=other.com&sz=128', + }, + }); + + const res = await client.query(USER_EXPERIENCE_IMAGE_QUERY, { + variables: { id: experienceId }, + }); + + expect(res.errors).toBeFalsy(); + // Company image should take priority + expect(res.data.userExperienceById.company.image).toBe( + 'https://daily.dev/logo.png', + ); + expect(res.data.userExperienceById.image).toBe( + 'https://daily.dev/logo.png', + ); + // customDomain should still be accessible + expect(res.data.userExperienceById.customDomain).toBe('https://other.com'); + }); + + it('should return null image when neither companyId nor customImage exists', async () => { + loggedUser = '1'; + + // Create experience with no companyId and no customImage + const experienceId = 'a7b8c9d0-bcde-4012-3456-789012345678'; + await con.getRepository(UserExperience).save({ + id: experienceId, + userId: '1', + companyId: null, + customCompanyName: 'No Image Company', + title: 'Intern', + startedAt: new Date('2023-01-01'), + type: UserExperienceType.Work, + flags: {}, + }); + + const res = await client.query(USER_EXPERIENCE_IMAGE_QUERY, { + variables: { id: experienceId }, + }); + + expect(res.errors).toBeFalsy(); + expect(res.data.userExperienceById.company).toBeNull(); + expect(res.data.userExperienceById.image).toBeNull(); + expect(res.data.userExperienceById.customDomain).toBeNull(); + }); + + it('should still link to existing company when customDomain is provided', async () => { + loggedUser = '1'; + + const UPSERT_WORK_MUTATION = /* GraphQL */ ` + mutation UpsertUserWorkExperience( + $input: UserExperienceWorkInput! + $id: ID + ) { + upsertUserWorkExperience(input: $input, id: $id) { + id + image + customDomain + customCompanyName + company { + id + name + image + } + } + } + `; + + // Create work experience with customCompanyName that matches existing company "Daily.dev" + // and also provide customDomain - should still link to the existing company + // customDomain is stored as a fallback, but company image takes priority + const res = await client.mutate(UPSERT_WORK_MUTATION, { + variables: { + input: { + type: 'work', + title: 'Engineer', + startedAt: new Date('2023-01-01'), + customCompanyName: 'Daily.dev', // This matches existing company-1 + customDomain: 'https://mycustomdomain.com', + }, + }, + }); + + expect(res.errors).toBeFalsy(); + // Should be linked to company since name matches + expect(res.data.upsertUserWorkExperience.company).not.toBeNull(); + expect(res.data.upsertUserWorkExperience.company.name).toBe('Daily.dev'); + // customCompanyName should be null since we linked to company + expect(res.data.upsertUserWorkExperience.customCompanyName).toBeNull(); + // customDomain should still be stored (but customImage is null since company was found) + expect(res.data.upsertUserWorkExperience.customDomain).toBe( + 'https://mycustomdomain.com', + ); + // Image should come from company (priority over customImage) + expect(res.data.upsertUserWorkExperience.image).toBe( + 'https://daily.dev/logo.png', + ); + }); + + it('should set removedEnrichment flag and verified=false when companyId is removed', async () => { + loggedUser = '1'; + + // Create experience with companyId + const experienceId = 'c9d0e1f2-def0-4234-5678-901234567890'; + await con.getRepository(UserExperience).save({ + id: experienceId, + userId: '1', + companyId: 'company-1', + title: 'Engineer', + startedAt: new Date('2023-01-01'), + type: UserExperienceType.Work, + flags: {}, + }); + + const UPSERT_WORK_MUTATION = /* GraphQL */ ` + mutation UpsertUserWorkExperience( + $input: UserExperienceWorkInput! + $id: ID + ) { + upsertUserWorkExperience(input: $input, id: $id) { + id + company { + id + } + customCompanyName + } + } + `; + + // Update to remove companyId (use customCompanyName instead) + const res = await client.mutate(UPSERT_WORK_MUTATION, { + variables: { + id: experienceId, + input: { + type: 'work', + title: 'Engineer', + startedAt: new Date('2023-01-01'), + customCompanyName: 'New Custom Company', + }, + }, + }); + + expect(res.errors).toBeFalsy(); + expect(res.data.upsertUserWorkExperience.company).toBeNull(); + expect(res.data.upsertUserWorkExperience.customCompanyName).toBe( + 'New Custom Company', + ); + + // Verify the flags and verified column in database + const updated = await con + .getRepository(UserExperience) + .findOne({ where: { id: experienceId } }); + expect(updated?.flags?.removedEnrichment).toBe(true); + // Note: verified is on UserExperienceWork, check it separately + const workExp = await con + .getRepository(UserExperienceWork) + .findOne({ where: { id: experienceId } }); + expect(workExp?.verified).toBe(false); + }); +}); diff --git a/src/common/companyEnrichment.ts b/src/common/companyEnrichment.ts index f46532c37d..a2b44579c7 100644 --- a/src/common/companyEnrichment.ts +++ b/src/common/companyEnrichment.ts @@ -102,7 +102,7 @@ async function validateDomain( return null; } -function getGoogleFaviconUrl(domain: string): string { +export function getGoogleFaviconUrl(domain: string): string { return `${GOOGLE_FAVICON_URL}?domain=${encodeURIComponent(domain)}&sz=${FAVICON_SIZE}`; } diff --git a/src/common/schema/profile.ts b/src/common/schema/profile.ts index e3ecc86def..93c114668f 100644 --- a/src/common/schema/profile.ts +++ b/src/common/schema/profile.ts @@ -34,7 +34,10 @@ export const userExperienceCertificationSchema = z .extend(userExperienceInputBaseSchema.shape); export const userExperienceEducationSchema = z - .object({ grade: z.string().nullish() }) + .object({ + grade: z.string().nullish(), + customDomain: z.url().nullish().default(null), + }) .extend(userExperienceInputBaseSchema.shape); export const userExperienceProjectSchema = z @@ -55,6 +58,7 @@ export const userExperienceWorkSchema = z .max(50) .optional() .default([]), + customDomain: z.url().nullish().default(null), }) .extend(userExperienceInputBaseSchema.shape); diff --git a/src/entity/user/experiences/UserExperience.ts b/src/entity/user/experiences/UserExperience.ts index ce4f14e7b8..e1bf13d581 100644 --- a/src/entity/user/experiences/UserExperience.ts +++ b/src/entity/user/experiences/UserExperience.ts @@ -19,6 +19,9 @@ import type { UserExperienceSkill } from './UserExperienceSkill'; export type UserExperienceFlags = Partial<{ import: string; + customDomain: string; + customImage: string; + removedEnrichment: boolean; }>; @Entity() diff --git a/src/graphorm/index.ts b/src/graphorm/index.ts index b47e506161..e0b6825421 100644 --- a/src/graphorm/index.ts +++ b/src/graphorm/index.ts @@ -1892,6 +1892,13 @@ const obj = new GraphORM({ customLocation: { jsonType: true, }, + image: { + select: (_, alias) => + `COALESCE((SELECT c.image FROM company c WHERE c.id = ${alias}."companyId"), ${alias}.flags->>'customImage')`, + }, + customDomain: { + select: (_, alias) => `${alias}.flags->>'customDomain'`, + }, }, }, OpportunityMatchCandidatePreference: { diff --git a/src/schema/profile.ts b/src/schema/profile.ts index ff0181b6ce..782e4d4da6 100644 --- a/src/schema/profile.ts +++ b/src/schema/profile.ts @@ -27,6 +27,8 @@ import { } from '../entity/user/experiences/UserExperienceSkill'; import { findOrCreateDatasetLocation } from '../entity/dataset/utils'; import { User } from '../entity/user/User'; +import { getGoogleFaviconUrl } from '../common/companyEnrichment'; + interface GQLUserExperience { id: string; type: UserExperienceType; @@ -78,6 +80,8 @@ export const typeDefs = /* GraphQL */ ` customCompanyName: String customLocation: Location isOwner: Boolean + image: String + customDomain: String # custom props per child entity url: String @@ -125,6 +129,7 @@ export const typeDefs = /* GraphQL */ ` url: String grade: String externalReferenceId: String + customDomain: String } input UserExperienceWorkInput { @@ -133,6 +138,7 @@ export const typeDefs = /* GraphQL */ ` locationType: ProtoEnumValue employmentType: ProtoEnumValue skills: [String] + customDomain: String } extend type Mutation { @@ -160,6 +166,46 @@ interface ExperienceMutationArgs { id?: string; } +interface ResolvedCompanyState { + companyId: string | null; + customCompanyName: string | null; +} + +const resolveCompanyState = async ( + ctx: AuthContext, + inputCompanyId: string | null | undefined, + inputCustomCompanyName: string | null | undefined, + userRemovingCompany: boolean, +): Promise => { + if (inputCompanyId) { + await ctx.con.getRepository(Company).findOneOrFail({ + where: { id: inputCompanyId }, + }); + return { companyId: inputCompanyId, customCompanyName: null }; + } + + if (inputCustomCompanyName && !userRemovingCompany) { + const existingCompany = await ctx.con + .getRepository(Company) + .createQueryBuilder('c') + .where('LOWER(c.name) = :name', { + name: inputCustomCompanyName.toLowerCase(), + }) + .getOne(); + + if (existingCompany) { + return { companyId: existingCompany.id, customCompanyName: null }; + } + return { companyId: null, customCompanyName: inputCustomCompanyName }; + } + + if (inputCustomCompanyName) { + return { companyId: null, customCompanyName: inputCustomCompanyName }; + } + + return { companyId: null, customCompanyName: null }; +}; + const generateExperienceToSave = async < T extends BaseInputSchema, R extends z.core.output, @@ -169,41 +215,60 @@ const generateExperienceToSave = async < ): Promise<{ userExperience: Partial; parsedInput: R; + removedCompanyId: boolean; }> => { const schema = getExperienceSchema(input.type); const parsed = schema.parse(input) as R; - const { customCompanyName, companyId, ...values } = parsed; + const { customCompanyName, companyId, customDomain, ...values } = + parsed as R & { customDomain?: string | null }; - const toUpdate = id + const toUpdate: Partial = id ? await ctx.con .getRepository(UserExperience) .findOneOrFail({ where: { id, userId: ctx.userId } }) - : await Promise.resolve({}); + : {}; - const toSave: Partial = { ...values, companyId }; + const isUpdate = !!id; + const hadCompanyId = !!toUpdate.companyId; + const userRemovingCompany = isUpdate && hadCompanyId && !companyId; - if (companyId) { - await ctx.con.getRepository(Company).findOneOrFail({ - where: { id: companyId }, - }); - toSave.customCompanyName = null; - } else if (customCompanyName) { - const existingCompany = await ctx.con - .getRepository(Company) - .createQueryBuilder('c') - .where('LOWER(c.name) = :name', { name: customCompanyName.toLowerCase() }) - .getOne(); + const resolved = await resolveCompanyState( + ctx, + companyId, + customCompanyName, + userRemovingCompany, + ); - if (existingCompany) { - toSave.customCompanyName = null; - toSave.companyId = existingCompany.id; - } else { - toSave.customCompanyName = customCompanyName; - toSave.companyId = null; - } + const toSave: Partial = { + ...values, + companyId: resolved.companyId, + customCompanyName: resolved.customCompanyName, + }; + + if (customDomain) { + const customImage = resolved.companyId + ? null + : getGoogleFaviconUrl(customDomain); + toSave.flags = { + ...toUpdate.flags, + customDomain, + ...(customImage && { customImage }), + }; + } + + if (userRemovingCompany) { + toSave.flags = { + ...toSave.flags, + ...toUpdate.flags, + removedEnrichment: true, + }; } - return { userExperience: { ...toUpdate, ...toSave }, parsedInput: parsed }; + return { + userExperience: { ...toUpdate, ...toSave }, + parsedInput: parsed, + removedCompanyId: userRemovingCompany, + }; }; const getUserExperience = ( @@ -323,21 +388,24 @@ export const resolvers = traceResolvers({ ctx, info, ): Promise => { - const result = await generateExperienceToSave(ctx, args); + const { userExperience, parsedInput, removedCompanyId } = + await generateExperienceToSave(ctx, args); const location = await findOrCreateDatasetLocation( ctx.con, - result.parsedInput.externalLocationId, + parsedInput.externalLocationId, ); const entity = await ctx.con.transaction(async (con) => { const repo = con.getRepository(UserExperienceWork); - const skills = result.parsedInput.skills; + const skills = parsedInput.skills; const saved = await repo.save({ - ...result.userExperience, + ...userExperience, locationId: location?.id || null, type: args.input.type, userId: ctx.userId, + // Set verified to false if companyId was removed + ...(removedCompanyId && { verified: false }), }); await dropSkillsExcept(con, saved.id, skills); diff --git a/src/workers/cdc/primary.ts b/src/workers/cdc/primary.ts index e519b37874..8d8354408b 100644 --- a/src/workers/cdc/primary.ts +++ b/src/workers/cdc/primary.ts @@ -1602,17 +1602,22 @@ const onUserExperienceChange = async ( userId: experience.userId, }); - // Trigger enrichment for Work and Education types (create only, when customCompanyName exists but no companyId) - if ( - data.payload.op === 'c' && + // Trigger enrichment for Work and Education types when customCompanyName exists but no companyId + // On CREATE: Always run enrichment + // On UPDATE: Only run if user hasn't explicitly removed the company (removedEnrichment flag) + const shouldEnrich = experience.customCompanyName && - !experience.companyId - ) { + !experience.companyId && + (data.payload.op === 'c' || + (data.payload.op === 'u' && + !JSON.parse(experience.flags || '{}')?.removedEnrichment)); + + if (shouldEnrich) { await enrichCompanyForExperience( con, { experienceId: experience.id, - customCompanyName: experience.customCompanyName, + customCompanyName: experience.customCompanyName!, experienceType: experience.type, }, logger, From 127f3179376339ebb33386388c69c73a7b0a035f Mon Sep 17 00:00:00 2001 From: Amar Trebinjac Date: Tue, 13 Jan 2026 23:37:50 +0100 Subject: [PATCH 2/8] domain schema --- src/common/schema/profile.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/common/schema/profile.ts b/src/common/schema/profile.ts index 93c114668f..3d65443543 100644 --- a/src/common/schema/profile.ts +++ b/src/common/schema/profile.ts @@ -1,6 +1,12 @@ import z from 'zod'; import { UserExperienceType } from '../../entity/user/experiences/types'; import { paginationSchema, urlParseSchema } from './common'; +import { domainOnly } from '../links'; + +const domainSchema = z.preprocess( + (val) => (val === '' ? null : val), + urlParseSchema.transform(domainOnly).nullish(), +); export const userExperiencesSchema = z .object({ @@ -36,7 +42,7 @@ export const userExperienceCertificationSchema = z export const userExperienceEducationSchema = z .object({ grade: z.string().nullish(), - customDomain: z.url().nullish().default(null), + customDomain: domainSchema, }) .extend(userExperienceInputBaseSchema.shape); @@ -58,7 +64,7 @@ export const userExperienceWorkSchema = z .max(50) .optional() .default([]), - customDomain: z.url().nullish().default(null), + customDomain: domainSchema, }) .extend(userExperienceInputBaseSchema.shape); From 9fd7a5a7b367964f375a1dc37d4e47dec5e34eb3 Mon Sep 17 00:00:00 2001 From: Amar Trebinjac Date: Wed, 14 Jan 2026 10:47:47 +0100 Subject: [PATCH 3/8] update test --- __tests__/schema/profile.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/__tests__/schema/profile.ts b/__tests__/schema/profile.ts index 6e174d5071..ac641f31b5 100644 --- a/__tests__/schema/profile.ts +++ b/__tests__/schema/profile.ts @@ -1944,9 +1944,9 @@ describe('UserExperience image field', () => { expect(res.data.upsertUserWorkExperience.company.name).toBe('Daily.dev'); // customCompanyName should be null since we linked to company expect(res.data.upsertUserWorkExperience.customCompanyName).toBeNull(); - // customDomain should still be stored (but customImage is null since company was found) + // customDomain should still be stored as just the hostname (but customImage is null since company was found) expect(res.data.upsertUserWorkExperience.customDomain).toBe( - 'https://mycustomdomain.com', + 'mycustomdomain.com', ); // Image should come from company (priority over customImage) expect(res.data.upsertUserWorkExperience.image).toBe( From 60b7da49a392483bc588e75ef7f55254aaefccc6 Mon Sep 17 00:00:00 2001 From: Amar Trebinjac Date: Wed, 14 Jan 2026 11:37:35 +0100 Subject: [PATCH 4/8] remove unnecessary return --- src/schema/profile.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/schema/profile.ts b/src/schema/profile.ts index 782e4d4da6..51c1a78db7 100644 --- a/src/schema/profile.ts +++ b/src/schema/profile.ts @@ -196,14 +196,9 @@ const resolveCompanyState = async ( if (existingCompany) { return { companyId: existingCompany.id, customCompanyName: null }; } - return { companyId: null, customCompanyName: inputCustomCompanyName }; } - if (inputCustomCompanyName) { - return { companyId: null, customCompanyName: inputCustomCompanyName }; - } - - return { companyId: null, customCompanyName: null }; + return { companyId: null, customCompanyName: inputCustomCompanyName || null }; }; const generateExperienceToSave = async < From bae53ddedafae4f73fb2a277f9c8d84d3c6471de Mon Sep 17 00:00:00 2001 From: Amar Trebinjac Date: Wed, 14 Jan 2026 11:46:22 +0100 Subject: [PATCH 5/8] update frenchie logic --- src/schema/profile.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/schema/profile.ts b/src/schema/profile.ts index 51c1a78db7..75efbf484e 100644 --- a/src/schema/profile.ts +++ b/src/schema/profile.ts @@ -223,9 +223,7 @@ const generateExperienceToSave = async < .findOneOrFail({ where: { id, userId: ctx.userId } }) : {}; - const isUpdate = !!id; - const hadCompanyId = !!toUpdate.companyId; - const userRemovingCompany = isUpdate && hadCompanyId && !companyId; + const userRemovingCompany = !!toUpdate.companyId && !companyId; const resolved = await resolveCompanyState( ctx, From b90bdbba6fed41b3df8cb88734ecbc0939a5b2b5 Mon Sep 17 00:00:00 2001 From: Amar Trebinjac Date: Wed, 14 Jan 2026 13:09:04 +0100 Subject: [PATCH 6/8] cleanup some logic --- src/workers/cdc/primary.ts | 41 ++++++++++++++++++++++++++++---------- 1 file changed, 30 insertions(+), 11 deletions(-) diff --git a/src/workers/cdc/primary.ts b/src/workers/cdc/primary.ts index 8d8354408b..9571fa6c24 100644 --- a/src/workers/cdc/primary.ts +++ b/src/workers/cdc/primary.ts @@ -1578,6 +1578,35 @@ export const onCampaignChange = async ( } }; +const shouldEnrichExperience = ( + data: ChangeMessage, +): boolean => { + if (data.payload.op === 'c') { + return true; + } + + if (data.payload.op === 'u') { + const experience = data.payload.after; + const removedEnrichment = JSON.parse( + experience?.flags || '{}', + )?.removedEnrichment; + + if ( + removedEnrichment || + !experience?.customCompanyName || + experience.companyId + ) { + return false; + } + + return ( + data.payload.before?.customCompanyName !== experience.customCompanyName + ); + } + + return false; +}; + const onUserExperienceChange = async ( con: DataSource, logger: FastifyBaseLogger, @@ -1602,17 +1631,7 @@ const onUserExperienceChange = async ( userId: experience.userId, }); - // Trigger enrichment for Work and Education types when customCompanyName exists but no companyId - // On CREATE: Always run enrichment - // On UPDATE: Only run if user hasn't explicitly removed the company (removedEnrichment flag) - const shouldEnrich = - experience.customCompanyName && - !experience.companyId && - (data.payload.op === 'c' || - (data.payload.op === 'u' && - !JSON.parse(experience.flags || '{}')?.removedEnrichment)); - - if (shouldEnrich) { + if (shouldEnrichExperience(data)) { await enrichCompanyForExperience( con, { From fd3d8eb3e68fda51489d5d7a928fdbee5777aceb Mon Sep 17 00:00:00 2001 From: Amar Trebinjac Date: Wed, 14 Jan 2026 13:45:37 +0100 Subject: [PATCH 7/8] AI gaslighting --- __tests__/schema/profile.ts | 114 +++++++++++++++++++++++++++++++----- src/schema/profile.ts | 9 +-- src/workers/cdc/primary.ts | 36 ++---------- 3 files changed, 109 insertions(+), 50 deletions(-) diff --git a/__tests__/schema/profile.ts b/__tests__/schema/profile.ts index ac641f31b5..6c3b695a3f 100644 --- a/__tests__/schema/profile.ts +++ b/__tests__/schema/profile.ts @@ -12,7 +12,6 @@ import { import { User } from '../../src/entity'; import { usersFixture } from '../fixture/user'; 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 { Company } from '../../src/entity/Company'; import { UserExperienceSkill } from '../../src/entity/user/experiences/UserExperienceSkill'; @@ -1954,7 +1953,7 @@ describe('UserExperience image field', () => { ); }); - it('should set removedEnrichment flag and verified=false when companyId is removed', async () => { + it('should set removedEnrichment flag and prevent auto-linking on subsequent saves', async () => { loggedUser = '1'; // Create experience with companyId @@ -1984,34 +1983,117 @@ describe('UserExperience image field', () => { } `; - // Update to remove companyId (use customCompanyName instead) - const res = await client.mutate(UPSERT_WORK_MUTATION, { + // First save: remove companyId and use customCompanyName that matches an existing company + const res1 = await client.mutate(UPSERT_WORK_MUTATION, { variables: { id: experienceId, input: { type: 'work', title: 'Engineer', startedAt: new Date('2023-01-01'), - customCompanyName: 'New Custom Company', + customCompanyName: 'Daily.dev', // Matches existing company }, }, }); - expect(res.errors).toBeFalsy(); - expect(res.data.upsertUserWorkExperience.company).toBeNull(); - expect(res.data.upsertUserWorkExperience.customCompanyName).toBe( - 'New Custom Company', + expect(res1.errors).toBeFalsy(); + // Should NOT auto-link because user is removing company + expect(res1.data.upsertUserWorkExperience.company).toBeNull(); + expect(res1.data.upsertUserWorkExperience.customCompanyName).toBe( + 'Daily.dev', ); - // Verify the flags and verified column in database - const updated = await con + // Verify removedEnrichment flag is set + const afterFirstSave = await con + .getRepository(UserExperience) + .findOne({ where: { id: experienceId } }); + expect(afterFirstSave?.flags?.removedEnrichment).toBe(true); + expect(afterFirstSave?.companyId).toBeNull(); + + // Second save: edit again with same customCompanyName + const res2 = await client.mutate(UPSERT_WORK_MUTATION, { + variables: { + id: experienceId, + input: { + type: 'work', + title: 'Senior Engineer', // Changed title + startedAt: new Date('2023-01-01'), + customCompanyName: 'Daily.dev', + }, + }, + }); + + expect(res2.errors).toBeFalsy(); + // Should still NOT auto-link because removedEnrichment flag is set + expect(res2.data.upsertUserWorkExperience.company).toBeNull(); + expect(res2.data.upsertUserWorkExperience.customCompanyName).toBe( + 'Daily.dev', + ); + + // Verify companyId is still null after second save + const afterSecondSave = await con .getRepository(UserExperience) .findOne({ where: { id: experienceId } }); - expect(updated?.flags?.removedEnrichment).toBe(true); - // Note: verified is on UserExperienceWork, check it separately - const workExp = await con - .getRepository(UserExperienceWork) + expect(afterSecondSave?.companyId).toBeNull(); + expect(afterSecondSave?.flags?.removedEnrichment).toBe(true); + }); + + it('should clear removedEnrichment flag when user explicitly sets companyId', async () => { + loggedUser = '1'; + + // Create experience with removedEnrichment flag already set + const experienceId = 'd0e1f2a3-ef01-5345-6789-012345678901'; + await con.getRepository(UserExperience).save({ + id: experienceId, + userId: '1', + companyId: null, + customCompanyName: 'Some Custom Company', + title: 'Developer', + startedAt: new Date('2023-01-01'), + type: UserExperienceType.Work, + flags: { removedEnrichment: true }, + }); + + const UPSERT_WORK_MUTATION = /* GraphQL */ ` + mutation UpsertUserWorkExperience( + $input: UserExperienceWorkInput! + $id: ID + ) { + upsertUserWorkExperience(input: $input, id: $id) { + id + company { + id + name + } + customCompanyName + } + } + `; + + // User explicitly selects a company + const res = await client.mutate(UPSERT_WORK_MUTATION, { + variables: { + id: experienceId, + input: { + type: 'work', + title: 'Developer', + startedAt: new Date('2023-01-01'), + companyId: 'company-1', + }, + }, + }); + + expect(res.errors).toBeFalsy(); + expect(res.data.upsertUserWorkExperience.company).not.toBeNull(); + expect(res.data.upsertUserWorkExperience.company.id).toBe('company-1'); + expect(res.data.upsertUserWorkExperience.customCompanyName).toBeNull(); + + // removedEnrichment should be cleared (not set to true again) + const updated = await con + .getRepository(UserExperience) .findOne({ where: { id: experienceId } }); - expect(workExp?.verified).toBe(false); + expect(updated?.companyId).toBe('company-1'); + // The flag should remain from before but shouldn't matter since companyId is set + // What's important is that the user can re-link to a company if they choose to }); }); diff --git a/src/schema/profile.ts b/src/schema/profile.ts index 75efbf484e..a2b723cd20 100644 --- a/src/schema/profile.ts +++ b/src/schema/profile.ts @@ -175,7 +175,7 @@ const resolveCompanyState = async ( ctx: AuthContext, inputCompanyId: string | null | undefined, inputCustomCompanyName: string | null | undefined, - userRemovingCompany: boolean, + skipAutoLinking: boolean, ): Promise => { if (inputCompanyId) { await ctx.con.getRepository(Company).findOneOrFail({ @@ -184,7 +184,7 @@ const resolveCompanyState = async ( return { companyId: inputCompanyId, customCompanyName: null }; } - if (inputCustomCompanyName && !userRemovingCompany) { + if (inputCustomCompanyName && !skipAutoLinking) { const existingCompany = await ctx.con .getRepository(Company) .createQueryBuilder('c') @@ -224,12 +224,14 @@ const generateExperienceToSave = async < : {}; const userRemovingCompany = !!toUpdate.companyId && !companyId; + const skipAutoLinking = + userRemovingCompany || !!toUpdate.flags?.removedEnrichment; const resolved = await resolveCompanyState( ctx, companyId, customCompanyName, - userRemovingCompany, + skipAutoLinking, ); const toSave: Partial = { @@ -252,7 +254,6 @@ const generateExperienceToSave = async < if (userRemovingCompany) { toSave.flags = { ...toSave.flags, - ...toUpdate.flags, removedEnrichment: true, }; } diff --git a/src/workers/cdc/primary.ts b/src/workers/cdc/primary.ts index 9571fa6c24..b887307e77 100644 --- a/src/workers/cdc/primary.ts +++ b/src/workers/cdc/primary.ts @@ -1578,35 +1578,6 @@ export const onCampaignChange = async ( } }; -const shouldEnrichExperience = ( - data: ChangeMessage, -): boolean => { - if (data.payload.op === 'c') { - return true; - } - - if (data.payload.op === 'u') { - const experience = data.payload.after; - const removedEnrichment = JSON.parse( - experience?.flags || '{}', - )?.removedEnrichment; - - if ( - removedEnrichment || - !experience?.customCompanyName || - experience.companyId - ) { - return false; - } - - return ( - data.payload.before?.customCompanyName !== experience.customCompanyName - ); - } - - return false; -}; - const onUserExperienceChange = async ( con: DataSource, logger: FastifyBaseLogger, @@ -1631,7 +1602,12 @@ const onUserExperienceChange = async ( userId: experience.userId, }); - if (shouldEnrichExperience(data)) { + // Trigger enrichment for Work and Education types (create only, when customCompanyName exists but no companyId) + if ( + data.payload.op === 'c' && + experience.customCompanyName && + !experience.companyId + ) { await enrichCompanyForExperience( con, { From 77342fbcf856450a16e150c646910816ce496dda Mon Sep 17 00:00:00 2001 From: Amar Trebinjac Date: Wed, 14 Jan 2026 13:55:02 +0100 Subject: [PATCH 8/8] remove comments --- __tests__/schema/profile.ts | 34 +++++----------------------------- src/schema/profile.ts | 1 - 2 files changed, 5 insertions(+), 30 deletions(-) diff --git a/__tests__/schema/profile.ts b/__tests__/schema/profile.ts index 6c3b695a3f..09b4530ea2 100644 --- a/__tests__/schema/profile.ts +++ b/__tests__/schema/profile.ts @@ -1810,7 +1810,6 @@ describe('UserExperience image field', () => { it('should return customImage from flags when no companyId', async () => { loggedUser = '1'; - // Create experience with customImage in flags but no companyId const experienceId = 'e5f6a7b8-9abc-4ef0-1234-567890123456'; await con.getRepository(UserExperience).save({ id: experienceId, @@ -1842,12 +1841,11 @@ describe('UserExperience image field', () => { it('should prioritize company image over customImage when both exist', async () => { loggedUser = '1'; - // Create experience with both companyId AND customImage in flags const experienceId = 'f6a7b8c9-abcd-4f01-2345-678901234567'; await con.getRepository(UserExperience).save({ id: experienceId, userId: '1', - companyId: 'company-1', // Has image 'https://daily.dev/logo.png' + companyId: 'company-1', title: 'Engineer', startedAt: new Date('2023-01-01'), type: UserExperienceType.Work, @@ -1863,21 +1861,18 @@ describe('UserExperience image field', () => { }); expect(res.errors).toBeFalsy(); - // Company image should take priority expect(res.data.userExperienceById.company.image).toBe( 'https://daily.dev/logo.png', ); expect(res.data.userExperienceById.image).toBe( 'https://daily.dev/logo.png', ); - // customDomain should still be accessible expect(res.data.userExperienceById.customDomain).toBe('https://other.com'); }); it('should return null image when neither companyId nor customImage exists', async () => { loggedUser = '1'; - // Create experience with no companyId and no customImage const experienceId = 'a7b8c9d0-bcde-4012-3456-789012345678'; await con.getRepository(UserExperience).save({ id: experienceId, @@ -1922,32 +1917,25 @@ describe('UserExperience image field', () => { } `; - // Create work experience with customCompanyName that matches existing company "Daily.dev" - // and also provide customDomain - should still link to the existing company - // customDomain is stored as a fallback, but company image takes priority const res = await client.mutate(UPSERT_WORK_MUTATION, { variables: { input: { type: 'work', title: 'Engineer', startedAt: new Date('2023-01-01'), - customCompanyName: 'Daily.dev', // This matches existing company-1 + customCompanyName: 'Daily.dev', customDomain: 'https://mycustomdomain.com', }, }, }); expect(res.errors).toBeFalsy(); - // Should be linked to company since name matches expect(res.data.upsertUserWorkExperience.company).not.toBeNull(); expect(res.data.upsertUserWorkExperience.company.name).toBe('Daily.dev'); - // customCompanyName should be null since we linked to company expect(res.data.upsertUserWorkExperience.customCompanyName).toBeNull(); - // customDomain should still be stored as just the hostname (but customImage is null since company was found) expect(res.data.upsertUserWorkExperience.customDomain).toBe( 'mycustomdomain.com', ); - // Image should come from company (priority over customImage) expect(res.data.upsertUserWorkExperience.image).toBe( 'https://daily.dev/logo.png', ); @@ -1956,7 +1944,6 @@ describe('UserExperience image field', () => { it('should set removedEnrichment flag and prevent auto-linking on subsequent saves', async () => { loggedUser = '1'; - // Create experience with companyId const experienceId = 'c9d0e1f2-def0-4234-5678-901234567890'; await con.getRepository(UserExperience).save({ id: experienceId, @@ -1983,7 +1970,6 @@ describe('UserExperience image field', () => { } `; - // First save: remove companyId and use customCompanyName that matches an existing company const res1 = await client.mutate(UPSERT_WORK_MUTATION, { variables: { id: experienceId, @@ -1991,32 +1977,29 @@ describe('UserExperience image field', () => { type: 'work', title: 'Engineer', startedAt: new Date('2023-01-01'), - customCompanyName: 'Daily.dev', // Matches existing company + customCompanyName: 'Daily.dev', }, }, }); expect(res1.errors).toBeFalsy(); - // Should NOT auto-link because user is removing company expect(res1.data.upsertUserWorkExperience.company).toBeNull(); expect(res1.data.upsertUserWorkExperience.customCompanyName).toBe( 'Daily.dev', ); - // Verify removedEnrichment flag is set const afterFirstSave = await con .getRepository(UserExperience) .findOne({ where: { id: experienceId } }); expect(afterFirstSave?.flags?.removedEnrichment).toBe(true); expect(afterFirstSave?.companyId).toBeNull(); - // Second save: edit again with same customCompanyName const res2 = await client.mutate(UPSERT_WORK_MUTATION, { variables: { id: experienceId, input: { type: 'work', - title: 'Senior Engineer', // Changed title + title: 'Senior Engineer', startedAt: new Date('2023-01-01'), customCompanyName: 'Daily.dev', }, @@ -2024,13 +2007,11 @@ describe('UserExperience image field', () => { }); expect(res2.errors).toBeFalsy(); - // Should still NOT auto-link because removedEnrichment flag is set expect(res2.data.upsertUserWorkExperience.company).toBeNull(); expect(res2.data.upsertUserWorkExperience.customCompanyName).toBe( 'Daily.dev', ); - // Verify companyId is still null after second save const afterSecondSave = await con .getRepository(UserExperience) .findOne({ where: { id: experienceId } }); @@ -2038,10 +2019,9 @@ describe('UserExperience image field', () => { expect(afterSecondSave?.flags?.removedEnrichment).toBe(true); }); - it('should clear removedEnrichment flag when user explicitly sets companyId', async () => { + it('should allow re-linking to company after removedEnrichment was set', async () => { loggedUser = '1'; - // Create experience with removedEnrichment flag already set const experienceId = 'd0e1f2a3-ef01-5345-6789-012345678901'; await con.getRepository(UserExperience).save({ id: experienceId, @@ -2070,7 +2050,6 @@ describe('UserExperience image field', () => { } `; - // User explicitly selects a company const res = await client.mutate(UPSERT_WORK_MUTATION, { variables: { id: experienceId, @@ -2088,12 +2067,9 @@ describe('UserExperience image field', () => { expect(res.data.upsertUserWorkExperience.company.id).toBe('company-1'); expect(res.data.upsertUserWorkExperience.customCompanyName).toBeNull(); - // removedEnrichment should be cleared (not set to true again) const updated = await con .getRepository(UserExperience) .findOne({ where: { id: experienceId } }); expect(updated?.companyId).toBe('company-1'); - // The flag should remain from before but shouldn't matter since companyId is set - // What's important is that the user can re-link to a company if they choose to }); }); diff --git a/src/schema/profile.ts b/src/schema/profile.ts index a2b723cd20..77678aae71 100644 --- a/src/schema/profile.ts +++ b/src/schema/profile.ts @@ -398,7 +398,6 @@ export const resolvers = traceResolvers({ locationId: location?.id || null, type: args.input.type, userId: ctx.userId, - // Set verified to false if companyId was removed ...(removedCompanyId && { verified: false }), });