Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 111 additions & 0 deletions __tests__/schema/opportunity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
5 changes: 5 additions & 0 deletions src/common/schema/opportunities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
44 changes: 44 additions & 0 deletions src/schema/opportunity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -420,6 +422,12 @@ export const typeDefs = /* GraphQL */ `
links: [OrganizationLinkInput!]
}

input RecruiterInput {
userId: ID!
title: String
bio: String
}

input OpportunityEditInput {
title: String
tldr: String
Expand All @@ -429,6 +437,7 @@ export const typeDefs = /* GraphQL */ `
content: OpportunityContentInput
questions: [OpportunityScreeningQuestionInput!]
organization: OrganizationEditInput
recruiter: RecruiterInput
}

extend type Mutation {
Expand Down Expand Up @@ -1256,6 +1265,7 @@ export const resolvers: IResolvers<unknown, BaseContext> = traceResolvers<
content,
questions,
organization,
recruiter,
...opportunityUpdate
} = opportunity;

Expand Down Expand Up @@ -1369,6 +1379,40 @@ export const resolvers: IResolvers<unknown, BaseContext> = 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<GQLOpportunity>(ctx, info, (builder) => {
Expand Down
Loading