diff --git a/.infra/common.ts b/.infra/common.ts index 13666e682b..d5d1b982a0 100644 --- a/.infra/common.ts +++ b/.infra/common.ts @@ -433,6 +433,10 @@ export const workers: Worker[] = [ topic: 'api.v1.candidate-accepted-opportunity', subscription: 'api.candidate-accepted-opportunity-slack', }, + { + topic: 'api.v1.recruiter-rejected-candidate-match', + subscription: 'api.recruiter-rejected-candidate-match-email', + }, ]; export const personalizedDigestWorkers: Worker[] = [ diff --git a/__tests__/workers/cdc/primary.ts b/__tests__/workers/cdc/primary.ts index cdae79afcb..6741b8c5c1 100644 --- a/__tests__/workers/cdc/primary.ts +++ b/__tests__/workers/cdc/primary.ts @@ -6164,6 +6164,53 @@ describe('opportunity match', () => { expect(triggerTypedEvent).toHaveBeenCalledTimes(0); }); }); + + describe('recruiter rejected', () => { + it('should notify on recruiter rejected candidate match', async () => { + const after: ChangeObject = { + ...base, + status: OpportunityMatchStatus.RecruiterRejected, + }; + await expectSuccessfulBackground( + worker, + mockChangeMessage({ + after, + before: base, + op: 'u', + table: 'opportunity_match', + }), + ); + expect(triggerTypedEvent).toHaveBeenCalledTimes(1); + expect(triggerTypedEvent).toHaveBeenCalledWith( + expect.any(Object), + 'api.v1.recruiter-rejected-candidate-match', + expect.objectContaining({ + opportunityId: opportunitiesFixture[0].id, + userId: '1', + }), + ); + }); + + it('should not notify when recruiter rejected status stays the same', async () => { + const after: ChangeObject = { + ...base, + status: OpportunityMatchStatus.RecruiterRejected, + }; + await expectSuccessfulBackground( + worker, + mockChangeMessage({ + after, + before: { + ...base, + status: OpportunityMatchStatus.RecruiterRejected, + }, + op: 'u', + table: 'opportunity_match', + }), + ); + expect(triggerTypedEvent).toHaveBeenCalledTimes(0); + }); + }); }); describe('opportunity', () => { diff --git a/__tests__/workers/newNotificationV2Mail.ts b/__tests__/workers/newNotificationV2Mail.ts index 48c8c73006..b55f162ba8 100644 --- a/__tests__/workers/newNotificationV2Mail.ts +++ b/__tests__/workers/newNotificationV2Mail.ts @@ -2813,7 +2813,7 @@ describe('warm_intro notification', () => { .calls[0][0] as SendEmailRequestWithTemplate; expect(args.message_data).toEqual({ - title: `It's a match!`, + title: `[Action Required] It's a match!`, copy: '

Great match based on your experience!

', cc: 'recruiter@test.com', }); diff --git a/src/common/opportunity/pubsub.ts b/src/common/opportunity/pubsub.ts index 4022b104af..bdf6e1ca1b 100644 --- a/src/common/opportunity/pubsub.ts +++ b/src/common/opportunity/pubsub.ts @@ -233,6 +233,27 @@ export const notifyRecruiterCandidateMatchAccepted = async ({ } }; +export const notifyRecruiterCandidateMatchRejected = async ({ + logger, + data, +}: { + logger: FastifyBaseLogger; + data: ChangeObject; +}) => { + const message = new CandidateRejectedOpportunityMessage({ + opportunityId: data.opportunityId, + userId: data.userId, + createdAt: getSecondsTimestamp(data.createdAt), + updatedAt: getSecondsTimestamp(data.updatedAt), + }); + + await triggerTypedEvent( + logger, + 'api.v1.recruiter-rejected-candidate-match', + message, + ); +}; + export const notifyCandidateOpportunityMatchRejected = async ({ con, logger, diff --git a/src/common/typedPubsub.ts b/src/common/typedPubsub.ts index 9c9c175642..c336a011ef 100644 --- a/src/common/typedPubsub.ts +++ b/src/common/typedPubsub.ts @@ -227,6 +227,7 @@ export type PubSubSchema = { }; 'api.v1.recruiter-accepted-candidate-match': RecruiterAcceptedCandidateMatchMessage; 'api.v1.candidate-rejected-opportunity': CandidateRejectedOpportunityMessage; + 'api.v1.recruiter-rejected-candidate-match': CandidateRejectedOpportunityMessage; 'gondul.v1.candidate-application-scored': ApplicationScored; 'gondul.v1.warm-intro-generated': WarmIntro; }; diff --git a/src/workers/cdc/primary.ts b/src/workers/cdc/primary.ts index c0a6f07ac0..98bd62c8a6 100644 --- a/src/workers/cdc/primary.ts +++ b/src/workers/cdc/primary.ts @@ -154,6 +154,7 @@ import { notifyCandidatePreferenceChange, notifyOpportunityMatchAccepted, notifyRecruiterCandidateMatchAccepted, + notifyRecruiterCandidateMatchRejected, } from '../../common/opportunity/pubsub'; import { Opportunity } from '../../entity/opportunities/Opportunity'; import { notifyJobOpportunity } from '../../common/opportunity/pubsub'; @@ -1329,6 +1330,15 @@ const onOpportunityMatchChange = async ( data: data.payload.after!, }); } + if ( + data.payload.after?.status === OpportunityMatchStatus.RecruiterRejected && + data?.payload.before?.status !== OpportunityMatchStatus.RecruiterRejected + ) { + await notifyRecruiterCandidateMatchRejected({ + logger, + data: data.payload.after!, + }); + } } }; diff --git a/src/workers/index.ts b/src/workers/index.ts index 4c8ecee9ad..11a5f60ee7 100644 --- a/src/workers/index.ts +++ b/src/workers/index.ts @@ -72,6 +72,7 @@ import { storeCandidateOpportunityMatch } from './opportunity/storeCandidateOppo import { storeCandidateApplicationScore } from './opportunity/storeCandidateApplicationScore'; import { extractCVMarkdown } from './extractCVMarkdown'; import candidateAcceptedOpportunitySlack from './candidateAcceptedOpportunitySlack'; +import recruiterRejectedCandidateMatchEmail from './recruiterRejectedCandidateMatchEmail'; export { Worker } from './worker'; @@ -147,6 +148,7 @@ export const typedWorkers: BaseTypedWorker[] = [ storeCandidateApplicationScore, extractCVMarkdown, candidateAcceptedOpportunitySlack, + recruiterRejectedCandidateMatchEmail, ]; export const personalizedDigestWorkers: Worker[] = [ diff --git a/src/workers/recruiterRejectedCandidateMatchEmail.ts b/src/workers/recruiterRejectedCandidateMatchEmail.ts new file mode 100644 index 0000000000..6cba9edc53 --- /dev/null +++ b/src/workers/recruiterRejectedCandidateMatchEmail.ts @@ -0,0 +1,74 @@ +import { TypedWorker } from './worker'; +import { CandidateRejectedOpportunityMessage } from '@dailydotdev/schema'; +import { User } from '../entity'; +import { sendEmail, baseNotificationEmailData } from '../common'; +import { isSubscribedToNotificationType } from './notifications/utils'; +import { NotificationChannel, NotificationType } from '../notifications/common'; + +const worker: TypedWorker<'api.v1.recruiter-rejected-candidate-match'> = { + subscription: 'api.recruiter-rejected-candidate-match-email', + handler: async ({ data }, con, logger): Promise => { + const { userId, opportunityId } = data; + + try { + const user = await con.getRepository(User).findOne({ + where: { id: userId }, + select: ['id', 'email', 'notificationFlags'], + }); + + if (!user) { + logger.warn( + { userId, opportunityId }, + 'User not found for recruiter rejected candidate email', + ); + return; + } + + if (!user.email) { + logger.warn( + { userId, opportunityId }, + 'User has no email for recruiter rejected candidate email', + ); + return; + } + + const shouldReceiveEmail = isSubscribedToNotificationType( + user.notificationFlags, + NotificationType.NewOpportunityMatch, + NotificationChannel.Email, + ); + + if (!shouldReceiveEmail) { + logger.info( + { userId, opportunityId }, + 'User is not subscribed to recruiter rejected opportunity emails', + ); + return; + } + + await sendEmail({ + ...baseNotificationEmailData, + reply_to: 'ido@daily.dev', + transactional_message_id: '85', + message_data: { + opportunity_id: opportunityId, + }, + identifiers: { + id: user.id, + }, + to: user.email, + }); + } catch (_err) { + const err = _err as Error; + logger.error( + { err, userId, opportunityId }, + 'failed to send recruiter rejected candidate email', + ); + throw err; + } + }, + parseMessage: (message) => + CandidateRejectedOpportunityMessage.fromBinary(message.data), +}; + +export default worker;