diff --git a/__tests__/schema/opportunity.ts b/__tests__/schema/opportunity.ts index fdbfd794a..f519b667d 100644 --- a/__tests__/schema/opportunity.ts +++ b/__tests__/schema/opportunity.ts @@ -4198,6 +4198,117 @@ describe('mutation editOpportunity', () => { stage: CompanyStage.SERIES_B, }); }); + + it('should update recruiter title and bio', async () => { + loggedUser = '1'; // user 1 is recruiter for opportunitiesFixture[0] + + const res = await client.mutate(MUTATION, { + variables: { + id: opportunitiesFixture[0].id, + payload: { + recruiter: { + userId: usersFixture[0].id, + title: 'Senior Tech Recruiter', + bio: 'Passionate about connecting great talent with amazing opportunities.', + }, + }, + }, + }); + + expect(res.errors).toBeFalsy(); + + // Verify the user's title and bio were updated + const userAfter = await con + .getRepository(User) + .findOneBy({ id: usersFixture[0].id }); + + expect(userAfter?.title).toBe('Senior Tech Recruiter'); + expect(userAfter?.bio).toBe( + 'Passionate about connecting great talent with amazing opportunities.', + ); + }); + + it('should fail to update recruiter when user is not a recruiter for the opportunity', async () => { + loggedUser = '1'; // user 1 is recruiter for opportunitiesFixture[0] + + // user 2 is NOT a recruiter for opportunitiesFixture[0] + await testMutationErrorCode( + client, + { + mutation: MUTATION, + variables: { + id: opportunitiesFixture[0].id, + payload: { + recruiter: { + userId: usersFixture[1].id, // user 2 + title: 'Unauthorized Recruiter', + bio: 'This should fail', + }, + }, + }, + }, + 'FORBIDDEN', + 'Access denied! Recruiter is not part of this opportunity', + ); + }); + + it('should fail to update recruiter when recruiter does not exist in opportunity_user', async () => { + loggedUser = '1'; + + // user 3 exists but is not a recruiter for opportunitiesFixture[0] + await testMutationErrorCode( + client, + { + mutation: MUTATION, + variables: { + id: opportunitiesFixture[0].id, + payload: { + recruiter: { + userId: '3', + title: 'Non-existent Recruiter', + bio: 'This should fail', + }, + }, + }, + }, + 'FORBIDDEN', + 'Access denied! Recruiter is not part of this opportunity', + ); + }); + + it('should only update title when bio is not provided', async () => { + loggedUser = '1'; + + // Set initial bio + await con.getRepository(User).update( + { id: usersFixture[0].id }, + { + title: 'Initial Title', + bio: 'Initial bio that should remain', + }, + ); + + const res = await client.mutate(MUTATION, { + variables: { + id: opportunitiesFixture[0].id, + payload: { + recruiter: { + userId: usersFixture[0].id, + title: 'Updated Title Only', + }, + }, + }, + }); + + expect(res.errors).toBeFalsy(); + + const userAfter = await con + .getRepository(User) + .findOneBy({ id: usersFixture[0].id }); + + expect(userAfter?.title).toBe('Updated Title Only'); + expect(userAfter?.bio).toBe('Initial bio that should remain'); + }); }); describe('mutation clearOrganizationImage', () => { diff --git a/src/common/schema/opportunities.ts b/src/common/schema/opportunities.ts index 6ed7762dd..33a04f262 100644 --- a/src/common/schema/opportunities.ts +++ b/src/common/schema/opportunities.ts @@ -155,6 +155,11 @@ export const opportunityEditSchema = z stage: z.number().int().nullable().optional(), links: z.array(organizationLinksSchema).max(50).optional(), }), + recruiter: z.object({ + userId: z.string(), + title: z.string().max(240).optional(), + bio: z.string().max(2000).optional(), + }), }) .partial(); diff --git a/src/schema/opportunity.ts b/src/schema/opportunity.ts index c270774e5..1dddec0f5 100644 --- a/src/schema/opportunity.ts +++ b/src/schema/opportunity.ts @@ -30,9 +30,11 @@ import { opportunityFeedbackAnswersSchema, } from '../common/schema/opportunityMatch'; import { OpportunityJob } from '../entity/opportunities/OpportunityJob'; +import { OpportunityUserRecruiter } from '../entity/opportunities/user/OpportunityUserRecruiter'; import { ForbiddenError } from 'apollo-server-errors'; import { ConflictError, NotFoundError } from '../errors'; import { UserCandidateKeyword } from '../entity/user/UserCandidateKeyword'; +import { User } from '../entity/user/User'; import { EMPLOYMENT_AGREEMENT_BUCKET_NAME } from '../config'; import { deleteEmploymentAgreementByUserId, @@ -420,6 +422,12 @@ export const typeDefs = /* GraphQL */ ` links: [OrganizationLinkInput!] } + input RecruiterInput { + userId: ID! + title: String + bio: String + } + input OpportunityEditInput { title: String tldr: String @@ -429,6 +437,7 @@ export const typeDefs = /* GraphQL */ ` content: OpportunityContentInput questions: [OpportunityScreeningQuestionInput!] organization: OrganizationEditInput + recruiter: RecruiterInput } extend type Mutation { @@ -1256,6 +1265,7 @@ export const resolvers: IResolvers = traceResolvers< content, questions, organization, + recruiter, ...opportunityUpdate } = opportunity; @@ -1369,6 +1379,40 @@ export const resolvers: IResolvers = traceResolvers< { conflictPaths: ['id'] }, ); } + + if (recruiter) { + // Check if the recruiter is part of the recruiters for this opportunity + const existingRecruiter = await entityManager + .getRepository(OpportunityUserRecruiter) + .findOne({ + where: { + opportunityId: id, + userId: recruiter.userId, + type: OpportunityUserType.Recruiter, + }, + }); + + if (!existingRecruiter) { + ctx.log.error( + { opportunityId: id, userId: recruiter.userId }, + 'Recruiter is not part of this opportunity', + ); + throw new ForbiddenError( + 'Access denied! Recruiter is not part of this opportunity', + ); + } + + // Update the recruiter's title and bio on the User entity + await entityManager.getRepository(User).update( + { + id: recruiter.userId, + }, + { + title: recruiter.title, + bio: recruiter.bio, + }, + ); + } }); return graphorm.queryOneOrFail(ctx, info, (builder) => {