diff --git a/.infra/common.ts b/.infra/common.ts index 231fb38e8c..c60813acf6 100644 --- a/.infra/common.ts +++ b/.infra/common.ts @@ -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', diff --git a/__tests__/workers/cdc/primary.ts b/__tests__/workers/cdc/primary.ts index b3ecdd599f..f5b5341167 100644 --- a/__tests__/workers/cdc/primary.ts +++ b/__tests__/workers/cdc/primary.ts @@ -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({ + 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({ + 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', () => { diff --git a/__tests__/workers/opportunityInReviewSlack.ts b/__tests__/workers/opportunityInReviewSlack.ts new file mode 100644 index 0000000000..31e33864db --- /dev/null +++ b/__tests__/workers/opportunityInReviewSlack.ts @@ -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(); + }); +}); diff --git a/src/common/typedPubsub.ts b/src/common/typedPubsub.ts index 8de57a1b8f..10218b0eeb 100644 --- a/src/common/typedPubsub.ts +++ b/src/common/typedPubsub.ts @@ -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; diff --git a/src/workers/cdc/primary.ts b/src/workers/cdc/primary.ts index 4be7bf0c9c..751a954d40 100644 --- a/src/workers/cdc/primary.ts +++ b/src/workers/cdc/primary.ts @@ -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'; @@ -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; + const organizationId = opportunityData.organizationId; + + if (organizationId) { + await triggerTypedEvent(logger, 'api.v1.opportunity-in-review', { + opportunityId: opportunityData.id, + organizationId, + title: opportunityData.title, + }); + } + } }; const onOrganizationChange = async ( diff --git a/src/workers/index.ts b/src/workers/index.ts index 1061768a39..e970e9fd81 100644 --- a/src/workers/index.ts +++ b/src/workers/index.ts @@ -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'; @@ -151,6 +152,7 @@ export const typedWorkers: BaseTypedWorker[] = [ candidateAcceptedOpportunitySlack, recruiterRejectedCandidateMatchEmail, opportunityPreviewResultWorker, + opportunityInReviewSlack, ]; export const personalizedDigestWorkers: Worker[] = [ diff --git a/src/workers/opportunityInReviewSlack.ts b/src/workers/opportunityInReviewSlack.ts new file mode 100644 index 0000000000..6fbf31e35c --- /dev/null +++ b/src/workers/opportunityInReviewSlack.ts @@ -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 => { + 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;