Skip to content

Commit a4790a0

Browse files
authored
feat: add email for recruiter rejected candidate match (#3273)
1 parent 8925dca commit a4790a0

8 files changed

Lines changed: 160 additions & 1 deletion

File tree

.infra/common.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -433,6 +433,10 @@ export const workers: Worker[] = [
433433
topic: 'api.v1.candidate-accepted-opportunity',
434434
subscription: 'api.candidate-accepted-opportunity-slack',
435435
},
436+
{
437+
topic: 'api.v1.recruiter-rejected-candidate-match',
438+
subscription: 'api.recruiter-rejected-candidate-match-email',
439+
},
436440
];
437441

438442
export const personalizedDigestWorkers: Worker[] = [

__tests__/workers/cdc/primary.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6164,6 +6164,53 @@ describe('opportunity match', () => {
61646164
expect(triggerTypedEvent).toHaveBeenCalledTimes(0);
61656165
});
61666166
});
6167+
6168+
describe('recruiter rejected', () => {
6169+
it('should notify on recruiter rejected candidate match', async () => {
6170+
const after: ChangeObject<ObjectType> = {
6171+
...base,
6172+
status: OpportunityMatchStatus.RecruiterRejected,
6173+
};
6174+
await expectSuccessfulBackground(
6175+
worker,
6176+
mockChangeMessage<ObjectType>({
6177+
after,
6178+
before: base,
6179+
op: 'u',
6180+
table: 'opportunity_match',
6181+
}),
6182+
);
6183+
expect(triggerTypedEvent).toHaveBeenCalledTimes(1);
6184+
expect(triggerTypedEvent).toHaveBeenCalledWith(
6185+
expect.any(Object),
6186+
'api.v1.recruiter-rejected-candidate-match',
6187+
expect.objectContaining({
6188+
opportunityId: opportunitiesFixture[0].id,
6189+
userId: '1',
6190+
}),
6191+
);
6192+
});
6193+
6194+
it('should not notify when recruiter rejected status stays the same', async () => {
6195+
const after: ChangeObject<ObjectType> = {
6196+
...base,
6197+
status: OpportunityMatchStatus.RecruiterRejected,
6198+
};
6199+
await expectSuccessfulBackground(
6200+
worker,
6201+
mockChangeMessage<ObjectType>({
6202+
after,
6203+
before: {
6204+
...base,
6205+
status: OpportunityMatchStatus.RecruiterRejected,
6206+
},
6207+
op: 'u',
6208+
table: 'opportunity_match',
6209+
}),
6210+
);
6211+
expect(triggerTypedEvent).toHaveBeenCalledTimes(0);
6212+
});
6213+
});
61676214
});
61686215

