Skip to content

Commit 50ec685

Browse files
authored
feat: recruiter facing notifications (#3378)
1 parent 67b24e7 commit 50ec685

15 files changed

Lines changed: 644 additions & 2 deletions

.infra/common.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -429,6 +429,14 @@ export const workers: Worker[] = [
429429
topic: 'api.v1.candidate-accepted-opportunity',
430430
subscription: 'api.candidate-accepted-opportunity-slack',
431431
},
432+
{
433+
topic: 'api.v1.candidate-accepted-opportunity',
434+
subscription: 'api.recruiter-new-candidate-notification',
435+
},
436+
{
437+
topic: 'api.v1.opportunity-went-live',
438+
subscription: 'api.recruiter-opportunity-live-notification',
439+
},
432440
{
433441
topic: 'api.v1.opportunity-in-review',
434442
subscription: 'api.opportunity-in-review-slack',

__tests__/workers/cdc/primary.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6570,10 +6570,13 @@ describe('opportunity', () => {
65706570
}),
65716571
);
65726572

6573-
expect(triggerTypedEvent).toHaveBeenCalledTimes(1);
6573+
expect(triggerTypedEvent).toHaveBeenCalledTimes(2);
65746574
expect(jest.mocked(triggerTypedEvent).mock.calls[0][1]).toEqual(
65756575
'api.v1.opportunity-added',
65766576
);
6577+
expect(jest.mocked(triggerTypedEvent).mock.calls[1][1]).toEqual(
6578+
'api.v1.opportunity-went-live',
6579+
);
65776580
});
65786581

