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
67 changes: 67 additions & 0 deletions __tests__/schema/opportunity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -774,11 +774,13 @@ describe('query opportunityMatches', () => {
const GET_OPPORTUNITY_MATCHES_QUERY = /* GraphQL */ `
query GetOpportunityMatches(
$opportunityId: ID!
$status: OpportunityMatchStatus
$first: Int
$after: String
) {
opportunityMatches(
opportunityId: $opportunityId
status: $status
first: $first
after: $after
) {
Expand All @@ -787,6 +789,7 @@ describe('query opportunityMatches', () => {
hasPreviousPage
endCursor
startCursor
totalCount
}
edges {
node {
Expand Down Expand Up @@ -1102,6 +1105,70 @@ describe('query opportunityMatches', () => {
// salaryExpectation should be null for recruiter viewing another candidate
expect(user2Match.node.candidatePreferences.salaryExpectation).toBeNull();
});

it('should filter by candidate_accepted status when provided', async () => {
loggedUser = '1';

const res = await client.query(GET_OPPORTUNITY_MATCHES_QUERY, {
variables: {
opportunityId: '550e8400-e29b-41d4-a716-446655440001',
status: 'candidate_accepted',
first: 10,
},
});

expect(res.errors).toBeFalsy();
expect(res.data.opportunityMatches.edges).toHaveLength(1);
expect(res.data.opportunityMatches.edges[0].node.status).toBe(
'candidate_accepted',
);
expect(res.data.opportunityMatches.edges[0].node.userId).toBe('2');
});

it('should reject invalid status values', async () => {
loggedUser = '1';

// 'pending' is not an allowed status for this query
await testQueryErrorCode(
client,
{
query: GET_OPPORTUNITY_MATCHES_QUERY,
variables: {
opportunityId: '550e8400-e29b-41d4-a716-446655440001',
status: 'pending',
first: 10,
},
},
'ZOD_VALIDATION_ERROR',
);
});

it('should return totalCount of matches for the given status filter', async () => {
loggedUser = '1';

// Test with no status filter - should count all allowed statuses
const resAll = await client.query(GET_OPPORTUNITY_MATCHES_QUERY, {
variables: {
opportunityId: '550e8400-e29b-41d4-a716-446655440001',
first: 10,
},
});

expect(resAll.errors).toBeFalsy();
expect(resAll.data.opportunityMatches.pageInfo.totalCount).toBe(3);

// Test with candidate_accepted status filter
const resFiltered = await client.query(GET_OPPORTUNITY_MATCHES_QUERY, {
variables: {
opportunityId: '550e8400-e29b-41d4-a716-446655440001',
status: 'candidate_accepted',
first: 10,
},
});

expect(resFiltered.errors).toBeFalsy();
expect(resFiltered.data.opportunityMatches.pageInfo.totalCount).toBe(1);
});
});

describe('query userOpportunityMatches', () => {
Expand Down
14 changes: 14 additions & 0 deletions src/common/schema/opportunities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import z from 'zod';
import { organizationLinksSchema } from './organizations';
import { fileUploadSchema, urlParseSchema } from './common';
import { parseBigInt } from '../utils';
import { OpportunityMatchStatus } from '../../entity/opportunities/types';

export const opportunityMatchDescriptionSchema = z.object({
reasoning: z.string(),
Expand Down Expand Up @@ -255,3 +256,16 @@ export const createSharedSlackChannelSchema = z.object({
'Channel name can only contain lowercase letters, numbers, hyphens, and underscores',
),
});

export const opportunityMatchesQuerySchema = z.object({
opportunityId: z.string(),
status: z
.enum([
OpportunityMatchStatus.CandidateAccepted,
OpportunityMatchStatus.RecruiterAccepted,
OpportunityMatchStatus.RecruiterRejected,
])
.optional(),
after: z.string().optional(),
first: z.number().optional(),
});
1 change: 1 addition & 0 deletions src/graphorm/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1602,6 +1602,7 @@ const obj = new GraphORM({
},
},
OpportunityMatch: {
requiredColumns: ['updatedAt'],
ignoredColumns: ['engagementProfile'],
fields: {
createdAt: {
Expand Down
115 changes: 78 additions & 37 deletions src/schema/opportunity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ import {
opportunityUpdateStateSchema,
createSharedSlackChannelSchema,
parseOpportunitySchema,
opportunityMatchesQuerySchema,
} from '../common/schema/opportunities';
import { OpportunityKeyword } from '../entity/OpportunityKeyword';
import {
Expand Down Expand Up @@ -310,8 +311,19 @@ export const typeDefs = /* GraphQL */ `
cursor: String!
}

type OpportunityMatchPageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean
startCursor: String
endCursor: String
"""
Total number of matches for the given status filter
"""
totalCount: Int!
}