61696216
describe('opportunity', () => {

__tests__/workers/newNotificationV2Mail.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2813,7 +2813,7 @@ describe('warm_intro notification', () => {
28132813
.calls[0][0] as SendEmailRequestWithTemplate;
28142814

28152815
expect(args.message_data).toEqual({
2816-
title: `It's a match!`,
2816+
title: `[Action Required] It's a match!`,
28172817
copy: '<p>Great match based on your experience!</p>',
28182818
cc: 'recruiter@test.com',
28192819
});

src/common/opportunity/pubsub.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,27 @@ export const notifyRecruiterCandidateMatchAccepted = async ({
233233
}
234234
};
235235

236+
export const notifyRecruiterCandidateMatchRejected = async ({
237+
logger,
238+
data,
239+
}: {
240+
logger: FastifyBaseLogger;
241+
data: ChangeObject<OpportunityMatch>;
242+
}) => {
243+
const message = new CandidateRejectedOpportunityMessage({
244+
opportunityId: data.opportunityId,
245+
userId: data.userId,
246+
createdAt: getSecondsTimestamp(data.createdAt),
247+
updatedAt: getSecondsTimestamp(data.updatedAt),
248+
});
249+
250+
await triggerTypedEvent(
251+
logger,
252+
'api.v1.recruiter-rejected-candidate-match',
253+
message,
254+
);
255+
};
256+
236257
export const notifyCandidateOpportunityMatchRejected = async ({
237258
con,
238259
logger,

src/common/typedPubsub.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,7 @@ export type PubSubSchema = {
227227
};
228228
'api.v1.recruiter-accepted-candidate-match': RecruiterAcceptedCandidateMatchMessage;
229229
'api.v1.candidate-rejected-opportunity': CandidateRejectedOpportunityMessage;
230+
'api.v1.recruiter-rejected-candidate-match': CandidateRejectedOpportunityMessage;
230231
'gondul.v1.candidate-application-scored': ApplicationScored;
231232
'gondul.v1.warm-intro-generated': WarmIntro;
232233
};

src/workers/cdc/primary.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,7 @@ import {
154154
notifyCandidatePreferenceChange,
155155
notifyOpportunityMatchAccepted,
156156
notifyRecruiterCandidateMatchAccepted,
157+
notifyRecruiterCandidateMatchRejected,
157158
} from '../../common/opportunity/pubsub';
158159
import { Opportunity } from '../../entity/opportunities/Opportunity';
159160
import { notifyJobOpportunity } from '../../common/opportunity/pubsub';
@@ -1329,6 +1330,15 @@ const onOpportunityMatchChange = async (
13291330
data: data.payload.after!,
13301331
});
13311332
}
1333+
if (
1334+
data.payload.after?.status === OpportunityMatchStatus.RecruiterRejected &&
1335+
data?.payload.before?.status !== OpportunityMatchStatus.RecruiterRejected
1336+
) {
1337+
await notifyRecruiterCandidateMatchRejected({
1338+
logger,
1339+
data: data.payload.after!,
1340+
});
1341+
}
13321342
}
13331343
};
13341344

src/workers/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ import { storeCandidateOpportunityMatch } from './opportunity/storeCandidateOppo
7272
import { storeCandidateApplicationScore } from './opportunity/storeCandidateApplicationScore';
7373
import { extractCVMarkdown } from './extractCVMarkdown';
7474
import candidateAcceptedOpportunitySlack from './candidateAcceptedOpportunitySlack';
75+
import recruiterRejectedCandidateMatchEmail from './recruiterRejectedCandidateMatchEmail';
7576

7677
export { Worker } from './worker';
7778

@@ -147,6 +148,7 @@ export const typedWorkers: BaseTypedWorker<any>[] = [
147148
storeCandidateApplicationScore,
148149
extractCVMarkdown,
149150
candidateAcceptedOpportunitySlack,
151+
recruiterRejectedCandidateMatchEmail,
150152
];
151153

152154
export const personalizedDigestWorkers: Worker[] = [
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { TypedWorker } from './worker';
2+
import { CandidateRejectedOpportunityMessage } from '@dailydotdev/schema';
3+
import { User } from '../entity';
4+
import { sendEmail, baseNotificationEmailData } from '../common';
5+
import { isSubscribedToNotificationType } from './notifications/utils';
6+
import { NotificationChannel, NotificationType } from '../notifications/common';
7+
8+
const worker: TypedWorker<'api.v1.recruiter-rejected-candidate-match'> = {
9+
subscription: 'api.recruiter-rejected-candidate-match-email',
10+
handler: async ({ data }, con, logger): Promise<void> => {
11+
const { userId, opportunityId } = data;
12+
13+
try {
14+
const user = await con.getRepository(User).findOne({
15+
where: { id: userId },
16+
select: ['id', 'email', 'notificationFlags'],
17+
});
18+
19+
if (!user) {
20+
logger.warn(
21+
{ userId, opportunityId },
22+
'User not found for recruiter rejected candidate email',
23+
);
24+
return;
25+
}
26+
27+
if (!user.email) {
28+
logger.warn(
29+
{ userId, opportunityId },
30+
'User has no email for recruiter rejected candidate email',
31+
);
32+
return;
33+
}
34+
35+
const shouldReceiveEmail = isSubscribedToNotificationType(
36+
user.notificationFlags,
37+
NotificationType.NewOpportunityMatch,
38+
NotificationChannel.Email,
39+
);
40+
41+
if (!shouldReceiveEmail) {
42+
logger.info(
43+
{ userId, opportunityId },
44+
'User is not subscribed to recruiter rejected opportunity emails',
45+
);
46+
return;
47+
}
48+
49+
await sendEmail({
50+
...baseNotificationEmailData,
51+
reply_to: 'ido@daily.dev',
52+
transactional_message_id: '85',
53+
message_data: {
54+
opportunity_id: opportunityId,
55+
},
56+
identifiers: {
57+
id: user.id,
58+
},
59+
to: user.email,
60+
});
61+
} catch (_err) {
62+
const err = _err as Error;
63+
logger.error(
64+
{ err, userId, opportunityId },
65+
'failed to send recruiter rejected candidate email',
66+
);
67+
throw err;
68+
}
69+
},
70+
parseMessage: (message) =>
71+
CandidateRejectedOpportunityMessage.fromBinary(message.data),
72+
};
73+
74+
export default worker;

0 commit comments

Comments
 (0)