Skip to content

Commit ed78b7e

Browse files
authored
feat: add feedback option to opportunity (#3262)
1 parent b4e01fc commit ed78b7e

10 files changed

Lines changed: 493 additions & 1 deletion

File tree

__tests__/fixture/opportunity.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
SocialMediaType,
1818
} from '../../src/common/schema/organizations';
1919
import type { QuestionScreening } from '../../src/entity/questions/QuestionScreening';
20+
import type { QuestionFeedback } from '../../src/entity/questions/QuestionFeedback';
2021
import { demoCompany } from '../../src/common';
2122

2223
export const organizationsFixture: DeepPartial<Organization>[] = [
@@ -313,3 +314,21 @@ export const opportunityMatchesFixture: DeepPartial<OpportunityMatch>[] = [
313314
updatedAt: new Date('2023-01-03'),
314315
},
315316
];
317+
318+
export const opportunityFeedbackQuestionsFixture: DeepPartial<QuestionFeedback>[] =
319+
[
320+
{
321+
id: '850e8400-e29b-41d4-a716-446655440001',
322+
title: 'How did you hear about this opportunity?',
323+
placeholder: 'e.g., LinkedIn, friend, etc.',
324+
opportunityId: opportunitiesFixture[0].id,
325+
questionOrder: 0,
326+
},
327+
{
328+
id: '850e8400-e29b-41d4-a716-446655440002',
329+
title: 'What interests you most about this role?',
330+
placeholder: 'Your answer here...',
331+
opportunityId: opportunitiesFixture[0].id,
332+
questionOrder: 1,
333+
},
334+
];

__tests__/schema/opportunity.ts

Lines changed: 268 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
2626
opportunityKeywordsFixture,
2727
opportunityMatchesFixture,
2828
opportunityQuestionsFixture,
29+
opportunityFeedbackQuestionsFixture,
2930
organizationsFixture,
3031
} from '../fixture/opportunity';
3132
import { OpportunityUser } from '../../src/entity/opportunities/user';
@@ -57,6 +58,7 @@ import { fileTypeFromBuffer } from '../setup';
5758
import { EMPLOYMENT_AGREEMENT_BUCKET_NAME } from '../../src/config';
5859
import { RoleType } from '../../src/common/schema/userCandidate';
5960
import { QuestionType } from '../../src/entity/questions/types';
61+
import { QuestionFeedback } from '../../src/entity/questions/QuestionFeedback';
6062
import type { FastifyInstance } from 'fastify';
6163
import type { Context } from '../../src/Context';
6264
import { createMockGondulTransport } from '../helpers';
@@ -105,6 +107,11 @@ beforeEach(async () => {
105107
await saveFixtures(con, Organization, organizationsFixture);
106108
await saveFixtures(con, Opportunity, opportunitiesFixture);
107109
await saveFixtures(con, QuestionScreening, opportunityQuestionsFixture);
110+
await saveFixtures(
111+
con,
112+
QuestionFeedback,
113+
opportunityFeedbackQuestionsFixture,
114+
);
108115
await saveFixtures(con, OpportunityKeyword, opportunityKeywordsFixture);
109116
await saveFixtures(con, OpportunityMatch, opportunityMatchesFixture);
110117
await saveFixtures(con, OpportunityUser, [
@@ -189,6 +196,13 @@ describe('query opportunityById', () => {
189196
placeholder
190197
opportunityId
191198
}
199+
feedbackQuestions {
200+
id
201+
title
202+
order
203+
placeholder
204+
opportunityId
205+
}
192206
}
193207
}
194208
@@ -300,7 +314,58 @@ describe('query opportunityById', () => {
300314
order: 0,
301315
},
302316
]),
317+
feedbackQuestions: expect.arrayContaining([
318+
{
319+
id: '850e8400-e29b-41d4-a716-446655440001',
320+
title: 'How did you hear about this opportunity?',
321+
placeholder: 'e.g., LinkedIn, friend, etc.',
322+
opportunityId: '550e8400-e29b-41d4-a716-446655440001',
323+
order: 0,
324+
},
325+
{
326+
id: '850e8400-e29b-41d4-a716-446655440002',
327+
title: 'What interests you most about this role?',
328+
placeholder: 'Your answer here...',
329+
opportunityId: '550e8400-e29b-41d4-a716-446655440001',
330+
order: 1,
331+
},
332+
]),
333+
});
334+
});
335+
336+
it('should correctly separate screening and feedback questions by type', async () => {
337+
// This test ensures that questions and feedbackQuestions
338+
// are properly filtered by their type discriminator
339+
const res = await client.query<
340+
{ opportunityById: GQLOpportunity },
341+
{ id: string }
342+
>(OPPORTUNITY_BY_ID_QUERY, {
343+
variables: { id: '550e8400-e29b-41d4-a716-446655440001' },
303344
});
345+
346+
expect(res.errors).toBeFalsy();
347+
348+
// Verify screening questions only contain screening type (IDs starting with 750e)
349+
expect(res.data.opportunityById.questions).toHaveLength(2);
350+
expect(
351+
res.data.opportunityById.questions.every((q) => q.id.startsWith('750e')),
352+
).toBe(true);
353+
354+
// Verify feedback questions only contain feedback type (IDs starting with 850e)
355+
expect(res.data.opportunityById.feedbackQuestions).toHaveLength(2);
356+
expect(
357+
res.data.opportunityById.feedbackQuestions.every((q) =>
358+
q.id.startsWith('850e'),
359+
),
360+
).toBe(true);
361+
362+
// Verify no overlap - screening questions should not appear in feedback
363+
const screeningIds = res.data.opportunityById.questions.map((q) => q.id);
364+
const feedbackIds = res.data.opportunityById.feedbackQuestions.map(
365+
(q) => q.id,
366+
);
367+
const hasOverlap = screeningIds.some((id) => feedbackIds.includes(id));
368+
expect(hasOverlap).toBe(false);
304369
});
305370

