diff --git a/__tests__/schema/profile.ts b/__tests__/schema/profile.ts index 31f0c4b850..09b4530ea2 100644 --- a/__tests__/schema/profile.ts +++ b/__tests__/schema/profile.ts @@ -1774,3 +1774,302 @@ 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'; + + 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'; + + const experienceId = 'f6a7b8c9-abcd-4f01-2345-678901234567'; + await con.getRepository(UserExperience).save({ + id: experienceId, + userId: '1', + companyId: 'company-1', + 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(); + expect(res.data.userExperienceById.company.image).toBe( + 'https://daily.dev/logo.png', + ); + expect(res.data.userExperienceById.image).toBe( + 'https://daily.dev/logo.png', + ); + expect(res.data.userExperienceById.customDomain).toBe('https://other.com'); + }); + + it('should return null image when neither companyId nor customImage exists', async () => { + loggedUser = '1'; + + 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 + } + } + } + `; + + const res = await client.mutate(UPSERT_WORK_MUTATION, { + variables: { + input: { + type: 'work', + title: 'Engineer', + startedAt: new Date('2023-01-01'), + customCompanyName: 'Daily.dev', + customDomain: 'https://mycustomdomain.com', + }, + }, + }); + + expect(res.errors).toBeFalsy(); + expect(res.data.upsertUserWorkExperience.company).not.toBeNull(); + expect(res.data.upsertUserWorkExperience.company.name).toBe('Daily.dev'); + expect(res.data.upsertUserWorkExperience.customCompanyName).toBeNull(); + expect(res.data.upsertUserWorkExperience.customDomain).toBe( + 'mycustomdomain.com', + ); + expect(res.data.upsertUserWorkExperience.image).toBe( + 'https://daily.dev/logo.png', + ); + }); + + it('should set removedEnrichment flag and prevent auto-linking on subsequent saves', async () => { + loggedUser = '1'; + + 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 + } + } + `; + + const res1 = await client.mutate(UPSERT_WORK_MUTATION, { + variables: { + id: experienceId, + input: { + type: 'work', + title: 'Engineer', + startedAt: new Date('2023-01-01'), + customCompanyName: 'Daily.dev', + }, + }, + }); + + expect(res1.errors).toBeFalsy(); + expect(res1.data.upsertUserWorkExperience.company).toBeNull(); + expect(res1.data.upsertUserWorkExperience.customCompanyName).toBe( + 'Daily.dev', + ); + + const afterFirstSave = await con + .getRepository(UserExperience) + .findOne({ where: { id: experienceId } }); + expect(afterFirstSave?.flags?.removedEnrichment).toBe(true); + expect(afterFirstSave?.companyId).toBeNull(); + + const res2 = await client.mutate(UPSERT_WORK_MUTATION, { + variables: { + id: experienceId, + input: { + type: 'work', + title: 'Senior Engineer', + startedAt: new Date('2023-01-01'), + customCompanyName: 'Daily.dev', + }, + }, + }); + + expect(res2.errors).toBeFalsy(); + expect(res2.data.upsertUserWorkExperience.company).toBeNull(); + expect(res2.data.upsertUserWorkExperience.customCompanyName).toBe( + 'Daily.dev', + ); + + const afterSecondSave = await con + .getRepository(UserExperience) + .findOne({ where: { id: experienceId } }); + expect(afterSecondSave?.companyId).toBeNull(); + expect(afterSecondSave?.flags?.removedEnrichment).toBe(true); + }); + + it('should allow re-linking to company after removedEnrichment was set', async () => { + loggedUser = '1'; + + 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 + } + } + `; + + 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(); + + const updated = await con + .getRepository(UserExperience) + .findOne({ where: { id: experienceId } }); + expect(updated?.companyId).toBe('company-1'); + }); +}); 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..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({ @@ -34,7 +40,10 @@ export const userExperienceCertificationSchema = z .extend(userExperienceInputBaseSchema.shape); export const userExperienceEducationSchema = z - .object({ grade: z.string().nullish() }) + .object({ + grade: z.string().nullish(), + customDomain: domainSchema, + }) .extend(userExperienceInputBaseSchema.shape); export const userExperienceProjectSchema = z @@ -55,6 +64,7 @@ export const userExperienceWorkSchema = z .max(50) .optional() .default([]), + customDomain: domainSchema, }) .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 f918fa6a5b..b5e820d578 100644 --- a/src/graphorm/index.ts +++ b/src/graphorm/index.ts @@ -1897,6 +1897,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..77678aae71 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,41 @@ 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, + skipAutoLinking: boolean, +): Promise => { + if (inputCompanyId) { + await ctx.con.getRepository(Company).findOneOrFail({ + where: { id: inputCompanyId }, + }); + return { companyId: inputCompanyId, customCompanyName: null }; + } + + if (inputCustomCompanyName && !skipAutoLinking) { + 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 || null }; +}; + const generateExperienceToSave = async < T extends BaseInputSchema, R extends z.core.output, @@ -169,41 +210,59 @@ 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 userRemovingCompany = !!toUpdate.companyId && !companyId; + const skipAutoLinking = + userRemovingCompany || !!toUpdate.flags?.removedEnrichment; - 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, + skipAutoLinking, + ); - 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, + removedEnrichment: true, + }; } - return { userExperience: { ...toUpdate, ...toSave }, parsedInput: parsed }; + return { + userExperience: { ...toUpdate, ...toSave }, + parsedInput: parsed, + removedCompanyId: userRemovingCompany, + }; }; const getUserExperience = ( @@ -323,21 +382,23 @@ 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, + ...(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..b887307e77 100644 --- a/src/workers/cdc/primary.ts +++ b/src/workers/cdc/primary.ts @@ -1612,7 +1612,7 @@ const onUserExperienceChange = async ( con, { experienceId: experience.id, - customCompanyName: experience.customCompanyName, + customCompanyName: experience.customCompanyName!, experienceType: experience.type, }, logger,