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
153 changes: 153 additions & 0 deletions __tests__/schema/opportunity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -456,6 +456,38 @@ describe('query opportunityById', () => {
);
});

it('should allow anonymous user to view LIVE opportunity', async () => {
// loggedUser is null by default from beforeEach
const res = await client.query(OPPORTUNITY_BY_ID_QUERY, {
variables: { id: '550e8400-e29b-41d4-a716-446655440001' },
});

expect(res.errors).toBeFalsy();
expect(res.data.opportunityById.id).toEqual(
'550e8400-e29b-41d4-a716-446655440001',
);
expect(res.data.opportunityById.state).toEqual(OpportunityState.LIVE);
});

it('should return NOT_FOUND for anonymous user viewing non-LIVE opportunity', async () => {
// Update to DRAFT state
await con
.getRepository(Opportunity)
.update(
{ id: '550e8400-e29b-41d4-a716-446655440001' },
{ state: OpportunityState.DRAFT },
);

await testQueryErrorCode(
client,
{
query: OPPORTUNITY_BY_ID_QUERY,
variables: { id: '550e8400-e29b-41d4-a716-446655440001' },
},
'NOT_FOUND',
);
});

it('should return null for non-live opportunity when user is not a recruiter', async () => {
loggedUser = '2';

Expand Down Expand Up @@ -2819,6 +2851,127 @@ describe('mutation rejectOpportunityMatch', () => {
});
});

describe('mutation opportunityApply', () => {
const MUTATION = /* GraphQL */ `
mutation OpportunityApply($id: ID!) {
opportunityApply(id: $id) {
opportunityId
userId
status
}
}
`;

it('should require authentication', async () => {
await testMutationErrorCode(
client,
{
mutation: MUTATION,
variables: {
id: '550e8400-e29b-41d4-a716-446655440001',
},
},
'UNAUTHENTICATED',
);
});

it('should allow authenticated user to apply to LIVE opportunity', async () => {
loggedUser = '3';

const res = await client.mutate(MUTATION, {
variables: {
id: '550e8400-e29b-41d4-a716-446655440002', // LIVE opportunity without match for user 3
},
});

expect(res.errors).toBeFalsy();
expect(res.data.opportunityApply).toEqual({
opportunityId: '550e8400-e29b-41d4-a716-446655440002',
userId: '3',
status: OpportunityMatchStatus.CandidateApplied,
});

// Verify the match was created in the database
const match = await con.getRepository(OpportunityMatch).findOne({
where: {
opportunityId: '550e8400-e29b-41d4-a716-446655440002',
userId: '3',
},
});

expect(match).toMatchObject({
status: OpportunityMatchStatus.CandidateApplied,
description: {},
screening: [],
feedback: [],
applicationRank: {},
});
});

it('should return error for non-LIVE opportunity', async () => {
loggedUser = '3';

await testMutationErrorCode(
client,
{
mutation: MUTATION,
variables: {
id: '550e8400-e29b-41d4-a716-446655440003', // DRAFT state
},
},
'CONFLICT',
'Can not apply to this opportunity',
);
});

it('should return error if already applied', async () => {
loggedUser = '1'; // User who already has a match in the fixture

await testMutationErrorCode(
client,
{
mutation: MUTATION,
variables: {
id: '550e8400-e29b-41d4-a716-446655440001',
},
},
'CONFLICT',
'You have already applied to this opportunity',
);
});

it('should return error for non-existent opportunity', async () => {
loggedUser = '3';

await testMutationErrorCode(
client,
{
mutation: MUTATION,
variables: {
id: '550e8400-e29b-41d4-a716-000000000000',
},
},
'NOT_FOUND',
'Opportunity not found!',
);
});

it('should return error for invalid UUID', async () => {
loggedUser = '3';

await testMutationErrorCode(
client,
{
mutation: MUTATION,
variables: {
id: 'not-a-valid-uuid',
},
},
'ZOD_VALIDATION_ERROR',
);
});
});