306371
it('should return UNEXPECTED for false UUID opportunity', async () => {
@@ -1062,6 +1127,209 @@ describe('mutation saveOpportunityScreeningAnswers', () => {
10621127
});
10631128
});
10641129

1130+
describe('mutation saveOpportunityFeedbackAnswers', () => {
1131+
const MUTATION = /* GraphQL */ `
1132+
mutation SaveOpportunityFeedbackAnswers(
1133+
$id: ID!
1134+
$answers: [OpportunityScreeningAnswerInput!]!
1135+
) {
1136+
saveOpportunityFeedbackAnswers(id: $id, answers: $answers) {
1137+
_
1138+
}
1139+
}
1140+
`;
1141+
1142+
it('should require authentication', async () => {
1143+
await testMutationErrorCode(
1144+
client,
1145+
{
1146+
mutation: MUTATION,
1147+
variables: {
1148+
id: '550e8400-e29b-41d4-a716-446655440001',
1149+
answers: [
1150+
{
1151+
questionId: '850e8400-e29b-41d4-a716-446655440001',
1152+
answer: 'From a friend',
1153+
},
1154+
],
1155+
},
1156+
},
1157+
'UNAUTHENTICATED',
1158+
);
1159+
});
1160+
1161+
it('should save feedback answers for authenticated user', async () => {
1162+
loggedUser = '1';
1163+
1164+
const res = await client.mutate(MUTATION, {
1165+
variables: {
1166+
id: '550e8400-e29b-41d4-a716-446655440001',
1167+
answers: [
1168+
{
1169+
questionId: '850e8400-e29b-41d4-a716-446655440001',
1170+
answer: 'From a friend',
1171+
},
1172+
{
1173+
questionId: '850e8400-e29b-41d4-a716-446655440002',
1174+
answer: 'The company culture',
1175+
},
1176+
],
1177+
},
1178+
});
1179+
1180+
expect(res.errors).toBeFalsy();
1181+
expect(res.data.saveOpportunityFeedbackAnswers).toEqual({ _: true });
1182+
1183+
const match = await con.getRepository(OpportunityMatch).findOneByOrFail({
1184+
opportunityId: '550e8400-e29b-41d4-a716-446655440001',
1185+
userId: '1',
1186+
});
1187+
1188+
expect(match.feedback).toEqual(
1189+
expect.arrayContaining([
1190+
{
1191+
screening: 'How did you hear about this opportunity?',
1192+
answer: 'From a friend',
1193+
},
1194+
{
1195+
screening: 'What interests you most about this role?',
1196+
answer: 'The company culture',
1197+
},
1198+
]),
1199+
);
1200+
});
1201+
1202+
it('should allow partial feedback answers since they are optional', async () => {
1203+
loggedUser = '1';
1204+
1205+
const res = await client.mutate(MUTATION, {
1206+
variables: {
1207+
id: '550e8400-e29b-41d4-a716-446655440001',
1208+
answers: [
1209+
{
1210+
questionId: '850e8400-e29b-41d4-a716-446655440001',
1211+
answer: 'From LinkedIn',
1212+
},
1213+
],
1214+
},
1215+
});
1216+
1217+
expect(res.errors).toBeFalsy();
1218+
expect(res.data.saveOpportunityFeedbackAnswers).toEqual({ _: true });
1219+
1220+
const match = await con.getRepository(OpportunityMatch).findOneByOrFail({
1221+
opportunityId: '550e8400-e29b-41d4-a716-446655440001',
1222+
userId: '1',
1223+
});
1224+
1225+
expect(match.feedback).toEqual([
1226+
{
1227+
screening: 'How did you hear about this opportunity?',
1228+
answer: 'From LinkedIn',
1229+
},
1230+
]);
1231+
});
1232+
1233+
it('should allow empty feedback answers since they are optional', async () => {
1234+
loggedUser = '1';
1235+
1236+
const res = await client.mutate(MUTATION, {
1237+
variables: {
1238+
id: '550e8400-e29b-41d4-a716-446655440001',
1239+
answers: [],
1240+
},
1241+
});
1242+
1243+
expect(res.errors).toBeFalsy();
1244+
expect(res.data.saveOpportunityFeedbackAnswers).toEqual({ _: true });
1245+
1246+
const match = await con.getRepository(OpportunityMatch).findOneByOrFail({
1247+
opportunityId: '550e8400-e29b-41d4-a716-446655440001',
1248+
userId: '1',
1249+
});
1250+
1251+
expect(match.feedback).toEqual([]);
1252+
});
1253+
1254+
it('should return FORBIDDEN when match does not exist', async () => {
1255+
loggedUser = '3';
1256+
1257+
await testMutationErrorCode(
1258+
client,
1259+
{
1260+
mutation: MUTATION,
1261+
variables: {
1262+
id: '550e8400-e29b-41d4-a716-446655440001',
1263+
answers: [
1264+
{
1265+
questionId: '850e8400-e29b-41d4-a716-446655440001',
1266+
answer: 'From a friend',
1267+
},
1268+
],
1269+
},
1270+
},
1271+
'FORBIDDEN',
1272+
'Access denied! No match found',
1273+
);
1274+
});
1275+
1276+
it('should return error when there are duplicate answers by questionId', async () => {
1277+
loggedUser = '1';
1278+
1279+
await testMutationErrorCode(
1280+
client,
1281+
{
1282+
mutation: MUTATION,
1283+
variables: {
1284+
id: '550e8400-e29b-41d4-a716-446655440001',
1285+
answers: [
1286+
{
1287+
questionId: '850e8400-e29b-41d4-a716-446655440001',
1288+
answer: 'From a friend',
1289+
},
1290+
{
1291+
questionId: '850e8400-e29b-41d4-a716-446655440001',
1292+
answer: 'From LinkedIn',
1293+
},
1294+
],
1295+
},
1296+
},
1297+
'ZOD_VALIDATION_ERROR',
1298+
'Validation error',
1299+
(errors) => {
1300+
const extensions = errors[0].extensions as unknown as ZodError;
1301+
expect(extensions.issues.length).toEqual(1);
1302+
expect(extensions.issues[0].code).toEqual('custom');
1303+
expect(extensions.issues[0].message).toEqual(
1304+
'Duplicate questionId 850e8400-e29b-41d4-a716-446655440001',
1305+
);
1306+
},
1307+
);
1308+
});
1309+
1310+
it('should return error when the questionId does not belong to opportunity', async () => {
1311+
loggedUser = '1';
1312+
1313+
await testMutationErrorCode(
1314+
client,
1315+
{
1316+
mutation: MUTATION,
1317+
variables: {
1318+
id: '550e8400-e29b-41d4-a716-446655440001',
1319+
answers: [
1320+
{
1321+
questionId: '750e8400-e29b-41d4-a716-446655440003',
1322+
answer: 'Invalid question',
1323+
},
1324+
],
1325+
},
1326+
},
1327+
'CONFLICT',
1328+
'Question 750e8400-e29b-41d4-a716-446655440003 not found for opportunity',
1329+
);
1330+
});
1331+
});
1332+
10651333
describe('mutation acceptOpportunityMatch', () => {
10661334
const MUTATION = /* GraphQL */ `
10671335
mutation AcceptOpportunityMatch($id: ID!) {

src/common/schema/opportunityMatch.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,28 @@ export const opportunityScreeningAnswersSchema = z.object({
2424
});
2525
}),
2626
});
27+
28+
export const opportunityFeedbackAnswersSchema = z.object({
29+
id: z.uuid(),
30+
answers: z
31+
.array(
32+
z.object({
33+
questionId: z.uuid(),
34+
answer: z.string().max(500),
35+
}),
36+
)
37+
.superRefine((answers, ctx) => {
38+
const seen = new Map();
39+
answers.forEach((answer, i) => {
40+
if (seen.has(answer.questionId)) {
41+
ctx.addIssue({
42+
code: 'custom',
43+
message: `Duplicate questionId ${answer.questionId}`,
44+
path: [i],
45+
});
46+
} else {
47+
seen.set(answer.questionId, i);
48+
}
49+
});
50+
}),
51+
});

src/entity/OpportunityMatch.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,9 @@ export class OpportunityMatch {
4848
@Column({ type: 'jsonb', default: '[]' })
4949
screening: Array<Screening>;
5050

51+
@Column({ type: 'jsonb', default: '[]' })
52+
feedback: Array<Screening>;
53+
5154
@Column({ type: 'jsonb', default: '{}' })
5255
applicationRank: z.infer<typeof applicationScoreSchema>;
5356

src/entity/opportunities/Opportunity.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import type { OpportunityUser } from './user';
1818
import type { OpportunityKeyword } from '../OpportunityKeyword';
1919
import type { OpportunityMatch } from '../OpportunityMatch';
2020
import type { QuestionScreening } from '../questions/QuestionScreening';
21+
import type { QuestionFeedback } from '../questions/QuestionFeedback';
2122

2223
@Entity()
2324
@TableInheritance({ column: { type: 'text', name: 'type' } })
@@ -85,4 +86,11 @@ export class Opportunity {
8586
{ lazy: true },
8687
)
8788
questions: Promise<QuestionScreening[]>;
89+
90+
@OneToMany(
91+
'QuestionFeedback',
92+
(question: QuestionFeedback) => question.opportunity,
93+
{ lazy: true },
94+
)
95+
feedbackQuestions: Promise<QuestionFeedback[]>;
8896
}

0 commit comments

Comments
 (0)