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
2 changes: 2 additions & 0 deletions .infra/Pulumi.prod.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,8 @@ config:
secure: AAABANvfb9iJDOmbRPpMpkPzBLpeKTHsLCsFkNfU38yn53ZKUmeP0PA34uVGPZp+y4luHmEFXRE/jy/4rFwr9ZoVtufE59aa7J5AtVJcYY1VlumIY6P5olg=
brokkrOrigin:
secure: AAABABvk5Zi2sL2p8Gjl4vwVS+Ge+P2S3Jo8yDBOTNMZ6FZ1WquX+DqQkvE1YBTCvSMy9kmd+TCsRzeV3LrTC0Xbnlh7PxLog7fseJS37GUJ
slackRecruiterWebhook:
secure: AAABANouDkXZrXKlBhPnSmgjwQS/mb+qPaFmD/vpxeEkdCx4pfnsjYOsdOQFvsH6KcXb7BNGnnEtpEBUaeb3CNNMUbc2/JP80iNsnJSrF4hx5zBEYEPlMi0rmTXAJ4nc087FooqlV031idBJLxyNLDU=
api:k8s:
host: subs.daily.dev
namespace: daily
Expand Down
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-preference-updated',
subscription: 'api.extract-cv-markdown',
},
{
topic: 'api.v1.candidate-accepted-opportunity',
subscription: 'api.candidate-accepted-opportunity-slack',
},
];

export const personalizedDigestWorkers: Worker[] = [
Expand Down
203 changes: 203 additions & 0 deletions __tests__/workers/candidateAcceptedOpportunitySlack.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
import { expectSuccessfulTypedBackground, saveFixtures } from '../helpers';
import worker from '../../src/workers/candidateAcceptedOpportunitySlack';
import { OpportunityMatch } from '../../src/entity/OpportunityMatch';
import { User } from '../../src/entity';
import { DataSource } from 'typeorm';
import createOrGetConnection from '../../src/db';
import { webhooks } from '../../src/common/slack';
import { OpportunityMatchStatus } from '../../src/entity/opportunities/types';
import { CandidateAcceptedOpportunityMessage } from '@dailydotdev/schema';
import { OpportunityJob } from '../../src/entity/opportunities/OpportunityJob';
import { Organization } from '../../src/entity/Organization';
import {
organizationsFixture,
opportunitiesFixture,
} from '../fixture/opportunity';
import { usersFixture } from '../fixture/user';

// 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, Organization, organizationsFixture);
await saveFixtures(con, OpportunityJob, opportunitiesFixture);
await saveFixtures(con, User, usersFixture);
});

