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 @@ -429,6 +429,10 @@ export const workers: Worker[] = [
topic: 'api.v1.candidate-accepted-opportunity',
subscription: 'api.candidate-accepted-opportunity-slack',
},
{
topic: 'api.v1.opportunity-in-review',
subscription: 'api.opportunity-in-review-slack',
},
{
topic: 'api.v1.recruiter-rejected-candidate-match',
subscription: 'api.recruiter-rejected-candidate-match-email',
Expand Down
80 changes: 80 additions & 0 deletions __tests__/workers/cdc/primary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6665,6 +6665,86 @@ describe('opportunity', () => {
.countBy({ opportunityId: '550e8400-e29b-41d4-a716-446655440001' }),
).toEqual(0);
});

it('should trigger event when opportunity moves to IN_REVIEW state', async () => {
await expectSuccessfulBackground(
worker,
mockChangeMessage<OpportunityJob>({
before: {
id: '550e8400-e29b-41d4-a716-446655440001',
createdAt: new Date().getTime(),
updatedAt: new Date().getTime(),
type: OpportunityType.JOB,
title: 'Senior Backend Engineer',
tldr: 'We are looking for a Senior Backend Engineer...',
content: [],
meta: {},
state: OpportunityState.DRAFT,
organizationId: organizationsFixture[0].id,
},
after: {
id: '550e8400-e29b-41d4-a716-446655440001',
createdAt: new Date().getTime(),
updatedAt: new Date().getTime(),
type: OpportunityType.JOB,
title: 'Senior Backend Engineer',
tldr: 'We are looking for a Senior Backend Engineer...',
content: [],
meta: {},
state: OpportunityState.IN_REVIEW,
organizationId: organizationsFixture[0].id,
},
op: 'u',
table: 'opportunity',
}),
);

expect(triggerTypedEvent).toHaveBeenCalledTimes(1);
expect(jest.mocked(triggerTypedEvent).mock.calls[0].slice(1)).toEqual([
'api.v1.opportunity-in-review',
{
opportunityId: '550e8400-e29b-41d4-a716-446655440001',
organizationId: organizationsFixture[0].id,
title: 'Senior Backend Engineer',
},
]);
});

it('should not trigger IN_REVIEW event when state was already IN_REVIEW', async () => {
await expectSuccessfulBackground(
worker,
mockChangeMessage<OpportunityJob>({
before: {
id: '550e8400-e29b-41d4-a716-446655440001',
createdAt: new Date().getTime(),
updatedAt: new Date().getTime(),
type: OpportunityType.JOB,
title: 'Senior Backend Engineer',
tldr: 'We are looking for a Senior Backend Engineer...',
content: [],
meta: {},
state: OpportunityState.IN_REVIEW,
organizationId: organizationsFixture[0].id,
},
after: {
id: '550e8400-e29b-41d4-a716-446655440001',
createdAt: new Date().getTime(),
updatedAt: new Date().getTime(),
type: OpportunityType.JOB,
title: 'Updated Senior Backend Engineer',
tldr: 'We are looking for a Senior Backend Engineer...',
content: [],
meta: {},
state: OpportunityState.IN_REVIEW,
organizationId: organizationsFixture[0].id,
},
op: 'u',
table: 'opportunity',
}),
);

expect(triggerTypedEvent).toHaveBeenCalledTimes(0);
});
});

