Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .infra/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -433,6 +433,10 @@ export const workers: Worker[] = [
topic: 'api.v1.candidate-accepted-opportunity',
subscription: 'api.recruiter-new-candidate-notification',
},
{
topic: 'api.v1.candidate-review-opportunity',
subscription: 'api.candidate-review-opportunity-slack',
},
{
topic: 'api.v1.opportunity-went-live',
subscription: 'api.recruiter-opportunity-live-notification',
Expand Down
14 changes: 14 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,20 @@ The migration generator compares entities against the local database schema. Ens
- `.infra/common.ts` - Worker subscription definitions
- `.infra/index.ts` - Main Pulumi deployment configuration

## Code Style Preferences

**Keep implementations concise:**
- Prefer short, readable implementations over verbose ones
- Avoid excessive logging - errors will propagate naturally
- Use early returns instead of nested conditionals
- Extract repeated patterns into small inline helpers (e.g., `const respond = (text) => ...`)
- Combine related checks (e.g., `if (!match || match.status !== X)` instead of separate blocks)

**PubSub topics should be general-purpose:**
- Topics should contain only essential identifiers (e.g., `{ opportunityId, userId }`)
- Subscribers fetch their own data - don't optimize topic payloads for specific consumers
- This allows multiple subscribers with different data needs

## Best Practices & Lessons Learned

**Avoiding Code Duplication:**
Expand Down
155 changes: 153 additions & 2 deletions __tests__/integrations/slack.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import appFunc from '../../src';
import { FastifyInstance } from 'fastify';
import { authorizeRequest, saveFixtures } from '../helpers';
import { User } from '../../src/entity';
import { Organization, User } from '../../src/entity';
import { usersFixture } from '../fixture';
import { DataSource } from 'typeorm';
import createOrGetConnection from '../../src/db';
Expand All @@ -11,11 +11,20 @@ import {
UserIntegration,
UserIntegrationType,
} from '../../src/entity/UserIntegration';
import { SlackEvent } from '../../src/common';
import { SlackEvent, verifySlackSignature } from '../../src/common';
import {
AnalyticsEventName,
sendAnalyticsEvent,
} from '../../src/integrations/analytics';
import { DatasetLocation } from '../../src/entity/dataset/DatasetLocation';
import { OpportunityJob } from '../../src/entity/opportunities/OpportunityJob';
import {
datasetLocationsFixture,
opportunitiesFixture,
organizationsFixture,
} from '../fixture/opportunity';
import { OpportunityMatchStatus } from '../../src/entity/opportunities/types';
import { OpportunityMatch } from '../../src/entity/OpportunityMatch';

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

const actualVerifySlackSignature =
jest.requireActual<typeof import('../../src/common')>(
'../../src/common',
).verifySlackSignature;

jest.mock('../../src/common', () => ({
...(jest.requireActual('../../src/common') as Record<string, unknown>),
verifySlackSignature: jest.fn(),
}));

const mockVerifySlackSignature = verifySlackSignature as jest.MockedFunction<
typeof verifySlackSignature
>;

let app: FastifyInstance;
let con: DataSource;

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

Expand Down Expand Up @@ -482,3 +507,129 @@ describe('POST /integrations/slack/events', () => {
expect(await teamIntegrationsQuery.getCount()).toBe(1);
});
});