describe('mutation recruiterAcceptOpportunityMatch', () => {
const MUTATION = /* GraphQL */ `
mutation RecruiterAcceptOpportunityMatch(
Expand Down
14 changes: 14 additions & 0 deletions src/common/opportunity/accessControl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@ import { OpportunityUser } from '../../entity/opportunities/user';
import { OpportunityUserType } from '../../entity/opportunities/types';
import { ConflictError, NotFoundError } from '../../errors';
import { Opportunity } from '../../entity/opportunities/Opportunity';
import { OpportunityMatch } from '../../entity/OpportunityMatch';
import { OpportunityState } from '@dailydotdev/schema';

export enum OpportunityPermissions {
Edit = 'opportunity_edit',
UpdateState = 'opportunity_update_state',
ViewDraft = 'opportunity_view_draft',
CreateSlackChannel = 'opportunity_create_slack_channel',
Apply = 'opportunity_apply',
}

export const ensureOpportunityPermissions = async ({
Expand Down Expand Up @@ -39,6 +41,18 @@ export const ensureOpportunityPermissions = async ({
return;
}

if (permission === OpportunityPermissions.Apply) {
const existingMatch = await con.getRepository(OpportunityMatch).exists({
where: { opportunityId, userId },
});

if (existingMatch) {
throw new ConflictError('You have already applied to this opportunity');
}

return;
}

if (
[
OpportunityPermissions.Edit,
Expand Down
1 change: 1 addition & 0 deletions src/entity/opportunities/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,5 @@ export enum OpportunityMatchStatus {
CandidateTimeOut = 'candidate_time_out',
RecruiterAccepted = 'recruiter_accepted',
RecruiterRejected = 'recruiter_rejected',
CandidateApplied = 'candidate_applied',
}
64 changes: 64 additions & 0 deletions src/schema/opportunity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1052,6 +1052,16 @@ export const typeDefs = /* GraphQL */ `
Claim opportunities associated with an anonymous identifier
"""
claimOpportunities(identifier: String!): OpportunitiesClaim @auth

"""
Apply to an opportunity as an authenticated user
"""
opportunityApply(
"""
Id of the Opportunity
"""
id: ID!
): OpportunityMatch @auth
}
`;

Expand Down Expand Up @@ -3006,6 +3016,60 @@ export const resolvers: IResolvers<unknown, BaseContext> = traceResolvers<
ids: opportunities.map((item) => item.id),
};
},
opportunityApply: async (
_,
{ id: idArgument }: { id: string },
ctx: AuthContext,
info,
): Promise<GQLOpportunityMatch> => {
const opportunityId = z.uuid().parse(idArgument);

// Check user hasn't already applied
await ensureOpportunityPermissions({
con: ctx.con.manager,
userId: ctx.userId,
opportunityId,
permission: OpportunityPermissions.Apply,
isTeamMember: ctx.isTeamMember,
});

// Verify opportunity exists and is LIVE
const opportunity = await ctx.con.getRepository(OpportunityJob).findOne({
where: { id: opportunityId },
select: ['id', 'state'],
});

if (!opportunity) {
throw new NotFoundError('Opportunity not found!');
}

if (opportunity.state !== OpportunityState.LIVE) {
throw new ConflictError('Can not apply to this opportunity');
}

await ctx.con.getRepository(OpportunityMatch).insert(
ctx.con.getRepository(OpportunityMatch).create({
opportunityId,
userId: ctx.userId,
status: OpportunityMatchStatus.CandidateApplied,
description: {},
screening: [],
feedback: [],
applicationRank: {},
}),
);

return await graphorm.queryOneOrFail<GQLOpportunityMatch>(
ctx,
info,
(builder) => {
builder.queryBuilder
.where({ opportunityId })
.andWhere({ userId: ctx.userId });
return builder;
},
);
},
},
OpportunityMatch: {
engagementProfile: async (
Expand Down
Loading