describe('user_candidate_preference', () => {
Expand Down
169 changes: 169 additions & 0 deletions __tests__/workers/opportunityInReviewSlack.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import { expectSuccessfulTypedBackground, saveFixtures } from '../helpers';
import worker from '../../src/workers/opportunityInReviewSlack';
import { DataSource } from 'typeorm';
import createOrGetConnection from '../../src/db';
import { webhooks } from '../../src/common/slack';
import { OpportunityJob } from '../../src/entity/opportunities/OpportunityJob';
import { Organization } from '../../src/entity/Organization';
import {
organizationsFixture,
opportunitiesFixture,
datasetLocationsFixture,
} from '../fixture/opportunity';
import { DatasetLocation } from '../../src/entity/dataset/DatasetLocation';

// Spy on the webhooks.recruiter.send method
const mockRecruiterSend = jest
.spyOn(webhooks.recruiter, 'send')
.mockResolvedValue(undefined);

let con: DataSource;

beforeAll(async () => {
jest.clearAllMocks();
con = await createOrGetConnection();
});

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

describe('opportunityInReviewSlack worker', () => {
it('should send a slack notification when an opportunity is submitted for review', async () => {
const eventData = {
opportunityId: '550e8400-e29b-41d4-a716-446655440001',
organizationId: '550e8400-e29b-41d4-a716-446655440000',
title: 'Senior Full Stack Developer',
};

await expectSuccessfulTypedBackground<'api.v1.opportunity-in-review'>(
worker,
eventData,
);

expect(mockRecruiterSend).toHaveBeenCalledWith({
text: 'New opportunity submitted for review!',
attachments: [
{
title: 'Senior Full Stack Developer',
title_link: `${process.env.COMMENTS_PREFIX}/jobs/550e8400-e29b-41d4-a716-446655440001`,
fields: [
{
title: 'Organization',
value: 'Daily Dev Inc',
},
{
title: 'Opportunity ID',
value: '550e8400-e29b-41d4-a716-446655440001',
},
],
color: '#FFB800',
},
],
});
});

it('should not send notification when organization is not found', async () => {
const eventData = {
opportunityId: '550e8400-e29b-41d4-a716-446655440001',
organizationId: 'non-existent-org-id',
title: 'Senior Full Stack Developer',
};

await expectSuccessfulTypedBackground<'api.v1.opportunity-in-review'>(
worker,
eventData,
);

expect(mockRecruiterSend).not.toHaveBeenCalled();
});

it('should handle different opportunities and organizations', async () => {
const eventData = {
opportunityId: '550e8400-e29b-41d4-a716-446655440002',
organizationId: 'ed487a47-6f4d-480f-9712-f48ab29db27c',
title: 'Frontend Developer',
};

await expectSuccessfulTypedBackground<'api.v1.opportunity-in-review'>(
worker,
eventData,
);

expect(mockRecruiterSend).toHaveBeenCalledWith({
text: 'New opportunity submitted for review!',
attachments: [
{
title: 'Frontend Developer',
title_link: `${process.env.COMMENTS_PREFIX}/jobs/550e8400-e29b-41d4-a716-446655440002`,
fields: [
{
title: 'Organization',
value: 'Yearly Dev Inc',
},
{
title: 'Opportunity ID',
value: '550e8400-e29b-41d4-a716-446655440002',
},
],
color: '#FFB800',
},
],
});
});

it('should include opportunity title from the event data', async () => {
const eventData = {
opportunityId: '550e8400-e29b-41d4-a716-446655440001',
organizationId: '550e8400-e29b-41d4-a716-446655440000',
title: 'Backend Engineer - Node.js',
};

await expectSuccessfulTypedBackground<'api.v1.opportunity-in-review'>(
worker,
eventData,
);

expect(mockRecruiterSend).toHaveBeenCalledWith({
text: 'New opportunity submitted for review!',
attachments: [
{
title: 'Backend Engineer - Node.js',
title_link: `${process.env.COMMENTS_PREFIX}/jobs/550e8400-e29b-41d4-a716-446655440001`,
fields: [
{
title: 'Organization',
value: 'Daily Dev Inc',
},
{
title: 'Opportunity ID',
value: '550e8400-e29b-41d4-a716-446655440001',
},
],
color: '#FFB800',
},
],
});
});

it('should handle webhook send failures gracefully', async () => {
mockRecruiterSend.mockRejectedValueOnce(new Error('Slack API error'));

const eventData = {
opportunityId: '550e8400-e29b-41d4-a716-446655440001',
organizationId: '550e8400-e29b-41d4-a716-446655440000',
title: 'Senior Full Stack Developer',
};

// Should not throw error
await expectSuccessfulTypedBackground<'api.v1.opportunity-in-review'>(
worker,
eventData,
);

expect(mockRecruiterSend).toHaveBeenCalled();
});
});
5 changes: 5 additions & 0 deletions src/common/typedPubsub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,11 @@ export type PubSubSchema = {
'api.v1.candidate-accepted-opportunity': CandidateAcceptedOpportunityMessage;
'api.v1.opportunity-added': OpportunityMessage;
'api.v1.opportunity-updated': OpportunityMessage;
'api.v1.opportunity-in-review': {
opportunityId: string;
organizationId: string;
title: string;
};
'gondul.v1.candidate-opportunity-match': MatchedCandidate;
'api.v1.candidate-preference-updated': CandidatePreferenceUpdated;
'api.v1.delayed-notification-reminder': z.infer<typeof entityReminderSchema>;
Expand Down
20 changes: 20 additions & 0 deletions src/workers/cdc/primary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ import {
notifyRecruiterCandidateMatchRejected,
} from '../../common/opportunity/pubsub';
import { Opportunity } from '../../entity/opportunities/Opportunity';
import { OpportunityJob } from '../../entity/opportunities/OpportunityJob';
import { notifyJobOpportunity } from '../../common/opportunity/pubsub';
import { UserCandidatePreference } from '../../entity/user/UserCandidatePreference';
import { PollPost } from '../../entity/posts/PollPost';
Expand Down Expand Up @@ -1383,6 +1384,25 @@ const onOpportunityChange = async (
);
}
}

// Trigger event when opportunity moves to IN_REVIEW
if (
data.payload.op === 'u' &&
data.payload.after?.type === OpportunityType.JOB &&
data.payload.after?.state === OpportunityState.IN_REVIEW &&
data.payload.before?.state !== OpportunityState.IN_REVIEW
) {
const opportunityData = data.payload.after as ChangeObject<OpportunityJob>;
const organizationId = opportunityData.organizationId;

if (organizationId) {
await triggerTypedEvent(logger, 'api.v1.opportunity-in-review', {
opportunityId: opportunityData.id,
organizationId,
title: opportunityData.title,
});
}
}
};