describe('POST /integrations/slack/interactions', () => {
const createInteractionPayload = (
actionId: string,
opportunityId: string,
userId: string,
) =>
`payload=${encodeURIComponent(
JSON.stringify({
type: 'block_actions',
actions: [
{
action_id: actionId,
value: JSON.stringify({ opportunityId, userId }),
},
],
response_url: 'https://hooks.slack.com/actions/test',
user: { id: 'U123', username: 'testuser' },
}),
)}`;

beforeEach(async () => {
await saveFixtures(con, DatasetLocation, datasetLocationsFixture);
await saveFixtures(con, Organization, organizationsFixture);
await saveFixtures(con, OpportunityJob, opportunitiesFixture);
mockVerifySlackSignature.mockReturnValue(true);
});

it('should return 403 when signature is invalid', async () => {
mockVerifySlackSignature.mockReturnValue(false);

const { body } = await request(app.server)
.post('/integrations/slack/interactions')
.set('Content-Type', 'application/x-www-form-urlencoded')
.send(createInteractionPayload('candidate_review_accept', 'opp1', 'u1'))
.expect(403);

expect(body).toEqual({ error: 'invalid signature' });
});

it('should accept candidate and update match status', async () => {
const match = {
opportunityId: '550e8400-e29b-41d4-a716-446655440001',
userId: '1',
status: OpportunityMatchStatus.CandidateReview,
createdAt: new Date(),
updatedAt: new Date(),
};
await saveFixtures(con, OpportunityMatch, [match]);

nock('https://hooks.slack.com').post('/actions/test').reply(200);

await request(app.server)
.post('/integrations/slack/interactions')
.set('Content-Type', 'application/x-www-form-urlencoded')
.set('x-slack-request-timestamp', '1722461509')
.set(
'x-slack-signature',
'v0=test', // Signature validation is mocked in test env
)
.send(
createInteractionPayload(
'candidate_review_accept',
match.opportunityId,
match.userId,
),
)
.expect(200);

const updatedMatch = await con.getRepository(OpportunityMatch).findOneBy({
opportunityId: match.opportunityId,
userId: match.userId,
});
expect(updatedMatch?.status).toBe(OpportunityMatchStatus.CandidateAccepted);
});

it('should reject candidate and update match status', async () => {
const match = {
opportunityId: '550e8400-e29b-41d4-a716-446655440001',
userId: '1',
status: OpportunityMatchStatus.CandidateReview,
createdAt: new Date(),
updatedAt: new Date(),
};
await saveFixtures(con, OpportunityMatch, [match]);

nock('https://hooks.slack.com').post('/actions/test').reply(200);

await request(app.server)
.post('/integrations/slack/interactions')
.set('Content-Type', 'application/x-www-form-urlencoded')
.set('x-slack-request-timestamp', '1722461509')
.set('x-slack-signature', 'v0=test')
.send(
createInteractionPayload(
'candidate_review_reject',
match.opportunityId,
match.userId,
),
)
.expect(200);

const updatedMatch = await con.getRepository(OpportunityMatch).findOneBy({
opportunityId: match.opportunityId,
userId: match.userId,
});
expect(updatedMatch?.status).toBe(OpportunityMatchStatus.RecruiterRejected);
});

it('should return 200 for unknown action types', async () => {
await request(app.server)
.post('/integrations/slack/interactions')
.set('Content-Type', 'application/x-www-form-urlencoded')
.set('x-slack-request-timestamp', '1722461509')
.set('x-slack-signature', 'v0=test')
.send(
`payload=${encodeURIComponent(
JSON.stringify({
type: 'block_actions',
actions: [{ action_id: 'unknown_action', value: '{}' }],
}),
)}`,
)
.expect(200);
});
});
4 changes: 2 additions & 2 deletions __tests__/schema/opportunity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2477,7 +2477,7 @@ describe('mutation acceptOpportunityMatch', () => {
await con.getRepository(OpportunityMatch).countBy({
opportunityId: '550e8400-e29b-41d4-a716-446655440001',
userId: '1',
status: OpportunityMatchStatus.CandidateAccepted,
status: OpportunityMatchStatus.CandidateReview,
}),
).toEqual(1);
});
Expand Down Expand Up @@ -2553,7 +2553,7 @@ describe('mutation rejectOpportunityMatch', () => {
);
});

