From 2c6c940e6d4c6f74dae8c19ecc14443a8c35e587 Mon Sep 17 00:00:00 2001 From: Chris Bongers Date: Fri, 7 Nov 2025 11:01:40 +0200 Subject: [PATCH 1/2] feat: add feedback option to opportunity --- __tests__/fixture/opportunity.ts | 19 ++ __tests__/schema/opportunity.ts | 260 ++++++++++++++++++ src/common/schema/opportunityMatch.ts | 25 ++ src/entity/OpportunityMatch.ts | 3 + src/entity/opportunities/Opportunity.ts | 8 + src/entity/questions/QuestionFeedback.ts | 21 ++ src/entity/questions/types.ts | 1 + src/graphorm/index.ts | 43 +++ ...2500537748-OpportunityFeedbackQuestions.ts | 19 ++ src/schema/opportunity.ts | 87 +++++- 10 files changed, 485 insertions(+), 1 deletion(-) create mode 100644 src/entity/questions/QuestionFeedback.ts create mode 100644 src/migration/1762500537748-OpportunityFeedbackQuestions.ts diff --git a/__tests__/fixture/opportunity.ts b/__tests__/fixture/opportunity.ts index 61734ecf9e..23ce732f4d 100644 --- a/__tests__/fixture/opportunity.ts +++ b/__tests__/fixture/opportunity.ts @@ -17,6 +17,7 @@ import { SocialMediaType, } from '../../src/common/schema/organizations'; import type { QuestionScreening } from '../../src/entity/questions/QuestionScreening'; +import type { QuestionFeedback } from '../../src/entity/questions/QuestionFeedback'; import { demoCompany } from '../../src/common'; export const organizationsFixture: DeepPartial[] = [ @@ -313,3 +314,21 @@ export const opportunityMatchesFixture: DeepPartial[] = [ updatedAt: new Date('2023-01-03'), }, ]; + +export const opportunityFeedbackQuestionsFixture: DeepPartial[] = + [ + { + id: '850e8400-e29b-41d4-a716-446655440001', + title: 'How did you hear about this opportunity?', + placeholder: 'e.g., LinkedIn, friend, etc.', + opportunityId: opportunitiesFixture[0].id, + questionOrder: 0, + }, + { + id: '850e8400-e29b-41d4-a716-446655440002', + title: 'What interests you most about this role?', + placeholder: 'Your answer here...', + opportunityId: opportunitiesFixture[0].id, + questionOrder: 1, + }, + ]; diff --git a/__tests__/schema/opportunity.ts b/__tests__/schema/opportunity.ts index 9cc5b89af0..63fe440318 100644 --- a/__tests__/schema/opportunity.ts +++ b/__tests__/schema/opportunity.ts @@ -26,6 +26,7 @@ import { opportunityKeywordsFixture, opportunityMatchesFixture, opportunityQuestionsFixture, + opportunityFeedbackQuestionsFixture, organizationsFixture, } from '../fixture/opportunity'; import { OpportunityUser } from '../../src/entity/opportunities/user'; @@ -57,6 +58,7 @@ import { fileTypeFromBuffer } from '../setup'; import { EMPLOYMENT_AGREEMENT_BUCKET_NAME } from '../../src/config'; import { RoleType } from '../../src/common/schema/userCandidate'; import { QuestionType } from '../../src/entity/questions/types'; +import { QuestionFeedback } from '../../src/entity/questions/QuestionFeedback'; import type { FastifyInstance } from 'fastify'; import type { Context } from '../../src/Context'; import { createMockGondulTransport } from '../helpers'; @@ -105,6 +107,11 @@ beforeEach(async () => { await saveFixtures(con, Organization, organizationsFixture); await saveFixtures(con, Opportunity, opportunitiesFixture); await saveFixtures(con, QuestionScreening, opportunityQuestionsFixture); + await saveFixtures( + con, + QuestionFeedback, + opportunityFeedbackQuestionsFixture, + ); await saveFixtures(con, OpportunityKeyword, opportunityKeywordsFixture); await saveFixtures(con, OpportunityMatch, opportunityMatchesFixture); await saveFixtures(con, OpportunityUser, [ @@ -189,6 +196,13 @@ describe('query opportunityById', () => { placeholder opportunityId } + feedbackQuestions { + id + title + order + placeholder + opportunityId + } } } @@ -300,9 +314,52 @@ describe('query opportunityById', () => { order: 0, }, ]), + feedbackQuestions: expect.arrayContaining([ + { + id: '850e8400-e29b-41d4-a716-446655440001', + title: 'How did you hear about this opportunity?', + placeholder: 'e.g., LinkedIn, friend, etc.', + opportunityId: '550e8400-e29b-41d4-a716-446655440001', + order: 0, + }, + { + id: '850e8400-e29b-41d4-a716-446655440002', + title: 'What interests you most about this role?', + placeholder: 'Your answer here...', + opportunityId: '550e8400-e29b-41d4-a716-446655440001', + order: 1, + }, + ]), }); }); + it('should correctly separate screening and feedback questions by type', async () => { + // This test ensures that questions and feedbackQuestions + // are properly filtered by their type discriminator + const res = await client.query< + { opportunityById: GQLOpportunity }, + { id: string } + >(OPPORTUNITY_BY_ID_QUERY, { + variables: { id: '550e8400-e29b-41d4-a716-446655440001' }, + }); + + expect(res.errors).toBeFalsy(); + + // Verify screening questions only contain screening type (IDs starting with 750e) + expect(res.data.opportunityById.questions).toHaveLength(2); + expect(res.data.opportunityById.questions.every(q => q.id.startsWith('750e'))).toBe(true); + + // Verify feedback questions only contain feedback type (IDs starting with 850e) + expect(res.data.opportunityById.feedbackQuestions).toHaveLength(2); + expect(res.data.opportunityById.feedbackQuestions.every(q => q.id.startsWith('850e'))).toBe(true); + + // Verify no overlap - screening questions should not appear in feedback + const screeningIds = res.data.opportunityById.questions.map(q => q.id); + const feedbackIds = res.data.opportunityById.feedbackQuestions.map(q => q.id); + const hasOverlap = screeningIds.some(id => feedbackIds.includes(id)); + expect(hasOverlap).toBe(false); + }); + it('should return UNEXPECTED for false UUID opportunity', async () => { await testQueryErrorCode( client, @@ -1062,6 +1119,209 @@ describe('mutation saveOpportunityScreeningAnswers', () => { }); }); +describe('mutation saveOpportunityFeedbackAnswers', () => { + const MUTATION = /* GraphQL */ ` + mutation SaveOpportunityFeedbackAnswers( + $id: ID! + $answers: [OpportunityScreeningAnswerInput!]! + ) { + saveOpportunityFeedbackAnswers(id: $id, answers: $answers) { + _ + } + } + `; + + it('should require authentication', async () => { + await testMutationErrorCode( + client, + { + mutation: MUTATION, + variables: { + id: '550e8400-e29b-41d4-a716-446655440001', + answers: [ + { + questionId: '850e8400-e29b-41d4-a716-446655440001', + answer: 'From a friend', + }, + ], + }, + }, + 'UNAUTHENTICATED', + ); + }); + + it('should save feedback answers for authenticated user', async () => { + loggedUser = '1'; + + const res = await client.mutate(MUTATION, { + variables: { + id: '550e8400-e29b-41d4-a716-446655440001', + answers: [ + { + questionId: '850e8400-e29b-41d4-a716-446655440001', + answer: 'From a friend', + }, + { + questionId: '850e8400-e29b-41d4-a716-446655440002', + answer: 'The company culture', + }, + ], + }, + }); + + expect(res.errors).toBeFalsy(); + expect(res.data.saveOpportunityFeedbackAnswers).toEqual({ _: true }); + + const match = await con.getRepository(OpportunityMatch).findOneByOrFail({ + opportunityId: '550e8400-e29b-41d4-a716-446655440001', + userId: '1', + }); + + expect(match.feedback).toEqual( + expect.arrayContaining([ + { + screening: 'How did you hear about this opportunity?', + answer: 'From a friend', + }, + { + screening: 'What interests you most about this role?', + answer: 'The company culture', + }, + ]), + ); + }); + + it('should allow partial feedback answers since they are optional', async () => { + loggedUser = '1'; + + const res = await client.mutate(MUTATION, { + variables: { + id: '550e8400-e29b-41d4-a716-446655440001', + answers: [ + { + questionId: '850e8400-e29b-41d4-a716-446655440001', + answer: 'From LinkedIn', + }, + ], + }, + }); + + expect(res.errors).toBeFalsy(); + expect(res.data.saveOpportunityFeedbackAnswers).toEqual({ _: true }); + + const match = await con.getRepository(OpportunityMatch).findOneByOrFail({ + opportunityId: '550e8400-e29b-41d4-a716-446655440001', + userId: '1', + }); + + expect(match.feedback).toEqual([ + { + screening: 'How did you hear about this opportunity?', + answer: 'From LinkedIn', + }, + ]); + }); + + it('should allow empty feedback answers since they are optional', async () => { + loggedUser = '1'; + + const res = await client.mutate(MUTATION, { + variables: { + id: '550e8400-e29b-41d4-a716-446655440001', + answers: [], + }, + }); + + expect(res.errors).toBeFalsy(); + expect(res.data.saveOpportunityFeedbackAnswers).toEqual({ _: true }); + + const match = await con.getRepository(OpportunityMatch).findOneByOrFail({ + opportunityId: '550e8400-e29b-41d4-a716-446655440001', + userId: '1', + }); + + expect(match.feedback).toEqual([]); + }); + + it('should return FORBIDDEN when match does not exist', async () => { + loggedUser = '3'; + + await testMutationErrorCode( + client, + { + mutation: MUTATION, + variables: { + id: '550e8400-e29b-41d4-a716-446655440001', + answers: [ + { + questionId: '850e8400-e29b-41d4-a716-446655440001', + answer: 'From a friend', + }, + ], + }, + }, + 'FORBIDDEN', + 'Access denied! No match found', + ); + }); + + it('should return error when there are duplicate answers by questionId', async () => { + loggedUser = '1'; + + await testMutationErrorCode( + client, + { + mutation: MUTATION, + variables: { + id: '550e8400-e29b-41d4-a716-446655440001', + answers: [ + { + questionId: '850e8400-e29b-41d4-a716-446655440001', + answer: 'From a friend', + }, + { + questionId: '850e8400-e29b-41d4-a716-446655440001', + answer: 'From LinkedIn', + }, + ], + }, + }, + 'ZOD_VALIDATION_ERROR', + 'Validation error', + (errors) => { + const extensions = errors[0].extensions as unknown as ZodError; + expect(extensions.issues.length).toEqual(1); + expect(extensions.issues[0].code).toEqual('custom'); + expect(extensions.issues[0].message).toEqual( + 'Duplicate questionId 850e8400-e29b-41d4-a716-446655440001', + ); + }, + ); + }); + + it('should return error when the questionId does not belong to opportunity', async () => { + loggedUser = '1'; + + await testMutationErrorCode( + client, + { + mutation: MUTATION, + variables: { + id: '550e8400-e29b-41d4-a716-446655440001', + answers: [ + { + questionId: '750e8400-e29b-41d4-a716-446655440003', + answer: 'Invalid question', + }, + ], + }, + }, + 'CONFLICT', + 'Question 750e8400-e29b-41d4-a716-446655440003 not found for opportunity', + ); + }); +}); + describe('mutation acceptOpportunityMatch', () => { const MUTATION = /* GraphQL */ ` mutation AcceptOpportunityMatch($id: ID!) { diff --git a/src/common/schema/opportunityMatch.ts b/src/common/schema/opportunityMatch.ts index 4c22a04edc..53a0665e4c 100644 --- a/src/common/schema/opportunityMatch.ts +++ b/src/common/schema/opportunityMatch.ts @@ -24,3 +24,28 @@ export const opportunityScreeningAnswersSchema = z.object({ }); }), }); + +export const opportunityFeedbackAnswersSchema = z.object({ + id: z.uuid(), + answers: z + .array( + z.object({ + questionId: z.uuid(), + answer: z.string().max(500), + }), + ) + .superRefine((answers, ctx) => { + const seen = new Map(); + answers.forEach((answer, i) => { + if (seen.has(answer.questionId)) { + ctx.addIssue({ + code: 'custom', + message: `Duplicate questionId ${answer.questionId}`, + path: [i], + }); + } else { + seen.set(answer.questionId, i); + } + }); + }), +}); diff --git a/src/entity/OpportunityMatch.ts b/src/entity/OpportunityMatch.ts index 29f73365b2..8319763fd4 100644 --- a/src/entity/OpportunityMatch.ts +++ b/src/entity/OpportunityMatch.ts @@ -48,6 +48,9 @@ export class OpportunityMatch { @Column({ type: 'jsonb', default: '[]' }) screening: Array; + @Column({ type: 'jsonb', default: '[]' }) + feedback: Array; + @Column({ type: 'jsonb', default: '{}' }) applicationRank: z.infer; diff --git a/src/entity/opportunities/Opportunity.ts b/src/entity/opportunities/Opportunity.ts index f92ef59250..21fad2bad4 100644 --- a/src/entity/opportunities/Opportunity.ts +++ b/src/entity/opportunities/Opportunity.ts @@ -18,6 +18,7 @@ import type { OpportunityUser } from './user'; import type { OpportunityKeyword } from '../OpportunityKeyword'; import type { OpportunityMatch } from '../OpportunityMatch'; import type { QuestionScreening } from '../questions/QuestionScreening'; +import type { QuestionFeedback } from '../questions/QuestionFeedback'; @Entity() @TableInheritance({ column: { type: 'text', name: 'type' } }) @@ -85,4 +86,11 @@ export class Opportunity { { lazy: true }, ) questions: Promise; + + @OneToMany( + 'QuestionFeedback', + (question: QuestionFeedback) => question.opportunity, + { lazy: true }, + ) + feedbackQuestions: Promise; } diff --git a/src/entity/questions/QuestionFeedback.ts b/src/entity/questions/QuestionFeedback.ts new file mode 100644 index 0000000000..728e6401b4 --- /dev/null +++ b/src/entity/questions/QuestionFeedback.ts @@ -0,0 +1,21 @@ +import { ChildEntity, Column, JoinColumn, ManyToOne } from 'typeorm'; +import { Question } from './Question'; +import { QuestionType } from './types'; +import type { Opportunity } from '../opportunities/Opportunity'; + +@ChildEntity(QuestionType.Feedback) +export class QuestionFeedback extends Question { + @Column({ type: 'uuid' }) + opportunityId: string; + + @ManyToOne( + 'Opportunity', + (opportunity: Opportunity) => opportunity.feedbackQuestions, + { lazy: true, onDelete: 'CASCADE' }, + ) + @JoinColumn({ + name: 'opportunityId', + foreignKeyConstraintName: 'FK_question_feedback_opportunity_id', + }) + opportunity: Promise; +} diff --git a/src/entity/questions/types.ts b/src/entity/questions/types.ts index 90253da2a7..8326bfbac4 100644 --- a/src/entity/questions/types.ts +++ b/src/entity/questions/types.ts @@ -1,4 +1,5 @@ export enum QuestionType { Screening = 'screening', CandidatePreference = 'candidate_preference', + Feedback = 'feedback', } diff --git a/src/graphorm/index.ts b/src/graphorm/index.ts index 351da0d580..b7fdd5be97 100644 --- a/src/graphorm/index.ts +++ b/src/graphorm/index.ts @@ -63,6 +63,7 @@ import { OpportunityUserRecruiter } from '../entity/opportunities/user'; import { OpportunityUserType } from '../entity/opportunities/types'; import { OrganizationLinkType } from '../common/schema/organizations'; import type { GCSBlob } from '../common/schema/userCandidate'; +import { QuestionType } from '../entity/questions/types'; const existsByUserAndPost = (entity: string, build?: (queryBuilder: QueryBuilder) => QueryBuilder) => @@ -1535,6 +1536,34 @@ const obj = new GraphORM({ childColumn: 'opportunityId', }, }, + questions: { + relation: { + isMany: true, + parentColumn: 'id', + childColumn: 'opportunityId', + customRelation: (_, parentAlias, childAlias, qb): QueryBuilder => + qb + .where(`${childAlias}."opportunityId" = "${parentAlias}".id`) + .andWhere(`${childAlias}."type" = :screeningType`, { + screeningType: QuestionType.Screening, + }) + .orderBy(`${childAlias}."questionOrder"`, 'ASC'), + }, + }, + feedbackQuestions: { + relation: { + isMany: true, + parentColumn: 'id', + childColumn: 'opportunityId', + customRelation: (_, parentAlias, childAlias, qb): QueryBuilder => + qb + .where(`${childAlias}."opportunityId" = "${parentAlias}".id`) + .andWhere(`${childAlias}."type" = :feedbackType`, { + feedbackType: QuestionType.Feedback, + }) + .orderBy(`${childAlias}."questionOrder"`, 'ASC'), + }, + }, }, }, OpportunityScreeningQuestion: { @@ -1548,6 +1577,17 @@ const obj = new GraphORM({ }, }, }, + OpportunityFeedbackQuestion: { + from: 'QuestionFeedback', + requiredColumns: ['questionOrder'], + additionalQuery: (_, alias, qb) => + qb.orderBy(`${alias}."questionOrder"`, 'ASC'), + fields: { + order: { + alias: { field: 'questionOrder', type: 'int' }, + }, + }, + }, OpportunityMatch: { fields: { createdAt: { @@ -1562,6 +1602,9 @@ const obj = new GraphORM({ screening: { jsonType: true, }, + feedback: { + jsonType: true, + }, applicationRank: { jsonType: true, }, diff --git a/src/migration/1762500537748-OpportunityFeedbackQuestions.ts b/src/migration/1762500537748-OpportunityFeedbackQuestions.ts new file mode 100644 index 0000000000..0940478347 --- /dev/null +++ b/src/migration/1762500537748-OpportunityFeedbackQuestions.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class OpportunityFeedbackQuestions1762500537748 + implements MigrationInterface +{ + name = 'OpportunityFeedbackQuestions1762500537748'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "opportunity_match" ADD "feedback" jsonb NOT NULL DEFAULT '[]'`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "opportunity_match" DROP COLUMN "feedback"`, + ); + } +} diff --git a/src/schema/opportunity.ts b/src/schema/opportunity.ts index 79842e6d2f..d051bd9ba6 100644 --- a/src/schema/opportunity.ts +++ b/src/schema/opportunity.ts @@ -20,7 +20,10 @@ import { userCandidateToggleKeywordSchema, } from '../common/schema/userCandidate'; import { Alerts } from '../entity'; -import { opportunityScreeningAnswersSchema } from '../common/schema/opportunityMatch'; +import { + opportunityScreeningAnswersSchema, + opportunityFeedbackAnswersSchema, +} from '../common/schema/opportunityMatch'; import { OpportunityJob } from '../entity/opportunities/OpportunityJob'; import { ForbiddenError } from 'apollo-server-errors'; import { ConflictError, NotFoundError } from '../errors'; @@ -130,6 +133,14 @@ export const typeDefs = /* GraphQL */ ` opportunityId: ID! } + type OpportunityFeedbackQuestion { + id: ID! + title: String! + order: Int! + placeholder: String + opportunityId: ID! + } + type Opportunity { id: ID! type: ProtoEnumValue! @@ -143,6 +154,7 @@ export const typeDefs = /* GraphQL */ ` recruiters: [User!]! keywords: [OpportunityKeyword]! questions: [OpportunityScreeningQuestion]! + feedbackQuestions: [OpportunityFeedbackQuestion]! } type OpportunityMatchDescription { @@ -295,6 +307,15 @@ export const typeDefs = /* GraphQL */ ` answers: [OpportunityScreeningAnswerInput!]! ): EmptyResponse @auth + saveOpportunityFeedbackAnswers( + """ + Id of the Opportunity + """ + id: ID! + + answers: [OpportunityScreeningAnswerInput!]! + ): EmptyResponse @auth + acceptOpportunityMatch( """ Id of the Opportunity @@ -545,6 +566,70 @@ export const resolvers: IResolvers = traceResolvers< ); return { _: true }; }, + saveOpportunityFeedbackAnswers: async ( + _, + payload: z.infer, + { userId, con, log }: AuthContext, + ): Promise => { + const safePayload = opportunityFeedbackAnswersSchema.safeParse(payload); + if (safePayload.error) { + throw safePayload.error; + } + + const opportunityId = safePayload.data.id; + const answers = safePayload.data.answers; + + const [match, opportunity] = await Promise.all([ + con.getRepository(OpportunityMatch).findOneBy({ + opportunityId, + userId, + }), + con.getRepository(OpportunityJob).findOneOrFail({ + where: { id: opportunityId }, + relations: { + feedbackQuestions: true, + }, + }), + ]); + + if (!match) { + throw new ForbiddenError(`Access denied! No match found`); + } + + const questions = await opportunity.feedbackQuestions; + + // Feedback questions are optional, so validate only that provided questionIds exist + const feedback = answers.map((answer) => { + const question = questions.find((q) => q.id === answer.questionId); + if (!question) { + log.error( + { answer, questions, opportunityId }, + 'Question not found for opportunity', + ); + throw new ConflictError( + `Question ${answer.questionId} not found for opportunity`, + ); + } + + return { + screening: question.title, + answer: answer.answer, + }; + }); + + await con.getRepository(OpportunityMatch).upsert( + { + opportunityId, + userId, + feedback, + }, + { + conflictPaths: ['opportunityId', 'userId'], + skipUpdateIfNoValuesChanged: true, + }, + ); + return { _: true }; + }, acceptOpportunityMatch: async ( _, { id }: { id: string }, From 6454aeb8d18a560504080674826003a1b64ad136 Mon Sep 17 00:00:00 2001 From: Chris Bongers Date: Fri, 7 Nov 2025 11:06:55 +0200 Subject: [PATCH 2/2] fix: linting --- __tests__/schema/opportunity.ts | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/__tests__/schema/opportunity.ts b/__tests__/schema/opportunity.ts index 63fe440318..06a43287ad 100644 --- a/__tests__/schema/opportunity.ts +++ b/__tests__/schema/opportunity.ts @@ -347,16 +347,24 @@ describe('query opportunityById', () => { // Verify screening questions only contain screening type (IDs starting with 750e) expect(res.data.opportunityById.questions).toHaveLength(2); - expect(res.data.opportunityById.questions.every(q => q.id.startsWith('750e'))).toBe(true); + expect( + res.data.opportunityById.questions.every((q) => q.id.startsWith('750e')), + ).toBe(true); // Verify feedback questions only contain feedback type (IDs starting with 850e) expect(res.data.opportunityById.feedbackQuestions).toHaveLength(2); - expect(res.data.opportunityById.feedbackQuestions.every(q => q.id.startsWith('850e'))).toBe(true); + expect( + res.data.opportunityById.feedbackQuestions.every((q) => + q.id.startsWith('850e'), + ), + ).toBe(true); // Verify no overlap - screening questions should not appear in feedback - const screeningIds = res.data.opportunityById.questions.map(q => q.id); - const feedbackIds = res.data.opportunityById.feedbackQuestions.map(q => q.id); - const hasOverlap = screeningIds.some(id => feedbackIds.includes(id)); + const screeningIds = res.data.opportunityById.questions.map((q) => q.id); + const feedbackIds = res.data.opportunityById.feedbackQuestions.map( + (q) => q.id, + ); + const hasOverlap = screeningIds.some((id) => feedbackIds.includes(id)); expect(hasOverlap).toBe(false); });