diff --git a/__tests__/schema/opportunity.ts b/__tests__/schema/opportunity.ts index 0a15ffa0aa..45b5d594ce 100644 --- a/__tests__/schema/opportunity.ts +++ b/__tests__/schema/opportunity.ts @@ -455,14 +455,14 @@ describe('query opportunityById', () => { }); }); -describe('query getOpportunities', () => { +describe('query opportunities', () => { const GET_OPPORTUNITIES_QUERY = /* GraphQL */ ` query GetOpportunities( $state: ProtoEnumValue $first: Int $after: String ) { - getOpportunities(state: $state, first: $first, after: $after) { + opportunities(state: $state, first: $first, after: $after) { pageInfo { hasNextPage hasPreviousPage @@ -515,8 +515,8 @@ describe('query getOpportunities', () => { }); expect(res.errors).toBeFalsy(); - expect(res.data.getOpportunities.edges).toHaveLength(3); - expect(res.data.getOpportunities.pageInfo.hasNextPage).toBe(false); + expect(res.data.opportunities.edges).toHaveLength(3); + expect(res.data.opportunities.pageInfo.hasNextPage).toBe(false); }); it('should return only recruiter DRAFT opportunities for authenticated non-team member', async () => { @@ -527,8 +527,8 @@ describe('query getOpportunities', () => { }); expect(res.errors).toBeFalsy(); - expect(res.data.getOpportunities.edges).toHaveLength(1); - expect(res.data.getOpportunities.edges[0].node).toEqual( + expect(res.data.opportunities.edges).toHaveLength(1); + expect(res.data.opportunities.edges[0].node).toEqual( expect.objectContaining({ id: '550e8400-e29b-41d4-a716-446655440003', state: OpportunityState.DRAFT, @@ -544,8 +544,8 @@ describe('query getOpportunities', () => { }); expect(res.errors).toBeFalsy(); - expect(res.data.getOpportunities.edges).toHaveLength(1); - expect(res.data.getOpportunities.edges[0].node).toEqual( + expect(res.data.opportunities.edges).toHaveLength(1); + expect(res.data.opportunities.edges[0].node).toEqual( expect.objectContaining({ id: '550e8400-e29b-41d4-a716-446655440004', state: OpportunityState.DRAFT, @@ -562,8 +562,8 @@ describe('query getOpportunities', () => { }); expect(res.errors).toBeFalsy(); - expect(res.data.getOpportunities.edges).toHaveLength(2); - const nodes = res.data.getOpportunities.edges.map( + expect(res.data.opportunities.edges).toHaveLength(2); + const nodes = res.data.opportunities.edges.map( (e: { node: unknown }) => e.node, ); expect(nodes).toEqual( @@ -590,9 +590,9 @@ describe('query getOpportunities', () => { }); expect(res.errors).toBeFalsy(); - expect(res.data.getOpportunities.edges).toHaveLength(2); - expect(res.data.getOpportunities.pageInfo.hasNextPage).toBe(true); - expect(res.data.getOpportunities.pageInfo.endCursor).toBeTruthy(); + expect(res.data.opportunities.edges).toHaveLength(2); + expect(res.data.opportunities.pageInfo.hasNextPage).toBe(true); + expect(res.data.opportunities.pageInfo.endCursor).toBeTruthy(); }); it('should support pagination with after cursor', async () => { @@ -604,7 +604,7 @@ describe('query getOpportunities', () => { }); expect(firstPage.errors).toBeFalsy(); - const endCursor = firstPage.data.getOpportunities.pageInfo.endCursor; + const endCursor = firstPage.data.opportunities.pageInfo.endCursor; // Get second page const secondPage = await client.query(GET_OPPORTUNITIES_QUERY, { @@ -612,11 +612,9 @@ describe('query getOpportunities', () => { }); expect(secondPage.errors).toBeFalsy(); - expect(secondPage.data.getOpportunities.edges).toHaveLength(1); - expect(secondPage.data.getOpportunities.pageInfo.hasNextPage).toBe(false); - expect(secondPage.data.getOpportunities.pageInfo.hasPreviousPage).toBe( - true, - ); + expect(secondPage.data.opportunities.edges).toHaveLength(1); + expect(secondPage.data.opportunities.pageInfo.hasNextPage).toBe(false); + expect(secondPage.data.opportunities.pageInfo.hasPreviousPage).toBe(true); }); }); @@ -736,14 +734,14 @@ describe('query getOpportunityMatch', () => { }); }); -describe('query getOpportunityMatches', () => { +describe('query opportunityMatches', () => { const GET_OPPORTUNITY_MATCHES_QUERY = /* GraphQL */ ` query GetOpportunityMatches( $opportunityId: ID! $first: Int $after: String ) { - getOpportunityMatches( + opportunityMatches( opportunityId: $opportunityId first: $first after: $after @@ -827,9 +825,9 @@ describe('query getOpportunityMatches', () => { }); expect(res.errors).toBeFalsy(); - expect(res.data.getOpportunityMatches.edges).toHaveLength(3); + expect(res.data.opportunityMatches.edges).toHaveLength(3); - const statuses = res.data.getOpportunityMatches.edges.map( + const statuses = res.data.opportunityMatches.edges.map( (e: { node: { status: string } }) => e.node.status, ); @@ -856,7 +854,7 @@ describe('query getOpportunityMatches', () => { expect(res.errors).toBeFalsy(); - const acceptedMatch = res.data.getOpportunityMatches.edges.find( + const acceptedMatch = res.data.opportunityMatches.edges.find( (e: { node: { status: string } }) => e.node.status === 'candidate_accepted', ); @@ -884,7 +882,7 @@ describe('query getOpportunityMatches', () => { expect(res.errors).toBeFalsy(); - const acceptedMatch = res.data.getOpportunityMatches.edges.find( + const acceptedMatch = res.data.opportunityMatches.edges.find( (e: { node: { status: string } }) => e.node.status === 'candidate_accepted', ); @@ -915,9 +913,9 @@ describe('query getOpportunityMatches', () => { }); expect(res.errors).toBeFalsy(); - expect(res.data.getOpportunityMatches.edges).toHaveLength(2); - expect(res.data.getOpportunityMatches.pageInfo.hasNextPage).toBe(true); - expect(res.data.getOpportunityMatches.pageInfo.endCursor).toBeTruthy(); + expect(res.data.opportunityMatches.edges).toHaveLength(2); + expect(res.data.opportunityMatches.pageInfo.hasNextPage).toBe(true); + expect(res.data.opportunityMatches.pageInfo.endCursor).toBeTruthy(); }); it('should support pagination with after cursor', async () => { @@ -932,14 +930,12 @@ describe('query getOpportunityMatches', () => { }); expect(firstPage.errors).toBeFalsy(); - expect(firstPage.data.getOpportunityMatches.edges).toHaveLength(2); - expect(firstPage.data.getOpportunityMatches.pageInfo.hasNextPage).toBe( - true, - ); - const firstUserIds = firstPage.data.getOpportunityMatches.edges.map( + expect(firstPage.data.opportunityMatches.edges).toHaveLength(2); + expect(firstPage.data.opportunityMatches.pageInfo.hasNextPage).toBe(true); + const firstUserIds = firstPage.data.opportunityMatches.edges.map( (e: { node: { userId: string } }) => e.node.userId, ); - const endCursor = firstPage.data.getOpportunityMatches.pageInfo.endCursor; + const endCursor = firstPage.data.opportunityMatches.pageInfo.endCursor; // Get second page const secondPage = await client.query(GET_OPPORTUNITY_MATCHES_QUERY, { @@ -951,15 +947,13 @@ describe('query getOpportunityMatches', () => { }); expect(secondPage.errors).toBeFalsy(); - expect(secondPage.data.getOpportunityMatches.edges).toHaveLength(1); - expect(secondPage.data.getOpportunityMatches.pageInfo.hasNextPage).toBe( - false, - ); + expect(secondPage.data.opportunityMatches.edges).toHaveLength(1); + expect(secondPage.data.opportunityMatches.pageInfo.hasNextPage).toBe(false); // Verify we got different results expect(firstUserIds).not.toContain( - secondPage.data.getOpportunityMatches.edges[0].node.userId, + secondPage.data.opportunityMatches.edges[0].node.userId, ); - expect(secondPage.data.getOpportunityMatches.pageInfo.hasPreviousPage).toBe( + expect(secondPage.data.opportunityMatches.pageInfo.hasPreviousPage).toBe( true, ); }); @@ -1014,8 +1008,63 @@ describe('query getOpportunityMatches', () => { }); expect(res.errors).toBeFalsy(); - expect(res.data.getOpportunityMatches.edges).toHaveLength(0); - expect(res.data.getOpportunityMatches.pageInfo.hasNextPage).toBe(false); + expect(res.data.opportunityMatches.edges).toHaveLength(0); + expect(res.data.opportunityMatches.pageInfo.hasNextPage).toBe(false); + }); + + it('should not expose salaryExpectation to recruiters viewing other candidates', async () => { + loggedUser = '1'; // Recruiter + + // Add salaryExpectation to user 2's candidate preferences + await con.getRepository(UserCandidatePreference).update( + { userId: usersFixture[1].id }, + { + salaryExpectation: { + min: 120000, + period: SalaryPeriod.ANNUALLY, + }, + }, + ); + + const GET_OPPORTUNITY_MATCHES_WITH_SALARY_QUERY = /* GraphQL */ ` + query GetOpportunityMatchesWithSalary($opportunityId: ID!, $first: Int) { + opportunityMatches(opportunityId: $opportunityId, first: $first) { + edges { + node { + userId + updatedAt + candidatePreferences { + status + role + salaryExpectation { + min + period + } + } + } + } + } + } + `; + + const res = await client.query(GET_OPPORTUNITY_MATCHES_WITH_SALARY_QUERY, { + variables: { + opportunityId: '550e8400-e29b-41d4-a716-446655440001', + first: 10, + }, + }); + + expect(res.errors).toBeFalsy(); + + // Find the match for user 2 (candidate with salaryExpectation) + const user2Match = res.data.opportunityMatches.edges.find( + (e: { node: { userId: string } }) => e.node.userId === '2', + ); + + expect(user2Match).toBeDefined(); + expect(user2Match.node.candidatePreferences.role).toBe('Senior Developer'); + // salaryExpectation should be null for recruiter viewing another candidate + expect(user2Match.node.candidatePreferences.salaryExpectation).toBeNull(); }); }); @@ -1874,22 +1923,6 @@ describe('mutation acceptOpportunityMatch', () => { 'Access denied! Match is not pending', ); }); - - it('should return error when the opportunity is not live', async () => { - loggedUser = '1'; - - await testMutationErrorCode( - client, - { - mutation: MUTATION, - variables: { - id: '550e8400-e29b-41d4-a716-446655440003', - }, - }, - 'FORBIDDEN', - 'Access denied! Opportunity is not live', - ); - }); }); describe('mutation rejectOpportunityMatch', () => { @@ -1990,22 +2023,6 @@ describe('mutation rejectOpportunityMatch', () => { 'Access denied! Match is not pending', ); }); - - it('should return error when the opportunity is not live', async () => { - loggedUser = '1'; - - await testMutationErrorCode( - client, - { - mutation: MUTATION, - variables: { - id: '550e8400-e29b-41d4-a716-446655440003', - }, - }, - 'FORBIDDEN', - 'Access denied! Opportunity is not live', - ); - }); }); describe('mutation recruiterAcceptOpportunityMatch', () => { @@ -2107,40 +2124,6 @@ describe('mutation recruiterAcceptOpportunityMatch', () => { ); }); - it('should return error when the opportunity is not live', async () => { - loggedUser = '1'; - - await saveFixtures(con, OpportunityUser, [ - { - opportunityId: opportunitiesFixture[2].id, - userId: usersFixture[0].id, - type: OpportunityUserType.Recruiter, - }, - ]); - - // Update the existing Pending match to CandidateAccepted - await con.getRepository(OpportunityMatch).update( - { - opportunityId: opportunitiesFixture[2].id, - userId: usersFixture[0].id, - }, - { status: OpportunityMatchStatus.CandidateAccepted }, - ); - - await testMutationErrorCode( - client, - { - mutation: MUTATION, - variables: { - opportunityId: '550e8400-e29b-41d4-a716-446655440003', // DRAFT opportunity - candidateUserId: '1', - }, - }, - 'FORBIDDEN', - 'Access denied! Opportunity is not live', - ); - }); - it('should return error when match does not exist', async () => { loggedUser = '1'; @@ -2427,40 +2410,6 @@ describe('mutation recruiterRejectOpportunityMatch', () => { ); }); - it('should return error when the opportunity is not live', async () => { - loggedUser = '1'; - - await saveFixtures(con, OpportunityUser, [ - { - opportunityId: opportunitiesFixture[2].id, - userId: usersFixture[0].id, - type: OpportunityUserType.Recruiter, - }, - ]); - - // Update the existing Pending match to CandidateAccepted - await con.getRepository(OpportunityMatch).update( - { - opportunityId: opportunitiesFixture[2].id, - userId: usersFixture[0].id, - }, - { status: OpportunityMatchStatus.CandidateAccepted }, - ); - - await testMutationErrorCode( - client, - { - mutation: MUTATION, - variables: { - opportunityId: '550e8400-e29b-41d4-a716-446655440003', // DRAFT opportunity - candidateUserId: '1', - }, - }, - 'FORBIDDEN', - 'Access denied! Opportunity is not live', - ); - }); - it('should return error when match does not exist', async () => { loggedUser = '1'; diff --git a/src/common/googleCloud.ts b/src/common/googleCloud.ts index ba0161575a..469f60b271 100644 --- a/src/common/googleCloud.ts +++ b/src/common/googleCloud.ts @@ -145,13 +145,13 @@ export const generateSignedUrl = async ({ * Generate a signed URL for a resume/CV file */ export const generateResumeSignedUrl = async ( - userId: string, + blobName: string, expiresInMinutes?: number, ): Promise => { const { bucketName } = gcsBucketMap.resume; return generateSignedUrl({ bucketName, - blobName: userId, + blobName, expiresInMinutes, }); }; diff --git a/src/graphorm/graphorm.ts b/src/graphorm/graphorm.ts index 6a3de754a3..4efdc74422 100644 --- a/src/graphorm/graphorm.ts +++ b/src/graphorm/graphorm.ts @@ -76,6 +76,8 @@ export interface GraphORMType { fields?: { [name: string]: GraphORMField }; // Array of columns to select regardless of the resolve tree requiredColumns?: (string | RequiredColumnConfig)[]; + // Array of columns to ignore + ignoredColumns?: (string | RequiredColumnConfig)[]; // Restricted columns when the user is not authenticated anonymousRestrictedColumns?: (string | RequiredColumnConfig)[]; // Define a function to manipulate the query every time @@ -292,6 +294,7 @@ export class GraphORM { ): QueryBuilder { const childType = Object.keys(field.fieldsByTypeName)[0]; const mapping = this.mappings?.[type]?.fields?.[field.name]; + if (mapping?.alias) { const fieldsByTypeName = childType ? { @@ -387,6 +390,9 @@ export class GraphORM { let newBuilder = builder.from(tableName, alias).select([]); fields.forEach((field) => { + if (this.mappings?.[type]?.ignoredColumns?.includes(field.name)) { + return; + } if (this.checkIsColumnRestricted(ctx, type, field.name)) { return; } diff --git a/src/graphorm/index.ts b/src/graphorm/index.ts index 4f59cedce5..0419c331be 100644 --- a/src/graphorm/index.ts +++ b/src/graphorm/index.ts @@ -64,8 +64,6 @@ import { OpportunityUserType } from '../entity/opportunities/types'; import { OrganizationLinkType } from '../common/schema/organizations'; import type { GCSBlob } from '../common/schema/userCandidate'; import { QuestionType } from '../entity/questions/types'; -import { generateResumeSignedUrl } from '../common/googleCloud'; -import { ProfileResponse, snotraClient } from '../integrations/snotra'; const existsByUserAndPost = (entity: string, build?: (queryBuilder: QueryBuilder) => QueryBuilder) => @@ -105,6 +103,15 @@ const nullIfNotSameUser = ( return ctx.userId === user.id ? value : null; }; +const nullIfNotSameUserById = ( + value: T, + ctx: Context, + parent: unknown, +): T | null => { + const entity = parent as { userId: string }; + return ctx.userId === entity.userId ? value : null; +}; + const checkIfTitleIsClickbait = (value?: string): boolean => { if (!value) { return false; @@ -1592,6 +1599,7 @@ const obj = new GraphORM({ }, }, OpportunityMatch: { + ignoredColumns: ['engagementProfile'], fields: { createdAt: { transform: transformDate, @@ -1611,49 +1619,6 @@ const obj = new GraphORM({ applicationRank: { jsonType: true, }, - engagementProfile: { - jsonType: true, - transform: async ( - _, - ctx, - parent, - ): Promise<{ profileText: string; updatedAt: Date } | null> => { - const match = parent as { userId: string }; - if (!match.userId) { - return null; - } - - try { - const timeoutPromise = new Promise((_, reject) => - setTimeout( - () => reject(new Error('Profile fetch timeout')), - 5000, - ), - ); - - const profile = await Promise.race([ - snotraClient.getProfile({ user_id: match.userId }), - timeoutPromise, - ]); - - if (!profile) { - return null; - } - - return { - profileText: (profile as ProfileResponse).profile_text, - updatedAt: new Date((profile as ProfileResponse).update_at), - }; - } catch (error) { - // Log error but don't fail the entire query - ctx.log.warn( - { userId: match.userId, err: error }, - 'Failed to fetch engagement profile from snotra', - ); - return null; - } - }, - }, opportunity: { relation: { isMany: false, @@ -1677,26 +1642,16 @@ const obj = new GraphORM({ }, }, }, - EngagementProfile: { - // This type is fetched from external API via transform in OpportunityMatch - // The transform function returns the entire object, so no DB mapping needed - from: 'OpportunityMatch', - }, UserCandidatePreference: { + requiredColumns: ['userId'], + ignoredColumns: ['signedUrl'], fields: { cv: { jsonType: true, - transform: async (value: GCSBlob): Promise => { - if (!value || !value.blob) { - return value; - } - - const signedUrl = await generateResumeSignedUrl(value.blob); - + transform: async (value: GCSBlob) => { return { ...value, lastModified: transformDate(value?.lastModified), - signedUrl, }; }, }, @@ -1709,6 +1664,7 @@ const obj = new GraphORM({ }, salaryExpectation: { jsonType: true, + transform: nullIfNotSameUserById, }, location: { jsonType: true, @@ -1738,6 +1694,24 @@ const obj = new GraphORM({ }, }, }, + OpportunityMatchCandidatePreference: { + from: 'UserCandidatePreference', + fields: { + cv: { + jsonType: true, + transform: async (value: GCSBlob) => { + return { + ...value, + lastModified: transformDate(value?.lastModified), + }; + }, + }, + salaryExpectation: { + jsonType: true, + transform: nullIfNotSameUserById, + }, + }, + }, }); export default obj; diff --git a/src/schema/opportunity.ts b/src/schema/opportunity.ts index 9a9c41133f..db8093e1f1 100644 --- a/src/schema/opportunity.ts +++ b/src/schema/opportunity.ts @@ -34,6 +34,7 @@ import { UserCandidateKeyword } from '../entity/user/UserCandidateKeyword'; import { EMPLOYMENT_AGREEMENT_BUCKET_NAME } from '../config'; import { deleteEmploymentAgreementByUserId, + generateResumeSignedUrl, uploadEmploymentAgreementFromBuffer, } from '../common/googleCloud'; import { @@ -53,6 +54,7 @@ import { getGondulClient } from '../common/gondul'; import { createOpportunityPrompt } from '../common/opportunity/prompt'; import { queryPaginatedByDate } from '../common/datePageGenerator'; import { ConnectionArguments } from 'graphql-relay'; +import { ProfileResponse, snotraClient } from '../integrations/snotra'; export interface GQLOpportunity extends Pick< @@ -199,7 +201,6 @@ export const typeDefs = /* GraphQL */ ` type EngagementProfile { profileText: String! - updatedAt: DateTime! } type OpportunityMatch { @@ -287,7 +288,7 @@ export const typeDefs = /* GraphQL */ ` """ Get all opportunities filtered by state (defaults to LIVE) """ - getOpportunities( + opportunities( """ State of opportunities to fetch (defaults to LIVE) """ @@ -305,7 +306,7 @@ export const typeDefs = /* GraphQL */ ` """ Get all opportunity matches for a specific opportunity (includes only candidate_accepted, recruiter_accepted, and recruiter_rejected statuses) """ - getOpportunityMatches( + opportunityMatches( """ Id of the Opportunity """ @@ -524,7 +525,7 @@ async function updateRecruiterMatchStatus( con: ctx.con.manager, userId: ctx.userId, opportunityId, - permission: OpportunityPermissions.ViewDraft, + permission: OpportunityPermissions.UpdateState, isTeamMember: ctx.isTeamMember, }); @@ -556,15 +557,6 @@ async function updateRecruiterMatchStatus( ); } - const opportunity = await match.opportunity; - if (opportunity.state !== OpportunityState.LIVE) { - ctx.log.error( - { opportunityId, candidateUserId, state: opportunity.state }, - 'Opportunity is not live', - ); - throw new ForbiddenError(`Access denied! Opportunity is not live`); - } - await ctx.con.getRepository(OpportunityMatch).update( { opportunityId, @@ -609,35 +601,28 @@ async function updateCandidateMatchStatus( throw new ForbiddenError(`Access denied! Match is not pending`); } - const opportunity = await match.opportunity; - if (opportunity.state !== OpportunityState.LIVE) { - ctx.log.error( - { opportunityId, userId, state: opportunity.state }, - 'Opportunity is not live', + await ctx.con.transaction(async (entityManager) => { + await entityManager.getRepository(OpportunityMatch).update( + { + opportunityId, + userId, + }, + { + status: targetStatus, + }, ); - throw new ForbiddenError(`Access denied! Opportunity is not live`); - } - await ctx.con.getRepository(OpportunityMatch).update( - { - opportunityId, - userId, - }, - { - status: targetStatus, - }, - ); - - await ctx.con.getRepository(Alerts).update( - { - userId, - opportunityId, - }, - { - opportunityId: null, - flags: updateFlagsStatement({ hasSeenOpportunity: true }), - }, - ); + await entityManager.getRepository(Alerts).update( + { + userId, + opportunityId, + }, + { + opportunityId: null, + flags: updateFlagsStatement({ hasSeenOpportunity: true }), + }, + ); + }); } export const resolvers: IResolvers = traceResolvers< @@ -720,7 +705,7 @@ export const resolvers: IResolvers = traceResolvers< keywords: [], }; }, - getOpportunities: async ( + opportunities: async ( _, args: ConnectionArguments & { state?: number }, ctx: Context, @@ -766,7 +751,7 @@ export const resolvers: IResolvers = traceResolvers< }, ); }, - getOpportunityMatches: async ( + opportunityMatches: async ( _, args: ConnectionArguments & { opportunityId: string }, ctx: AuthContext, @@ -781,19 +766,19 @@ export const resolvers: IResolvers = traceResolvers< con: ctx.con.manager, userId: ctx.userId, opportunityId: args.opportunityId, - permission: OpportunityPermissions.ViewDraft, + permission: OpportunityPermissions.UpdateState, isTeamMember: ctx.isTeamMember, }); return await queryPaginatedByDate< GQLOpportunityMatch, - 'createdAt', + 'updatedAt', typeof args >( ctx, info, args, - { key: 'createdAt', maxSize: 50 }, + { key: 'updatedAt', maxSize: 50 }, { queryBuilder: (builder) => { builder.queryBuilder @@ -806,12 +791,12 @@ export const resolvers: IResolvers = traceResolvers< ], }) // Order by candidate_accepted status first (priority 0), then others (priority 1) - // Then by createdAt ascending (oldest first) within each group + // Then by updatedAt ascending (oldest first) within each group .addOrderBy( `CASE WHEN ${builder.alias}.status = :candidateAcceptedStatus THEN 0 ELSE 1 END`, 'ASC', ) - .addOrderBy(`${builder.alias}.createdAt`, 'ASC') + .addOrderBy(`${builder.alias}.updatedAt`, 'ASC') .setParameter( 'candidateAcceptedStatus', OpportunityMatchStatus.CandidateAccepted, @@ -1383,4 +1368,54 @@ export const resolvers: IResolvers = traceResolvers< }; }, }, + OpportunityMatch: { + engagementProfile: async ( + parent: OpportunityMatch, + _, + ctx: Context, + ): Promise<{ profileText: string } | null> => { + if (!parent.userId) { + return null; + } + + try { + const timeoutPromise = new Promise((_, reject) => + setTimeout(() => reject(new Error('Profile fetch timeout')), 5000), + ); + + const profile = await Promise.race([ + snotraClient.getProfile({ user_id: parent.userId }), + timeoutPromise, + ]); + + if (!profile) { + return null; + } + + return { + profileText: (profile as ProfileResponse).profile_text, + }; + } catch (error) { + // Log error but don't fail the entire query + ctx.log.warn( + { userId: parent.userId, err: error }, + 'Failed to fetch engagement profile from snotra', + ); + return null; + } + }, + }, + UserCandidatePreference: { + cv: async (parent: UserCandidatePreference) => { + if (!parent?.cv?.blob) { + return parent?.cv; + } + + const signedUrl = await generateResumeSignedUrl(parent.cv.blob); + return { + ...parent.cv, + signedUrl, + }; + }, + }, });