it('should accept opportunity match for authenticated user', async () => {
it('should reject opportunity match for authenticated user', async () => {
Comment thread
rebelchris marked this conversation as resolved.
loggedUser = '1';

expect(
Expand Down
55 changes: 55 additions & 0 deletions __tests__/workers/candidateReviewOpportunitySlack.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { expectSuccessfulTypedBackground, saveFixtures } from '../helpers';
import worker from '../../src/workers/candidateReviewOpportunitySlack';
import { OpportunityMatch } from '../../src/entity/OpportunityMatch';
import { User } from '../../src/entity';
import createOrGetConnection from '../../src/db';
import { webhooks } from '../../src/common/slack';
import { OpportunityMatchStatus } from '../../src/entity/opportunities/types';
import { OpportunityJob } from '../../src/entity/opportunities/OpportunityJob';
import { Organization } from '../../src/entity/Organization';
import {
organizationsFixture,
opportunitiesFixture,
datasetLocationsFixture,
} from '../fixture/opportunity';
import { usersFixture } from '../fixture/user';
import { DatasetLocation } from '../../src/entity/dataset/DatasetLocation';

jest.spyOn(webhooks.recruiter, 'send').mockResolvedValue(undefined);

beforeEach(async () => {
jest.clearAllMocks();
const con = await createOrGetConnection();
await saveFixtures(con, DatasetLocation, datasetLocationsFixture);
await saveFixtures(con, Organization, organizationsFixture);
await saveFixtures(con, OpportunityJob, opportunitiesFixture);
await saveFixtures(con, User, usersFixture);
});

describe('candidateReviewOpportunitySlack worker', () => {
it('should send slack notification with accept/reject buttons', async () => {
const con = await createOrGetConnection();
await saveFixtures(con, OpportunityMatch, [
{
opportunityId: '550e8400-e29b-41d4-a716-446655440001',
userId: '1',
status: OpportunityMatchStatus.CandidateReview,
screening: [{ screening: 'Favorite language?', answer: 'TypeScript' }],
applicationRank: { score: 85 },
},
]);

await expectSuccessfulTypedBackground(worker, {
opportunityId: '550e8400-e29b-41d4-a716-446655440001',
userId: '1',
});

expect(webhooks.recruiter.send).toHaveBeenCalledTimes(1);
const blocks = (webhooks.recruiter.send as jest.Mock).mock.calls[0][0]
.blocks;
const actions = blocks.find((b: { type: string }) => b.type === 'actions');
expect(actions.elements).toHaveLength(2);
expect(actions.elements[0].action_id).toBe('candidate_review_accept');
expect(actions.elements[1].action_id).toBe('candidate_review_reject');
});
});
14 changes: 14 additions & 0 deletions src/common/opportunity/pubsub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -551,3 +551,17 @@ export const notifyCandidatePreferenceChange = async ({
);
}
};

export const notifyOpportunityMatchCandidateReview = async ({
logger,
data,
}: {
con: DataSource;
logger: FastifyBaseLogger;
data: ChangeObject<OpportunityMatch>;
}) => {
await triggerTypedEvent(logger, 'api.v1.candidate-review-opportunity', {
opportunityId: data.opportunityId,
userId: data.userId,
});
};
28 changes: 28 additions & 0 deletions src/common/schema/slack.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { z } from 'zod';

export const slackOpportunityActionValueSchema = z.object({
opportunityId: z.string(),
userId: z.string(),
});

export const slackOpportunityCandidateReviewPayloadSchema = z.object({
type: z.literal('block_actions'),
actions: z
.array(
z.object({
action_id: z.enum([
'candidate_review_accept',
'candidate_review_reject',
]),
value: z.string(),
}),
)
.min(1),
response_url: z.string().url().optional(),
user: z
.object({
id: z.string(),
username: z.string(),
})
.optional(),
});
4 changes: 4 additions & 0 deletions src/common/typedPubsub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,10 @@ export type PubSubSchema = {
payload: ChangeObject<ReputationEvent>;
};
'api.v1.candidate-accepted-opportunity': CandidateAcceptedOpportunityMessage;
'api.v1.candidate-review-opportunity': {
opportunityId: string;
userId: string;
};
'api.v1.opportunity-added': OpportunityMessage;
'api.v1.opportunity-updated': OpportunityMessage;
'api.v1.opportunity-in-review': {
Expand Down
10 changes: 10 additions & 0 deletions src/common/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,16 @@ export const textToSlug = (text: string): string =>
replacement: '-',
}).substring(0, 100);

export const truncateText = (
text: string | null | undefined,
maxLength = 500,
): string | null =>
text
? text.length > maxLength
? `${text.slice(0, maxLength - 3)}...`
: text
: null;

export const updateRecruiterSubscriptionFlags = <
Entity extends {
recruiterSubscriptionFlags: object;
Expand Down
1 change: 1 addition & 0 deletions src/entity/opportunities/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export enum OpportunityUserType {

export enum OpportunityMatchStatus {
Pending = 'pending',
CandidateReview = 'candidate_review',
CandidateAccepted = 'candidate_accepted',
CandidateRejected = 'candidate_rejected',
CandidateTimeOut = 'candidate_time_out',
Expand Down
Loading
Loading