const onOrganizationChange = async (
Expand Down
2 changes: 2 additions & 0 deletions src/workers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ import { extractCVMarkdown } from './extractCVMarkdown';
import candidateAcceptedOpportunitySlack from './candidateAcceptedOpportunitySlack';
import recruiterRejectedCandidateMatchEmail from './recruiterRejectedCandidateMatchEmail';
import { opportunityPreviewResultWorker } from './opportunity/opportunityPreviewResult';
import opportunityInReviewSlack from './opportunityInReviewSlack';

export { Worker } from './worker';

Expand Down Expand Up @@ -151,6 +152,7 @@ export const typedWorkers: BaseTypedWorker<any>[] = [
candidateAcceptedOpportunitySlack,
recruiterRejectedCandidateMatchEmail,
opportunityPreviewResultWorker,
opportunityInReviewSlack,
];

export const personalizedDigestWorkers: Worker[] = [
Expand Down
57 changes: 57 additions & 0 deletions src/workers/opportunityInReviewSlack.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { TypedWorker } from './worker';
import { Organization } from '../entity/Organization';
import { webhooks } from '../common';

const worker: TypedWorker<'api.v1.opportunity-in-review'> = {
subscription: 'api.opportunity-in-review-slack',
handler: async ({ data }, con, logger): Promise<void> => {
if (process.env.NODE_ENV === 'development') {
return;
}

const { opportunityId, organizationId, title } = data;

try {
// Fetch organization name
const organization = await con
.getRepository(Organization)
.findOne({ where: { id: organizationId } });

if (!organization) {
logger.warn(
{ opportunityId, organizationId },
'Organization not found for opportunity in review',
);
return;
}

await webhooks.recruiter.send({
text: 'New opportunity submitted for review!',
attachments: [
{
title,
title_link: `${process.env.COMMENTS_PREFIX}/jobs/${opportunityId}`,
fields: [
{
title: 'Organization',
value: organization.name,
},
{
title: 'Opportunity ID',
value: opportunityId,
},
],
color: '#FFB800', // Yellow/orange for review state
},
],
});
} catch (err) {
logger.error(
{ data, err },
'failed to send opportunity in review slack message',
);
}
},
};

export default worker;
Loading