Skip to content

Commit bc3ca29

Browse files
rebelchrisclaude
andauthored
feat: add parseOpportunityFeedback worker with Bragi integration (#3430)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent f446832 commit bc3ca29

13 files changed

Lines changed: 554 additions & 13 deletions

File tree

.infra/common.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -460,6 +460,10 @@ export const workers: Worker[] = [
460460
topic: 'gondul.v1.opportunity-preview-results',
461461
subscription: 'api.opportunity-preview-result',
462462
},
463+
{
464+
topic: 'api.v1.opportunity-feedback-submitted',
465+
subscription: 'api.parse-opportunity-feedback',
466+
},
463467
];
464468

465469
export const personalizedDigestWorkers: Worker[] = [

__tests__/schema/opportunity.ts

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6445,6 +6445,131 @@ describe('query opportunityStats', () => {
64456445
});
64466446
});
64476447

6448+
describe('query opportunityFeedback', () => {
6449+
const OPPORTUNITY_FEEDBACK_QUERY = /* GraphQL */ `
6450+
query OpportunityFeedback(
6451+
$opportunityId: ID!
6452+
$first: Int
6453+
$after: String
6454+
) {
6455+
opportunityFeedback(
6456+
opportunityId: $opportunityId
6457+
first: $first
6458+
after: $after
6459+
) {
6460+
pageInfo {
6461+
hasNextPage
6462+
endCursor
6463+
totalCount
6464+
}
6465+
edges {
6466+
node {
6467+
platform
6468+
category
6469+
sentiment
6470+
urgency
6471+
screening
6472+
answer
6473+
}
6474+
}
6475+
}
6476+
}
6477+
`;
6478+
6479+
it('should return only feedback with recruiter platform classification', async () => {
6480+
loggedUser = '1';
6481+
6482+
// Create matches with different feedback platforms
6483+
await con.getRepository(OpportunityMatch).save([
6484+
{
6485+
opportunityId: opportunitiesFixture[0].id,
6486+
userId: usersFixture[1].id,
6487+
status: OpportunityMatchStatus.CandidateAccepted,
6488+
description: { reasoning: 'Test' },
6489+
feedback: [
6490+
{
6491+
screening: 'What interests you?',
6492+
answer: 'The tech stack looks great',
6493+
classification: {
6494+
platform: 2, // RECRUITER - should be returned
6495+
category: 1,
6496+
sentiment: 1,
6497+
urgency: 2,
6498+
},
6499+
},
6500+
{
6501+
screening: 'Internal feedback',
6502+
answer: 'Platform seems slow',
6503+
classification: {
6504+
platform: 1, // DAILY_DEV - should NOT be returned
6505+
category: 2,
6506+
sentiment: 0,
6507+
urgency: 1,
6508+
},
6509+
},
6510+
],
6511+
},
6512+
{
6513+
opportunityId: opportunitiesFixture[0].id,
6514+
userId: usersFixture[2].id,
6515+
status: OpportunityMatchStatus.CandidateAccepted,
6516+
description: { reasoning: 'Test 2' },
6517+
feedback: [
6518+
{
6519+
screening: 'Salary expectations?',
6520+
answer: 'Looking for 150k+',
6521+
classification: {
6522+
platform: 2, // RECRUITER - should be returned
6523+
category: 3,
6524+
sentiment: 0,
6525+
urgency: 1,
6526+
},
6527+
},
6528+
{
6529+
screening: 'Unclassified feedback',
6530+
answer: 'No classification yet',
6531+
// No classification - should NOT be returned
6532+
},
6533+
],
6534+
},
6535+
]);
6536+
6537+
const res = await client.query(OPPORTUNITY_FEEDBACK_QUERY, {
6538+
variables: { opportunityId: opportunitiesFixture[0].id, first: 10 },
6539+
});
6540+
6541+
expect(res.errors).toBeFalsy();
6542+
expect(res.data.opportunityFeedback.pageInfo.totalCount).toBe(2);
6543+
expect(res.data.opportunityFeedback.edges).toHaveLength(2);
6544+
6545+
// Verify all returned feedback has recruiter platform
6546+
const platforms = res.data.opportunityFeedback.edges.map(
6547+
(e: { node: { platform: number } }) => e.node.platform,
6548+
);
6549+
expect(platforms).toEqual([2, 2]);
6550+
6551+
// Verify the answers match expected recruiter feedback
6552+
const answers = res.data.opportunityFeedback.edges.map(
6553+
(e: { node: { answer: string } }) => e.node.answer,
6554+
);
6555+
expect(answers).toContain('The tech stack looks great');
6556+
expect(answers).toContain('Looking for 150k+');
6557+
expect(answers).not.toContain('Platform seems slow'); // DAILY_DEV feedback
6558+
expect(answers).not.toContain('No classification yet'); // Unclassified feedback
6559+
});
6560+
6561+
it('should deny access if user is not a recruiter for the opportunity', async () => {
6562+
loggedUser = '3'; // User 3 is not a recruiter for opportunity 0
6563+
6564+
const res = await client.query(OPPORTUNITY_FEEDBACK_QUERY, {
6565+
variables: { opportunityId: opportunitiesFixture[0].id },
6566+
});
6567+
6568+
expect(res.errors).toBeTruthy();
6569+
expect(res.errors[0].extensions.code).toBe('FORBIDDEN');
6570+
});
6571+
});
6572+
64486573
describe('mutation reimportOpportunity', () => {
64496574
const MUTATION = /* GraphQL */ `
64506575
mutation ReimportOpportunity($payload: ReimportOpportunityInput!) {
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import { expectSuccessfulTypedBackground, saveFixtures } from '../../helpers';
2+
import { parseOpportunityFeedbackWorker as worker } from '../../../src/workers/opportunity/parseOpportunityFeedback';
3+
import { DataSource } from 'typeorm';
4+
import createOrGetConnection from '../../../src/db';
5+
import { OpportunityMatch } from '../../../src/entity/OpportunityMatch';
6+
import { User } from '../../../src/entity';
7+
import { usersFixture } from '../../fixture';
8+
import { OpportunityMatchStatus } from '../../../src/entity/opportunities/types';
9+
import {
10+
FeedbackCategory,
11+
FeedbackPlatform,
12+
FeedbackSentiment,
13+
FeedbackUrgency,
14+
OpportunityState,
15+
OpportunityType,
16+
} from '@dailydotdev/schema';
17+
18+
const mockParseFeedback = jest.fn();
19+
20+
jest.mock('../../../src/integrations/bragi', () => ({
21+
getBragiClient: () => ({
22+
garmr: {
23+
execute: (fn: () => Promise<unknown>) => fn(),
24+
},
25+
instance: {
26+
parseFeedback: (...args: unknown[]) => mockParseFeedback(...args),
27+
},
28+
}),
29+
}));
30+
31+
let con: DataSource;
32+
const testOpportunityId = '550e8400-e29b-41d4-a716-446655440099';
33+
34+
beforeAll(async () => {
35+
con = await createOrGetConnection();
36+
});
37+
38+
beforeEach(async () => {
39+
jest.resetAllMocks();
40+
await saveFixtures(con, User, usersFixture);
41+
42+
// Create a minimal opportunity record
43+
await con.query(
44+
`INSERT INTO opportunity (id, type, state, title, tldr, content, meta)
45+
VALUES ($1, $2, $3, $4, $5, $6, $7)
46+
ON CONFLICT (id) DO NOTHING`,
47+
[
48+
testOpportunityId,
49+
OpportunityType.JOB,
50+
OpportunityState.LIVE,
51+
'Test Opportunity',
52+
'Test TLDR',
53+
JSON.stringify({}),
54+
JSON.stringify({}),
55+
],
56+
);
57+
});
58+
59+
afterEach(async () => {
60+
await con
61+
.getRepository(OpportunityMatch)
62+
.delete({ opportunityId: testOpportunityId });
63+
});
64+
65+
afterAll(async () => {
66+
await con.query('DELETE FROM opportunity WHERE id = $1', [testOpportunityId]);
67+
});
68+
69+
describe('parseOpportunityFeedback worker', () => {
70+
it('should skip when no match is found', async () => {
71+
await expectSuccessfulTypedBackground<'api.v1.opportunity-feedback-submitted'>(
72+
worker,
73+
{
74+
opportunityId: testOpportunityId,
75+
userId: 'nonexistent-user',
76+
},
77+
);
78+
79+
expect(mockParseFeedback).not.toHaveBeenCalled();
80+
});
81+
82+
it('should skip when match has no feedback', async () => {
83+
await con.getRepository(OpportunityMatch).save({
84+
opportunityId: testOpportunityId,
85+
userId: '1',
86+
status: OpportunityMatchStatus.Pending,
87+
description: { reasoning: 'Test match' },
88+
feedback: [],
89+
});
90+
91+
await expectSuccessfulTypedBackground<'api.v1.opportunity-feedback-submitted'>(
92+
worker,
93+
{
94+
opportunityId: testOpportunityId,
95+
userId: '1',
96+
},
97+
);
98+
99+
expect(mockParseFeedback).not.toHaveBeenCalled();
100+
});
101+
102+
it('should parse feedback and store classification in database', async () => {
103+
await con.getRepository(OpportunityMatch).save({
104+
opportunityId: testOpportunityId,
105+
userId: '2',
106+
status: OpportunityMatchStatus.Pending,
107+
description: { reasoning: 'Test match' },
108+
feedback: [{ question: 'How was it?', answer: 'Great experience!' }],
109+
});
110+
111+
mockParseFeedback.mockResolvedValue({
112+
classification: {
113+
platform: FeedbackPlatform.RECRUITER,
114+
category: FeedbackCategory.FEATURE_REQUEST,
115+
sentiment: FeedbackSentiment.POSITIVE,
116+
urgency: FeedbackUrgency.LOW,
117+
},
118+
});
119+
120+
await expectSuccessfulTypedBackground<'api.v1.opportunity-feedback-submitted'>(
121+
worker,
122+
{
123+
opportunityId: testOpportunityId,
124+
userId: '2',
125+
},
126+
);
127+
128+
expect(mockParseFeedback).toHaveBeenCalledWith({
129+
feedback: 'Great experience!',
130+
});
131+
132+
// Verify the classification was stored correctly in the database
133+
const updatedMatch = await con.getRepository(OpportunityMatch).findOne({
134+
where: {
135+
opportunityId: testOpportunityId,
136+
userId: '2',
137+
},
138+
});
139+
140+
expect(updatedMatch?.feedback?.[0]?.classification).toEqual({
141+
platform: FeedbackPlatform.RECRUITER,
142+
category: FeedbackCategory.FEATURE_REQUEST,
143+
sentiment: FeedbackSentiment.POSITIVE,
144+
urgency: FeedbackUrgency.LOW,
145+
});
146+
});
147+
});

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@
3636
"@connectrpc/connect-fastify": "^1.6.1",
3737
"@connectrpc/connect-node": "^1.6.1",
3838
"@dailydotdev/graphql-redis-subscriptions": "^2.4.3",
39-
"@dailydotdev/schema": "0.2.64",
39+
"@dailydotdev/schema": "0.2.65",
4040
"@dailydotdev/ts-ioredis-pool": "^1.0.2",
4141
"@fastify/cookie": "^11.0.2",
4242
"@fastify/cors": "^11.2.0",

pnpm-lock.yaml

Lines changed: 5 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/common/opportunity/pubsub.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -585,3 +585,18 @@ export const notifyOpportunityMatchCandidateReview = async ({
585585
topic: 'api.v1.candidate-review-opportunity',
586586
});
587587
};
588+
589+
export const notifyOpportunityFeedbackSubmitted = async ({
590+
logger,
591+
opportunityId,
592+
userId,
593+
}: {
594+
logger: FastifyBaseLogger;
595+
opportunityId: string;
596+
userId: string;
597+
}) => {
598+
await triggerTypedEvent(logger, 'api.v1.opportunity-feedback-submitted', {
599+
opportunityId,
600+
userId,
601+
});
602+
};

src/common/schema/opportunityMatch.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,23 @@
11
import z from 'zod';
22

3+
export const feedbackClassificationSchema = z.object({
4+
platform: z.number(),
5+
category: z.number(),
6+
sentiment: z.number(),
7+
urgency: z.number(),
8+
});
9+
10+
export const opportunityFeedbackSchema = z.object({
11+
screening: z.string(),
12+
answer: z.string(),
13+
classification: feedbackClassificationSchema.optional(),
14+
});
15+
16+
export type OpportunityFeedback = z.infer<typeof opportunityFeedbackSchema>;
17+
export type FeedbackClassification = z.infer<
18+
typeof feedbackClassificationSchema
19+
>;
20+
321
export const opportunityScreeningAnswersSchema = z.object({
422
id: z.uuid(),
523
answers: z

src/common/typedPubsub.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,10 @@ export type PubSubSchema = {
246246
'gondul.v1.warm-intro-generated': WarmIntro;
247247
'api.v1.user-profile-updated': UserProfileUpdatedMessage;
248248
'gondul.v1.opportunity-preview-results': OpportunityPreviewResult;
249+
'api.v1.opportunity-feedback-submitted': {
250+
opportunityId: string;
251+
userId: string;
252+
};
249253
};
250254

251255
export async function triggerTypedEvent<T extends keyof PubSubSchema>(

0 commit comments

Comments
 (0)