diff --git a/__tests__/schema/opportunity.ts b/__tests__/schema/opportunity.ts index ce33568fb5..b11cd25ed8 100644 --- a/__tests__/schema/opportunity.ts +++ b/__tests__/schema/opportunity.ts @@ -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'; @@ -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( diff --git a/src/common/opportunity/accessControl.ts b/src/common/opportunity/accessControl.ts index ed50371935..c90b5fcec3 100644 --- a/src/common/opportunity/accessControl.ts +++ b/src/common/opportunity/accessControl.ts @@ -4,6 +4,7 @@ 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 { @@ -11,6 +12,7 @@ export enum OpportunityPermissions { UpdateState = 'opportunity_update_state', ViewDraft = 'opportunity_view_draft', CreateSlackChannel = 'opportunity_create_slack_channel', + Apply = 'opportunity_apply', } export const ensureOpportunityPermissions = async ({ @@ -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, diff --git a/src/entity/opportunities/types.ts b/src/entity/opportunities/types.ts index b6e51bac21..4b37493ac9 100644 --- a/src/entity/opportunities/types.ts +++ b/src/entity/opportunities/types.ts @@ -10,4 +10,5 @@ export enum OpportunityMatchStatus { CandidateTimeOut = 'candidate_time_out', RecruiterAccepted = 'recruiter_accepted', RecruiterRejected = 'recruiter_rejected', + CandidateApplied = 'candidate_applied', } diff --git a/src/schema/opportunity.ts b/src/schema/opportunity.ts index d7539e78e0..a173d5f8f2 100644 --- a/src/schema/opportunity.ts +++ b/src/schema/opportunity.ts @@ -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 } `; @@ -3006,6 +3016,60 @@ export const resolvers: IResolvers = traceResolvers< ids: opportunities.map((item) => item.id), }; }, + opportunityApply: async ( + _, + { id: idArgument }: { id: string }, + ctx: AuthContext, + info, + ): Promise => { + 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( + ctx, + info, + (builder) => { + builder.queryBuilder + .where({ opportunityId }) + .andWhere({ userId: ctx.userId }); + return builder; + }, + ); + }, }, OpportunityMatch: { engagementProfile: async (