65796582
it('should not trigger on updated opportunity when state is not live', async () => {

__tests__/workers/newNotificationV2Mail.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,10 @@ import { Opportunity } from '../../src/entity/opportunities/Opportunity';
9898
import { OpportunityMatch } from '../../src/entity/OpportunityMatch';
9999
import { OpportunityUserRecruiter } from '../../src/entity/opportunities/user';
100100
import { OpportunityUserType } from '../../src/entity/opportunities/types';
101+
import type {
102+
NotificationRecruiterNewCandidateContext,
103+
NotificationRecruiterOpportunityLiveContext,
104+
} from '../../src/notifications/types';
101105
import {
102106
datasetLocationsFixture,
103107
opportunitiesFixture,
@@ -2805,3 +2809,90 @@ describe('warm_intro notification', () => {
28052809
expect(args.transactional_message_id).toEqual('85');
28062810
});
28072811
});
2812+
2813+
describe('recruiter_new_candidate notification', () => {
2814+
it('should send email with matching tags between candidate and opportunity', async () => {
2815+
await saveFixtures(con, DatasetLocation, datasetLocationsFixture);
2816+
await saveFixtures(con, Organization, organizationsFixture);
2817+
await saveFixtures(con, Opportunity, opportunitiesFixture);
2818+
2819+
const candidate = await con.getRepository(User).findOneBy({ id: '1' });
2820+
2821+
// Create match with score
2822+
await con.getRepository(OpportunityMatch).save({
2823+
opportunityId: opportunitiesFixture[0].id,
2824+
userId: candidate!.id,
2825+
description: {
2826+
matchScore: 0.85,
2827+
reasoning: 'Great fit based on experience',
2828+
reasoningShort: 'Strong JS skills',
2829+
},
2830+
});
2831+
2832+
const ctx: NotificationRecruiterNewCandidateContext = {
2833+
userIds: ['2'],
2834+
opportunityId: opportunitiesFixture[0].id,
2835+
candidate: candidate!,
2836+
};
2837+
2838+
const notificationId = await saveNotificationV2Fixture(
2839+
con,
2840+
NotificationType.RecruiterNewCandidate,
2841+
ctx,
2842+
);
2843+
2844+
await expectSuccessfulBackground(worker, {
2845+
notification: {
2846+
id: notificationId,
2847+
userId: '2',
2848+
},
2849+
});
2850+
2851+
expect(sendEmail).toHaveBeenCalledTimes(1);
2852+
const args = jest.mocked(sendEmail).mock
2853+
.calls[0][0] as SendEmailRequestWithTemplate;
2854+
2855+
expect(args.message_data).toEqual({
2856+
candidate_name: 'Ido',
2857+
profile_picture: 'https://daily.dev/ido.jpg',
2858+
job_title: 'Senior Full Stack Developer',
2859+
score: '85%',
2860+
matching_content: 'Strong JS skills',
2861+
});
2862+
});
2863+
});
2864+
2865+
describe('recruiter_opportunity_live notification', () => {
2866+
it('should send email when opportunity goes live', async () => {
2867+
await saveFixtures(con, DatasetLocation, datasetLocationsFixture);
2868+
await saveFixtures(con, Organization, organizationsFixture);
2869+
await saveFixtures(con, Opportunity, opportunitiesFixture);
2870+
2871+
const ctx: NotificationRecruiterOpportunityLiveContext = {
2872+
userIds: ['2'],
2873+
opportunityId: opportunitiesFixture[0].id,
2874+
opportunityTitle: opportunitiesFixture[0].title,
2875+
};
2876+
2877+
const notificationId = await saveNotificationV2Fixture(
2878+
con,
2879+
NotificationType.RecruiterOpportunityLive,
2880+
ctx,
2881+
);
2882+
2883+
await expectSuccessfulBackground(worker, {
2884+
notification: {
2885+
id: notificationId,
2886+
userId: '2',
2887+
},
2888+
});
2889+
2890+
expect(sendEmail).toHaveBeenCalledTimes(1);
2891+
const args = jest.mocked(sendEmail).mock
2892+
.calls[0][0] as SendEmailRequestWithTemplate;
2893+
2894+
expect(args.message_data).toEqual({
2895+
opportunity_link: `http://localhost:5002/opportunity/${opportunitiesFixture[0].id}`,
2896+
});
2897+
});
2898+
});
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import { DataSource } from 'typeorm';
2+
import { recruiterNewCandidateNotification as worker } from '../../../src/workers/notifications/recruiterNewCandidateNotification';
3+
import createOrGetConnection from '../../../src/db';
4+
import { Organization, User } from '../../../src/entity';
5+
import { OpportunityJob } from '../../../src/entity/opportunities/OpportunityJob';
6+
import { OpportunityUser } from '../../../src/entity/opportunities/user';
7+
import { OpportunityUserType } from '../../../src/entity/opportunities/types';
8+
import { OpportunityType, OpportunityState } from '@dailydotdev/schema';
9+
import { usersFixture } from '../../fixture';
10+
import { workers } from '../../../src/workers';
11+
import { invokeTypedNotificationWorker, saveFixtures } from '../../helpers';
12+
import { NotificationType } from '../../../src/notifications/common';
13+
import type { NotificationRecruiterNewCandidateContext } from '../../../src/notifications';
14+
import { CandidateAcceptedOpportunityMessage } from '@dailydotdev/schema';
15+
16+
let con: DataSource;
17+
18+
describe('recruiterNewCandidateNotification worker', () => {
19+
beforeAll(async () => {
20+
con = await createOrGetConnection();
21+
});
22+
23+
beforeEach(async () => {
24+
jest.resetAllMocks();
25+
await saveFixtures(con, User, usersFixture);
26+
});
27+
28+
it('should be registered', () => {
29+
const registeredWorker = workers.find(
30+
(item) => item.subscription === worker.subscription,
31+
);
32+
33+
expect(registeredWorker).toBeDefined();
34+
});
35+
36+
it('should send notification to all recruiters when candidate accepts', async () => {
37+
const organization = await con.getRepository(Organization).save({
38+
id: 'org123',
39+
name: 'Test Organization',
40+
});
41+
42+
const opportunity = await con.getRepository(OpportunityJob).save({
43+
id: '123e4567-e89b-12d3-a456-426614174000',
44+
type: OpportunityType.JOB,
45+
state: OpportunityState.LIVE,
46+
title: 'Senior Software Engineer',
47+
tldr: 'Great opportunity',
48+
content: {},
49+
meta: {},
50+
organizationId: organization.id,
51+
location: [],
52+
});
53+
54+
const recruiter1 = await con.getRepository(User).save({
55+
id: 'recruiter1',
56+
name: 'John Recruiter',
57+
email: 'john@test.com',
58+
});
59+
60+
const recruiter2 = await con.getRepository(User).save({
61+
id: 'recruiter2',
62+
name: 'Jane Recruiter',
63+
email: 'jane@test.com',
64+
});
65+
66+
await con.getRepository(OpportunityUser).save([
67+
{
68+
opportunityId: opportunity.id,
69+
userId: recruiter1.id,
70+
type: OpportunityUserType.Recruiter,
71+
},
72+
{
73+
opportunityId: opportunity.id,
74+
userId: recruiter2.id,
75+
type: OpportunityUserType.Recruiter,
76+
},
77+
]);
78+
79+
const result =
80+
await invokeTypedNotificationWorker<'api.v1.candidate-accepted-opportunity'>(
81+
worker,
82+
new CandidateAcceptedOpportunityMessage({
83+
userId: '1',
84+
opportunityId: '123e4567-e89b-12d3-a456-426614174000',
85+
createdAt: Math.floor(Date.now() / 1000),
86+
updatedAt: Math.floor(Date.now() / 1000),
87+
}),
88+
);
89+
90+
expect(result!.length).toEqual(1);
91+
expect(result![0].type).toEqual(NotificationType.RecruiterNewCandidate);
92+
93+
const context = result![0].ctx as NotificationRecruiterNewCandidateContext;
94+
95+
expect(context.userIds).toHaveLength(2);
96+
expect(context.userIds).toContain('recruiter1');
97+
expect(context.userIds).toContain('recruiter2');
98+
expect(context.opportunityId).toEqual(
99+
'123e4567-e89b-12d3-a456-426614174000',
100+
);
101+
expect(context.candidate).toBeDefined();
102+
expect(context.candidate.id).toEqual('1');
103+
});
104+
105+
it('should return empty array when no recruiters found', async () => {
106+
const organization = await con.getRepository(Organization).save({
107+
id: 'org456',
108+
name: 'Another Organization',
109+
});
110+
111+
await con.getRepository(OpportunityJob).save({
112+
id: '123e4567-e89b-12d3-a456-426614174001',
113+
type: OpportunityType.JOB,
114+
state: OpportunityState.LIVE,
115+
title: 'Backend Developer',
116+
tldr: 'Backend role',
117+
content: {},
118+
meta: {},
119+
organizationId: organization.id,
120+
location: [],
121+
});
122+
123+
const result =
124+
await invokeTypedNotificationWorker<'api.v1.candidate-accepted-opportunity'>(
125+
worker,
126+
new CandidateAcceptedOpportunityMessage({
127+
userId: '1',
128+
opportunityId: '123e4567-e89b-12d3-a456-426614174001',
129+
createdAt: Math.floor(Date.now() / 1000),
130+
updatedAt: Math.floor(Date.now() / 1000),
131+
}),
132+
);
133+
134+
expect(result).toEqual([]);
135+
});
136+
});

0 commit comments

Comments
 (0)