Skip to content

Commit 49a6574

Browse files
authored
feat: new endpoint to expose user opportuntity matches (#3295)
1 parent 1428135 commit 49a6574

File tree

2 files changed

+379
-0
lines changed

2 files changed

+379
-0
lines changed

__tests__/schema/opportunity.ts

Lines changed: 344 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1068,6 +1068,350 @@ describe('query opportunityMatches', () => {
10681068
});
10691069
});
10701070

1071+
describe('query userOpportunityMatches', () => {
1072+
const GET_USER_OPPORTUNITY_MATCHES_QUERY = /* GraphQL */ `
1073+
query GetUserOpportunityMatches($first: Int, $after: String) {
1074+
userOpportunityMatches(first: $first, after: $after) {
1075+
pageInfo {
1076+
hasNextPage
1077+
hasPreviousPage
1078+
endCursor
1079+
startCursor
1080+
}
1081+
edges {
1082+
node {
1083+
userId
1084+
opportunityId
1085+
status
1086+
description {
1087+
reasoning
1088+
}
1089+
screening {
1090+
screening
1091+
answer
1092+
}
1093+
feedback {
1094+
screening
1095+
answer
1096+
}
1097+
applicationRank {
1098+
score
1099+
description
1100+
warmIntro
1101+
}
1102+
user {
1103+
id
1104+
name
1105+
}
1106+
candidatePreferences {
1107+
status
1108+
role
1109+
}
1110+
createdAt
1111+
updatedAt
1112+
}
1113+
}
1114+
}
1115+
}
1116+
`;
1117+
1118+
it('should require authentication', async () => {
1119+
await testQueryErrorCode(
1120+
client,
1121+
{
1122+
query: GET_USER_OPPORTUNITY_MATCHES_QUERY,
1123+
variables: {
1124+
first: 10,
1125+
},
1126+
},
1127+
'UNAUTHENTICATED',
1128+
);
1129+
});
1130+
1131+
it('should return all matches for the authenticated user', async () => {
1132+
loggedUser = '1';
1133+
1134+
const res = await client.query(GET_USER_OPPORTUNITY_MATCHES_QUERY, {
1135+
variables: {
1136+
first: 10,
1137+
},
1138+
});
1139+
1140+
expect(res.errors).toBeFalsy();
1141+
expect(res.data.userOpportunityMatches.edges).toHaveLength(2);
1142+
1143+
const opportunityIds = res.data.userOpportunityMatches.edges.map(
1144+
(e: { node: { opportunityId: string } }) => e.node.opportunityId,
1145+
);
1146+
1147+
// User 1 has matches for opportunities 1 and 3
1148+
expect(opportunityIds).toContain('550e8400-e29b-41d4-a716-446655440001');
1149+
expect(opportunityIds).toContain('550e8400-e29b-41d4-a716-446655440003');
1150+
1151+
// All matches should belong to user 1
1152+
const userIds = res.data.userOpportunityMatches.edges.map(
1153+
(e: { node: { userId: string } }) => e.node.userId,
1154+
);
1155+
expect(userIds.every((id: string) => id === '1')).toBe(true);
1156+
});
1157+
1158+
it('should return matches ordered by updatedAt DESC', async () => {
1159+
loggedUser = '2';
1160+
1161+
// Add more matches for user 2 with different updatedAt dates
1162+
await saveFixtures(con, OpportunityMatch, [
1163+
{
1164+
opportunityId: '550e8400-e29b-41d4-a716-446655440002',
1165+
userId: '2',
1166+
status: OpportunityMatchStatus.Pending,
1167+
description: { reasoning: 'Newer match' },
1168+
screening: [],
1169+
feedback: [],
1170+
applicationRank: {},
1171+
createdAt: new Date('2023-01-10'),
1172+
updatedAt: new Date('2023-01-10'),
1173+
},
1174+
]);
1175+
1176+
const res = await client.query(GET_USER_OPPORTUNITY_MATCHES_QUERY, {
1177+
variables: {
1178+
first: 10,
1179+
},
1180+
});
1181+
1182+
expect(res.errors).toBeFalsy();
1183+
expect(res.data.userOpportunityMatches.edges).toHaveLength(2);
1184+
1185+
const updatedDates = res.data.userOpportunityMatches.edges.map(
1186+
(e: { node: { updatedAt: string } }) => new Date(e.node.updatedAt),
1187+
);
1188+
1189+
// Verify DESC ordering (most recent first)
1190+
expect(updatedDates[0].getTime()).toBeGreaterThan(
1191+
updatedDates[1].getTime(),
1192+
);
1193+
});
1194+
1195+
it('should return different matches for different users', async () => {
1196+
loggedUser = '2';
1197+
1198+
const res = await client.query(GET_USER_OPPORTUNITY_MATCHES_QUERY, {
1199+
variables: {
1200+
first: 10,
1201+
},
1202+
});
1203+
1204+
expect(res.errors).toBeFalsy();
1205+
expect(res.data.userOpportunityMatches.edges).toHaveLength(1);
1206+
1207+
const match = res.data.userOpportunityMatches.edges[0].node;
1208+
expect(match.userId).toBe('2');
1209+
expect(match.opportunityId).toBe('550e8400-e29b-41d4-a716-446655440001');
1210+
expect(match.status).toBe('candidate_accepted');
1211+
expect(match.description.reasoning).toBe('Accepted candidate');
1212+
});
1213+
1214+
it('should include all match statuses for the user', async () => {
1215+
loggedUser = '1';
1216+
1217+
const res = await client.query(GET_USER_OPPORTUNITY_MATCHES_QUERY, {
1218+
variables: {
1219+
first: 10,
1220+
},
1221+
});
1222+
1223+
expect(res.errors).toBeFalsy();
1224+
1225+
const statuses = res.data.userOpportunityMatches.edges.map(
1226+
(e: { node: { status: string } }) => e.node.status,
1227+
);
1228+
1229+
// User 1 has two pending matches
1230+
expect(statuses).toContain('pending');
1231+
});
1232+
1233+
it('should include screening, feedback, and application rank data', async () => {
1234+
loggedUser = '1';
1235+
1236+
const res = await client.query(GET_USER_OPPORTUNITY_MATCHES_QUERY, {
1237+
variables: {
1238+
first: 10,
1239+
},
1240+
});
1241+
1242+
expect(res.errors).toBeFalsy();
1243+
1244+
const matchWithData = res.data.userOpportunityMatches.edges.find(
1245+
(e: { node: { opportunityId: string } }) =>
1246+
e.node.opportunityId === '550e8400-e29b-41d4-a716-446655440001',
1247+
);
1248+
1249+
expect(matchWithData.node.screening).toEqual([
1250+
{ screening: 'What is your favorite language?', answer: 'TypeScript' },
1251+
]);
1252+
1253+
expect(matchWithData.node.applicationRank).toEqual({
1254+
score: 85,
1255+
description: 'Strong candidate',
1256+
warmIntro: null,
1257+
});
1258+
});
1259+
1260+
it('should support pagination with first parameter', async () => {
1261+
loggedUser = '1';
1262+
1263+
const res = await client.query(GET_USER_OPPORTUNITY_MATCHES_QUERY, {
1264+
variables: {
1265+
first: 1,
1266+
},
1267+
});
1268+
1269+
expect(res.errors).toBeFalsy();
1270+
expect(res.data.userOpportunityMatches.edges).toHaveLength(1);
1271+
expect(res.data.userOpportunityMatches.pageInfo.hasNextPage).toBe(true);
1272+
expect(res.data.userOpportunityMatches.pageInfo.endCursor).toBeTruthy();
1273+
});
1274+
1275+
it('should support pagination with after cursor', async () => {
1276+
loggedUser = '1';
1277+
1278+
// Update one match to have a different updatedAt for proper pagination testing
1279+
await con.getRepository(OpportunityMatch).update(
1280+
{
1281+
opportunityId: '550e8400-e29b-41d4-a716-446655440001',
1282+
userId: '1',
1283+
},
1284+
{
1285+
updatedAt: new Date('2023-01-08'),
1286+
},
1287+
);
1288+
1289+
// Get first page
1290+
const firstPage = await client.query(GET_USER_OPPORTUNITY_MATCHES_QUERY, {
1291+
variables: {
1292+
first: 1,
1293+
},
1294+
});
1295+
1296+
expect(firstPage.errors).toBeFalsy();
1297+
expect(firstPage.data.userOpportunityMatches.edges).toHaveLength(1);
1298+
expect(firstPage.data.userOpportunityMatches.pageInfo.hasNextPage).toBe(
1299+
true,
1300+
);
1301+
const firstOpportunityId =
1302+
firstPage.data.userOpportunityMatches.edges[0].node.opportunityId;
1303+
const endCursor = firstPage.data.userOpportunityMatches.pageInfo.endCursor;
1304+
1305+
// Get second page
1306+
const secondPage = await client.query(GET_USER_OPPORTUNITY_MATCHES_QUERY, {
1307+
variables: {
1308+
first: 10,
1309+
after: endCursor,
1310+
},
1311+
});
1312+
1313+
expect(secondPage.errors).toBeFalsy();
1314+
expect(secondPage.data.userOpportunityMatches.edges).toHaveLength(1);
1315+
expect(secondPage.data.userOpportunityMatches.pageInfo.hasNextPage).toBe(
1316+
false,
1317+
);
1318+
// Verify we got different results
1319+
expect(
1320+
secondPage.data.userOpportunityMatches.edges[0].node.opportunityId,
1321+
).not.toBe(firstOpportunityId);
1322+
expect(
1323+
secondPage.data.userOpportunityMatches.pageInfo.hasPreviousPage,
1324+
).toBe(true);
1325+
});
1326+
1327+
it('should return empty list for user with no matches', async () => {
1328+
loggedUser = '5'; // User with no matches
1329+
1330+
const res = await client.query(GET_USER_OPPORTUNITY_MATCHES_QUERY, {
1331+
variables: {
1332+
first: 10,
1333+
},
1334+
});
1335+
1336+
expect(res.errors).toBeFalsy();
1337+
expect(res.data.userOpportunityMatches.edges).toHaveLength(0);
1338+
expect(res.data.userOpportunityMatches.pageInfo.hasNextPage).toBe(false);
1339+
});
1340+
1341+
it('should include user data in the response', async () => {
1342+
loggedUser = '1';
1343+
1344+
const res = await client.query(GET_USER_OPPORTUNITY_MATCHES_QUERY, {
1345+
variables: {
1346+
first: 10,
1347+
},
1348+
});
1349+
1350+
expect(res.errors).toBeFalsy();
1351+
1352+
const firstMatch = res.data.userOpportunityMatches.edges[0].node;
1353+
expect(firstMatch.user).toEqual({
1354+
id: '1',
1355+
name: 'Ido',
1356+
});
1357+
});
1358+
1359+
it('should expose salaryExpectation to user viewing their own matches', async () => {
1360+
loggedUser = '1';
1361+
1362+
// Add salaryExpectation to user 1's candidate preferences
1363+
await con.getRepository(UserCandidatePreference).upsert(
1364+
{
1365+
userId: '1',
1366+
salaryExpectation: {
1367+
min: 100000,
1368+
period: SalaryPeriod.ANNUAL,
1369+
},
1370+
},
1371+
{
1372+
conflictPaths: ['userId'],
1373+
skipUpdateIfNoValuesChanged: true,
1374+
},
1375+
);
1376+
1377+
const GET_USER_MATCHES_WITH_SALARY_QUERY = /* GraphQL */ `
1378+
query GetUserOpportunityMatchesWithSalary($first: Int) {
1379+
userOpportunityMatches(first: $first) {
1380+
edges {
1381+
node {
1382+
userId
1383+
updatedAt
1384+
candidatePreferences {
1385+
status
1386+
role
1387+
salaryExpectation {
1388+
min
1389+
period
1390+
}
1391+
}
1392+
}
1393+
}
1394+
}
1395+
}
1396+
`;
1397+
1398+
const res = await client.query(GET_USER_MATCHES_WITH_SALARY_QUERY, {
1399+
variables: {
1400+
first: 10,
1401+
},
1402+
});
1403+
1404+
expect(res.errors).toBeFalsy();
1405+
1406+
const firstMatch = res.data.userOpportunityMatches.edges[0].node;
1407+
expect(firstMatch.userId).toBe('1');
1408+
expect(firstMatch.candidatePreferences.salaryExpectation).toEqual({
1409+
min: 100000,
1410+
period: 1, // ANNUAL
1411+
});
1412+
});
1413+
});
1414+
10711415
describe('query getCandidatePreferences', () => {
10721416
const QUERY = /* GraphQL */ `
10731417
query GetCandidatePreferences {

src/schema/opportunity.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,20 @@ export const typeDefs = /* GraphQL */ `
330330
"""
331331
first: Int
332332
): OpportunityMatchConnection! @auth
333+
334+
"""
335+
Get all opportunity matches for the authenticated user
336+
"""
337+
userOpportunityMatches(
338+
"""
339+
Paginate after opaque cursor
340+
"""
341+
after: String
342+
"""
343+
Paginate first
344+
"""
345+
first: Int
346+
): OpportunityMatchConnection! @auth
333347
}
334348
335349
input SalaryExpectationInput {
@@ -854,6 +868,27 @@ export const resolvers: IResolvers<unknown, BaseContext> = traceResolvers<
854868
},
855869
);
856870
},
871+
userOpportunityMatches: async (
872+
_,
873+
args: ConnectionArguments,
874+
ctx: AuthContext,
875+
info,
876+
) =>
877+
await queryPaginatedByDate<GQLOpportunityMatch, 'updatedAt', typeof args>(
878+
ctx,
879+
info,
880+
args,
881+
{ key: 'updatedAt', maxSize: 50 },
882+
{
883+
queryBuilder: (builder) => {
884+
builder.queryBuilder.where({ userId: ctx.userId });
885+
886+
return builder;
887+
},
888+
orderByKey: 'DESC',
889+
readReplica: true,
890+
},
891+
),
857892
},
858893
Mutation: {
859894
updateCandidatePreferences: async (

0 commit comments

Comments
 (0)