Skip to content

Commit 20285c0

Browse files
authored
fix: slack for internal review (#3411)
1 parent 8b9a9f1 commit 20285c0

15 files changed

Lines changed: 549 additions & 5 deletions

File tree

.infra/common.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -433,6 +433,10 @@ export const workers: Worker[] = [
433433
topic: 'api.v1.candidate-accepted-opportunity',
434434
subscription: 'api.recruiter-new-candidate-notification',
435435
},
436+
{
437+
topic: 'api.v1.candidate-review-opportunity',
438+
subscription: 'api.candidate-review-opportunity-slack',
439+
},
436440
{
437441
topic: 'api.v1.opportunity-went-live',
438442
subscription: 'api.recruiter-opportunity-live-notification',

AGENTS.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,20 @@ The migration generator compares entities against the local database schema. Ens
117117
- `.infra/common.ts` - Worker subscription definitions
118118
- `.infra/index.ts` - Main Pulumi deployment configuration
119119

120+
## Code Style Preferences
121+
122+
**Keep implementations concise:**
123+
- Prefer short, readable implementations over verbose ones
124+
- Avoid excessive logging - errors will propagate naturally
125+
- Use early returns instead of nested conditionals
126+
- Extract repeated patterns into small inline helpers (e.g., `const respond = (text) => ...`)
127+
- Combine related checks (e.g., `if (!match || match.status !== X)` instead of separate blocks)
128+
129+
**PubSub topics should be general-purpose:**
130+
- Topics should contain only essential identifiers (e.g., `{ opportunityId, userId }`)
131+
- Subscribers fetch their own data - don't optimize topic payloads for specific consumers
132+
- This allows multiple subscribers with different data needs
133+
120134
## Best Practices & Lessons Learned
121135

122136
**Avoiding Code Duplication:**

__tests__/integrations/slack.ts

Lines changed: 153 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import appFunc from '../../src';
22
import { FastifyInstance } from 'fastify';
33
import { authorizeRequest, saveFixtures } from '../helpers';
4-
import { User } from '../../src/entity';
4+
import { Organization, User } from '../../src/entity';
55
import { usersFixture } from '../fixture';
66
import { DataSource } from 'typeorm';
77
import createOrGetConnection from '../../src/db';
@@ -11,11 +11,20 @@ import {
1111
UserIntegration,
1212
UserIntegrationType,
1313
} from '../../src/entity/UserIntegration';
14-
import { SlackEvent } from '../../src/common';
14+
import { SlackEvent, verifySlackSignature } from '../../src/common';
1515
import {
1616
AnalyticsEventName,
1717
sendAnalyticsEvent,
1818
} from '../../src/integrations/analytics';
19+
import { DatasetLocation } from '../../src/entity/dataset/DatasetLocation';
20+
import { OpportunityJob } from '../../src/entity/opportunities/OpportunityJob';
21+
import {
22+
datasetLocationsFixture,
23+
opportunitiesFixture,
24+
organizationsFixture,
25+
} from '../fixture/opportunity';
26+
import { OpportunityMatchStatus } from '../../src/entity/opportunities/types';
27+
import { OpportunityMatch } from '../../src/entity/OpportunityMatch';
1928

2029
jest.mock('../../src/integrations/analytics', () => ({
2130
...(jest.requireActual('../../src/integrations/analytics') as Record<
@@ -25,6 +34,20 @@ jest.mock('../../src/integrations/analytics', () => ({
2534
sendAnalyticsEvent: jest.fn(),
2635
}));
2736

37+
const actualVerifySlackSignature =
38+
jest.requireActual<typeof import('../../src/common')>(
39+
'../../src/common',
40+
).verifySlackSignature;
41+
42+
jest.mock('../../src/common', () => ({
43+
...(jest.requireActual('../../src/common') as Record<string, unknown>),
44+
verifySlackSignature: jest.fn(),
45+
}));
46+
47+
const mockVerifySlackSignature = verifySlackSignature as jest.MockedFunction<
48+
typeof verifySlackSignature
49+
>;
50+
2851
let app: FastifyInstance;
2952
let con: DataSource;
3053

@@ -39,6 +62,8 @@ afterAll(() => app.close());
3962
beforeEach(async () => {
4063
nock.cleanAll();
4164
jest.resetAllMocks();
65+
// Use the actual implementation by default (for events tests)
66+
mockVerifySlackSignature.mockImplementation(actualVerifySlackSignature);
4267
await saveFixtures(con, User, usersFixture);
4368
});
4469

@@ -482,3 +507,129 @@ describe('POST /integrations/slack/events', () => {
482507
expect(await teamIntegrationsQuery.getCount()).toBe(1);
483508
});
484509
});
510+
511+
describe('POST /integrations/slack/interactions', () => {
512+
const createInteractionPayload = (
513+
actionId: string,
514+
opportunityId: string,
515+
userId: string,
516+
) =>
517+
`payload=${encodeURIComponent(
518+
JSON.stringify({
519+
type: 'block_actions',
520+
actions: [
521+
{
522+
action_id: actionId,
523+
value: JSON.stringify({ opportunityId, userId }),
524+
},
525+
],
526+
response_url: 'https://hooks.slack.com/actions/test',
527+
user: { id: 'U123', username: 'testuser' },
528+
}),
529+
)}`;
530+
531+
beforeEach(async () => {
532+
await saveFixtures(con, DatasetLocation, datasetLocationsFixture);
533+
await saveFixtures(con, Organization, organizationsFixture);
534+
await saveFixtures(con, OpportunityJob, opportunitiesFixture);
535+
mockVerifySlackSignature.mockReturnValue(true);
536+
});
537+
538+
it('should return 403 when signature is invalid', async () => {
539+
mockVerifySlackSignature.mockReturnValue(false);
540+
541+
const { body } = await request(app.server)
542+
.post('/integrations/slack/interactions')
543+
.set('Content-Type', 'application/x-www-form-urlencoded')
544+
.send(createInteractionPayload('candidate_review_accept', 'opp1', 'u1'))
545+
.expect(403);
546+
547+
expect(body).toEqual({ error: 'invalid signature' });
548+
});
549+
550+
it('should accept candidate and update match status', async () => {
551+
const match = {
552+
opportunityId: '550e8400-e29b-41d4-a716-446655440001',
553+
userId: '1',
554+
status: OpportunityMatchStatus.CandidateReview,
555+
createdAt: new Date(),
556+
updatedAt: new Date(),
557+
};
558+
await saveFixtures(con, OpportunityMatch, [match]);
559+
560+
nock('https://hooks.slack.com').post('/actions/test').reply(200);
561+
562+
await request(app.server)
563+
.post('/integrations/slack/interactions')
564+
.set('Content-Type', 'application/x-www-form-urlencoded')
565+
.set('x-slack-request-timestamp', '1722461509')
566+
.set(
567+
'x-slack-signature',
568+
'v0=test', // Signature validation is mocked in test env
569+
)
570+
.send(
571+
createInteractionPayload(
572+
'candidate_review_accept',
573+
match.opportunityId,
574+
match.userId,
575+
),
576+
)
577+
.expect(200);
578+
579+
const updatedMatch = await con.getRepository(OpportunityMatch).findOneBy({
580+
opportunityId: match.opportunityId,
581+
userId: match.userId,
582+
});
583+
expect(updatedMatch?.status).toBe(OpportunityMatchStatus.CandidateAccepted);
584+
});
585+
586+
it('should reject candidate and update match status', async () => {
587+
const match = {
588+
opportunityId: '550e8400-e29b-41d4-a716-446655440001',
589+
userId: '1',
590+
status: OpportunityMatchStatus.CandidateReview,
591+
createdAt: new Date(),
592+
updatedAt: new Date(),
593+
};
594+
await saveFixtures(con, OpportunityMatch, [match]);
595+
596+
nock('https://hooks.slack.com').post('/actions/test').reply(200);
597+
598+
await request(app.server)
599+
.post('/integrations/slack/interactions')
600+
.set('Content-Type', 'application/x-www-form-urlencoded')
601+
.set('x-slack-request-timestamp', '1722461509')
602+
.set('x-slack-signature', 'v0=test')
603+
.send(
604+
createInteractionPayload(
605+
'candidate_review_reject',
606+
match.opportunityId,
607+
match.userId,
608+
),
609+
)
610+
.expect(200);
611+
612+
const updatedMatch = await con.getRepository(OpportunityMatch).findOneBy({
613+
opportunityId: match.opportunityId,
614+
userId: match.userId,
615+
});
616+
expect(updatedMatch?.status).toBe(OpportunityMatchStatus.RecruiterRejected);
617+
});
618+
619+
it('should return 200 for unknown action types', async () => {
620+
await request(app.server)
621+
.post('/integrations/slack/interactions')
622+
.set('Content-Type', 'application/x-www-form-urlencoded')
623+
.set('x-slack-request-timestamp', '1722461509')
624+
.set('x-slack-signature', 'v0=test')
625+
.send(
626+
`payload=${encodeURIComponent(
627+
JSON.stringify({
628+
type: 'block_actions',
629+
actions: [{ action_id: 'unknown_action', value: '{}' }],
630+
}),
631+
)}`,
632+
)
633+
.expect(200);
634+
});
635+
});

__tests__/schema/opportunity.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2477,7 +2477,7 @@ describe('mutation acceptOpportunityMatch', () => {
24772477
await con.getRepository(OpportunityMatch).countBy({
24782478
opportunityId: '550e8400-e29b-41d4-a716-446655440001',
24792479
userId: '1',
2480-
status: OpportunityMatchStatus.CandidateAccepted,
2480+
status: OpportunityMatchStatus.CandidateReview,
24812481
}),
24822482
).toEqual(1);
24832483
});
@@ -2553,7 +2553,7 @@ describe('mutation rejectOpportunityMatch', () => {
25532553
);
25542554
});
25552555

2556-
it('should accept opportunity match for authenticated user', async () => {
2556+
it('should reject opportunity match for authenticated user', async () => {
25572557
loggedUser = '1';
25582558

25592559
expect(
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { expectSuccessfulTypedBackground, saveFixtures } from '../helpers';
2+
import worker from '../../src/workers/candidateReviewOpportunitySlack';
3+
import { OpportunityMatch } from '../../src/entity/OpportunityMatch';
4+
import { User } from '../../src/entity';
5+
import createOrGetConnection from '../../src/db';
6+
import { webhooks } from '../../src/common/slack';
7+
import { OpportunityMatchStatus } from '../../src/entity/opportunities/types';
8+
import { OpportunityJob } from '../../src/entity/opportunities/OpportunityJob';
9+
import { Organization } from '../../src/entity/Organization';
10+
import {
11+
organizationsFixture,
12+
opportunitiesFixture,
13+
datasetLocationsFixture,
14+
} from '../fixture/opportunity';
15+
import { usersFixture } from '../fixture/user';
16+
import { DatasetLocation } from '../../src/entity/dataset/DatasetLocation';
17+
18+
jest.spyOn(webhooks.recruiter, 'send').mockResolvedValue(undefined);
19+
20+
beforeEach(async () => {
21+
jest.clearAllMocks();
22+
const con = await createOrGetConnection();
23+
await saveFixtures(con, DatasetLocation, datasetLocationsFixture);
24+
await saveFixtures(con, Organization, organizationsFixture);
25+
await saveFixtures(con, OpportunityJob, opportunitiesFixture);
26+
await saveFixtures(con, User, usersFixture);
27+
});
28+
29+
describe('candidateReviewOpportunitySlack worker', () => {
30+
it('should send slack notification with accept/reject buttons', async () => {
31+
const con = await createOrGetConnection();
32+
await saveFixtures(con, OpportunityMatch, [
33+
{
34+
opportunityId: '550e8400-e29b-41d4-a716-446655440001',
35+
userId: '1',
36+
status: OpportunityMatchStatus.CandidateReview,
37+
screening: [{ screening: 'Favorite language?', answer: 'TypeScript' }],
38+
applicationRank: { score: 85 },
39+
},
40+
]);
41+
42+
await expectSuccessfulTypedBackground(worker, {
43+
opportunityId: '550e8400-e29b-41d4-a716-446655440001',
44+
userId: '1',
45+
});
46+
47+
expect(webhooks.recruiter.send).toHaveBeenCalledTimes(1);
48+
const blocks = (webhooks.recruiter.send as jest.Mock).mock.calls[0][0]
49+
.blocks;
50+
const actions = blocks.find((b: { type: string }) => b.type === 'actions');
51+
expect(actions.elements).toHaveLength(2);
52+
expect(actions.elements[0].action_id).toBe('candidate_review_accept');
53+
expect(actions.elements[1].action_id).toBe('candidate_review_reject');
54+
});
55+
});

src/common/opportunity/pubsub.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -551,3 +551,17 @@ export const notifyCandidatePreferenceChange = async ({
551551
);
552552
}
553553
};
554+
555+
export const notifyOpportunityMatchCandidateReview = async ({
556+
logger,
557+
data,
558+
}: {
559+
con: DataSource;
560+
logger: FastifyBaseLogger;
561+
data: ChangeObject<OpportunityMatch>;
562+
}) => {
563+
await triggerTypedEvent(logger, 'api.v1.candidate-review-opportunity', {
564+
opportunityId: data.opportunityId,
565+
userId: data.userId,
566+
});
567+
};

src/common/schema/slack.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { z } from 'zod';
2+
3+
export const slackOpportunityActionValueSchema = z.object({
4+
opportunityId: z.string(),
5+
userId: z.string(),
6+
});
7+
8+
export const slackOpportunityCandidateReviewPayloadSchema = z.object({
9+
type: z.literal('block_actions'),
10+
actions: z
11+
.array(
12+
z.object({
13+
action_id: z.enum([
14+
'candidate_review_accept',
15+
'candidate_review_reject',
16+
]),
17+
value: z.string(),
18+
}),
19+
)
20+
.min(1),
21+
response_url: z.string().url().optional(),
22+
user: z
23+
.object({
24+
id: z.string(),
25+
username: z.string(),
26+
})
27+
.optional(),
28+
});

src/common/typedPubsub.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,10 @@ export type PubSubSchema = {
191191
payload: ChangeObject<ReputationEvent>;
192192
};
193193
'api.v1.candidate-accepted-opportunity': CandidateAcceptedOpportunityMessage;
194+
'api.v1.candidate-review-opportunity': {
195+
opportunityId: string;
196+
userId: string;
197+
};
194198
'api.v1.opportunity-added': OpportunityMessage;
195199
'api.v1.opportunity-updated': OpportunityMessage;
196200
'api.v1.opportunity-in-review': {

src/common/utils.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,16 @@ export const textToSlug = (text: string): string =>
321321
replacement: '-',
322322
}).substring(0, 100);
323323

324+
export const truncateText = (
325+
text: string | null | undefined,
326+
maxLength = 500,
327+
): string | null =>
328+
text
329+
? text.length > maxLength
330+
? `${text.slice(0, maxLength - 3)}...`
331+
: text
332+
: null;
333+
324334
export const updateRecruiterSubscriptionFlags = <
325335
Entity extends {
326336
recruiterSubscriptionFlags: object;

src/entity/opportunities/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ export enum OpportunityUserType {
44

55
export enum OpportunityMatchStatus {
66
Pending = 'pending',
7+
CandidateReview = 'candidate_review',
78
CandidateAccepted = 'candidate_accepted',
89
CandidateRejected = 'candidate_rejected',
910
CandidateTimeOut = 'candidate_time_out',

0 commit comments

Comments
 (0)