describe('candidateAcceptedOpportunitySlack worker', () => {
it('should send a slack notification when a candidate accepts an opportunity', async () => {
const match = {
opportunityId: '550e8400-e29b-41d4-a716-446655440001',
userId: '1',
status: OpportunityMatchStatus.CandidateAccepted,
description: { reasoning: 'Great fit for the role' },
createdAt: new Date('2023-01-05'),
updatedAt: new Date('2023-01-05'),
};

await saveFixtures(con, OpportunityMatch, [match]);

const eventData = new CandidateAcceptedOpportunityMessage({
opportunityId: match.opportunityId,
userId: match.userId,
createdAt: BigInt(Math.floor(match.createdAt.getTime() / 1000)),
updatedAt: BigInt(Math.floor(match.updatedAt.getTime() / 1000)),
});

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

expect(mockRecruiterSend).toHaveBeenCalledWith({
text: 'Candidate accepted opportunity!',
attachments: [
{
title: 'Senior Full Stack Developer',
title_link: `${process.env.COMMENTS_PREFIX}/opportunities/550e8400-e29b-41d4-a716-446655440001`,
fields: [
{
title: 'User',
value: 'idoshamun',
},
{
title: 'User ID',
value: '1',
},
{
title: 'Opportunity ID',
value: '550e8400-e29b-41d4-a716-446655440001',
},
],
color: '#1DDC6F',
},
],
});
});

it('should handle when user has no username', async () => {
const match = {
opportunityId: '550e8400-e29b-41d4-a716-446655440001',
userId: '2',
status: OpportunityMatchStatus.CandidateAccepted,
description: { reasoning: 'Great fit for the role' },
createdAt: new Date('2023-01-05'),
updatedAt: new Date('2023-01-05'),
};

await saveFixtures(con, OpportunityMatch, [match]);

const eventData = new CandidateAcceptedOpportunityMessage({
opportunityId: match.opportunityId,
userId: match.userId,
createdAt: BigInt(Math.floor(match.createdAt.getTime() / 1000)),
updatedAt: BigInt(Math.floor(match.updatedAt.getTime() / 1000)),
});

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

expect(mockRecruiterSend).toHaveBeenCalledWith({
text: 'Candidate accepted opportunity!',
attachments: [
{
title: 'Senior Full Stack Developer',
title_link: `${process.env.COMMENTS_PREFIX}/opportunities/550e8400-e29b-41d4-a716-446655440001`,
fields: [
{
title: 'User',
value: 'tsahidaily',
},
{
title: 'User ID',
value: '2',
},
{
title: 'Opportunity ID',
value: '550e8400-e29b-41d4-a716-446655440001',
},
],
color: '#1DDC6F',
},
],
});
});

it('should not send notification when match is not found', async () => {
const eventData = new CandidateAcceptedOpportunityMessage({
opportunityId: '550e8400-e29b-41d4-a716-446655440999',
userId: '1',
createdAt: BigInt(Math.floor(Date.now() / 1000)),
updatedAt: BigInt(Math.floor(Date.now() / 1000)),
});

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

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

it('should handle different opportunities', async () => {
const match = {
opportunityId: '550e8400-e29b-41d4-a716-446655440002',
userId: '3',
status: OpportunityMatchStatus.CandidateAccepted,
description: { reasoning: 'Excellent candidate' },
createdAt: new Date('2023-01-06'),
updatedAt: new Date('2023-01-06'),
};

await saveFixtures(con, OpportunityMatch, [match]);

const eventData = new CandidateAcceptedOpportunityMessage({
opportunityId: match.opportunityId,
userId: match.userId,
createdAt: BigInt(Math.floor(match.createdAt.getTime() / 1000)),
updatedAt: BigInt(Math.floor(match.updatedAt.getTime() / 1000)),
});

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

expect(mockRecruiterSend).toHaveBeenCalledWith({
text: 'Candidate accepted opportunity!',
attachments: [
{
title: 'Frontend Developer',
title_link: `${process.env.COMMENTS_PREFIX}/opportunities/550e8400-e29b-41d4-a716-446655440002`,
fields: [
{
title: 'User',
value: 'nimroddaily',
},
{
title: 'User ID',
value: '3',
},
{
title: 'Opportunity ID',
value: '550e8400-e29b-41d4-a716-446655440002',
},
],
color: '#1DDC6F',
},
],
});
});
});
3 changes: 3 additions & 0 deletions src/common/slack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ export const webhooks = Object.freeze({
ads: process.env.SLACK_ADS_WEBHOOK
? new IncomingWebhook(process.env.SLACK_ADS_WEBHOOK)
: nullWebhook,
recruiter: process.env.SLACK_RECRUITER
? new IncomingWebhook(process.env.SLACK_RECRUITER)
: nullWebhook,
});

interface NotifyBoostedProps {
Expand Down
68 changes: 68 additions & 0 deletions src/workers/candidateAcceptedOpportunitySlack.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { TypedWorker } from './worker';
import { OpportunityMatch } from '../entity/OpportunityMatch';
import { webhooks } from '../common';
import { CandidateAcceptedOpportunityMessage } from '@dailydotdev/schema';

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

const { opportunityId, userId } = data;

try {
// Fetch the match details to get additional context
const match = await con.getRepository(OpportunityMatch).findOne({
where: { opportunityId, userId },
relations: ['opportunity', 'user'],
});

if (!match) {
logger.warn(
{ opportunityId, userId },
'Match not found for candidate accepted opportunity',
);
return;
}

const opportunity = await match.opportunity;
const user = await match.user;

await webhooks.recruiter.send({
text: 'Candidate accepted opportunity!',
attachments: [
{
title: opportunity?.title || `Opportunity: ${opportunityId}`,
title_link: `${process.env.COMMENTS_PREFIX}/opportunities/${opportunityId}`,
fields: [
{
title: 'User',
value: user?.username || userId,
},
{
title: 'User ID',
value: userId,
},
{
title: 'Opportunity ID',
value: opportunityId,
},
],
color: '#1DDC6F',
},
],
});
} catch (err) {
logger.error(
{ data, err },
'failed to send candidate accepted opportunity slack message',
);
}
},
parseMessage: (message) =>
CandidateAcceptedOpportunityMessage.fromBinary(message.data),
};

export default worker;
2 changes: 2 additions & 0 deletions src/workers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ import { postAuthorCoresEarned } from './postAnalytics/postAuthorCoresEarned';
import { storeCandidateOpportunityMatch } from './opportunity/storeCandidateOpportunityMatch';
import { storeCandidateApplicationScore } from './opportunity/storeCandidateApplicationScore';
import { extractCVMarkdown } from './extractCVMarkdown';
import candidateAcceptedOpportunitySlack from './candidateAcceptedOpportunitySlack';

export { Worker } from './worker';

Expand Down Expand Up @@ -145,6 +146,7 @@ export const typedWorkers: BaseTypedWorker<any>[] = [
storeCandidateOpportunityMatch,
storeCandidateApplicationScore,
extractCVMarkdown,
candidateAcceptedOpportunitySlack,
];

export const personalizedDigestWorkers: Worker[] = [
Expand Down
Loading