type OpportunityMatchConnection {
pageInfo: PageInfo!
pageInfo: OpportunityMatchPageInfo!
edges: [OpportunityMatchEdge!]!
}

Expand Down Expand Up @@ -498,6 +510,10 @@ export const typeDefs = /* GraphQL */ `
"""
opportunityId: ID!
"""
Filter by match status (allowed: candidate_accepted, recruiter_accepted, recruiter_rejected)
"""
status: OpportunityMatchStatus
"""
Paginate after opaque cursor
"""
after: String
Expand Down Expand Up @@ -1044,61 +1060,86 @@ export const resolvers: IResolvers<unknown, BaseContext> = traceResolvers<
},
opportunityMatches: async (
_,
args: ConnectionArguments & { opportunityId: string },
args: ConnectionArguments & {
opportunityId: string;
status?: OpportunityMatchStatus;
},
ctx: AuthContext,
info,
) => {
if (!ctx.userId) {
throw new NotFoundError('Not found!');
}

// Validate and parse args using Zod schema
const validatedArgs = opportunityMatchesQuerySchema.parse(args);

// First verify the user has access to this opportunity
await ensureOpportunityPermissions({
con: ctx.con.manager,
userId: ctx.userId,
opportunityId: args.opportunityId,
opportunityId: validatedArgs.opportunityId,
permission: OpportunityPermissions.UpdateState,
isTeamMember: ctx.isTeamMember,
});

return await queryPaginatedByDate<
GQLOpportunityMatch,
'updatedAt',
typeof args
>(
ctx,
info,
args,
{ key: 'updatedAt', maxSize: 50 },
{
queryBuilder: (builder) => {
builder.queryBuilder
.where({ opportunityId: args.opportunityId })
.andWhere(`${builder.alias}.status IN (:...statuses)`, {
statuses: [
// If status is provided, filter by that status; otherwise use all allowed statuses
const statusesToFilter = validatedArgs.status
? [validatedArgs.status]
: [
OpportunityMatchStatus.CandidateAccepted,
OpportunityMatchStatus.RecruiterAccepted,
OpportunityMatchStatus.RecruiterRejected,
];

const [connection, totalCount] = await Promise.all([
queryPaginatedByDate<GQLOpportunityMatch, 'updatedAt', typeof args>(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this now used for tinder style matching? Do we want to make sure user can't browse multiple candidates if doing direct API requests and requesting more candidates, and only do one by one? (could be something we add later, just for shenanigans protection) 😆

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we should be strict to actually protected from it. But we also have couple use cases where we actually show a list, not just one by one. so it's better to support it this way

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Where do we show it as list? (just for my own reference)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe it doesn't exist at the moment, but i know we plan to show recruiter approved/rejected candidates

ctx,
info,
args,
{ key: 'updatedAt', maxSize: 50 },
{
queryBuilder: (builder) => {
builder.queryBuilder
.where({ opportunityId: validatedArgs.opportunityId })
.andWhere(`${builder.alias}.status IN (:...statuses)`, {
statuses: statusesToFilter,
})
// Order by candidate_accepted status first (priority 0), then others (priority 1)
// 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}.updatedAt`, 'ASC')
.setParameter(
'candidateAcceptedStatus',
OpportunityMatchStatus.CandidateAccepted,
OpportunityMatchStatus.RecruiterAccepted,
OpportunityMatchStatus.RecruiterRejected,
],
})
// Order by candidate_accepted status first (priority 0), then others (priority 1)
// 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}.updatedAt`, 'ASC')
.setParameter(
'candidateAcceptedStatus',
OpportunityMatchStatus.CandidateAccepted,
);
);

return builder;
return builder;
},
orderByKey: 'ASC',
readReplica: true,
},
orderByKey: 'ASC',
readReplica: true,
),
queryReadReplica(ctx.con, ({ queryRunner }) =>
queryRunner.manager.getRepository(OpportunityMatch).count({
where: {
opportunityId: validatedArgs.opportunityId,
status: In(statusesToFilter),
},
}),
),
]);

return {
...connection,
pageInfo: {
...connection.pageInfo,
totalCount,
},
);
};
},
userOpportunityMatches: async (
_,
Expand Down
Loading