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
4 changes: 4 additions & 0 deletions .infra/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [
Expand Down
47 changes: 47 additions & 0 deletions __tests__/workers/cdc/primary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ObjectType> = {
...base,
status: OpportunityMatchStatus.RecruiterRejected,
};
await expectSuccessfulBackground(
worker,
mockChangeMessage<ObjectType>({
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<ObjectType> = {
...base,
status: OpportunityMatchStatus.RecruiterRejected,
};
await expectSuccessfulBackground(
worker,
mockChangeMessage<ObjectType>({
after,
before: {
...base,
status: OpportunityMatchStatus.RecruiterRejected,
},
op: 'u',
table: 'opportunity_match',
}),
);
expect(triggerTypedEvent).toHaveBeenCalledTimes(0);
});
});
});

describe('opportunity', () => {
Expand Down
2 changes: 1 addition & 1 deletion __tests__/workers/newNotificationV2Mail.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should not be here?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i pushed a change to main earlier, didn't notice we test for the title. i actually looked for it but missed this place. so i'm fixing it here

copy: '<p>Great match based on your experience!</p>',
cc: 'recruiter@test.com',
});
Expand Down
21 changes: 21 additions & 0 deletions src/common/opportunity/pubsub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,27 @@ export const notifyRecruiterCandidateMatchAccepted = async ({
}
};

export const notifyRecruiterCandidateMatchRejected = async ({
logger,
data,
}: {
logger: FastifyBaseLogger;
data: ChangeObject<OpportunityMatch>;
}) => {
const message = new CandidateRejectedOpportunityMessage({
opportunityId: data.opportunityId,
userId: data.userId,
createdAt: getSecondsTimestamp(data.createdAt),
updatedAt: getSecondsTimestamp(data.updatedAt),
});

await triggerTypedEvent(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Think this could be here?

Suggested change
await triggerTypedEvent(
await triggerTypedEvent<'topicnamehere'>(

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nope it's not needed. it's automatically infer the type from the topic name provided as argument

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah ok maybe just the notification one that prefers it

logger,
'api.v1.recruiter-rejected-candidate-match',
message,
);
};

export const notifyCandidateOpportunityMatchRejected = async ({
con,
logger,
Expand Down
1 change: 1 addition & 0 deletions src/common/typedPubsub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
Expand Down
10 changes: 10 additions & 0 deletions src/workers/cdc/primary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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!,
});
}
}
};

Expand Down
2 changes: 2 additions & 0 deletions src/workers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

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

export const personalizedDigestWorkers: Worker[] = [
Expand Down
74 changes: 74 additions & 0 deletions src/workers/recruiterRejectedCandidateMatchEmail.ts
Original file line number Diff line number Diff line change
@@ -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<void> => {
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,
});
Comment on lines +49 to +60
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do you use this direct one and not the notification worker way?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

because we don't trigger an actual notification. just email this was my understanding

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

with a notification we can't ask for feedback

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes but you just null the notification? isn't it better to uniform instead of duplicate?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah thinks you're right I thought we could individually turn off

  • email
  • push
  • realtime

But guess we only added that to email, we should maybe do that?
Envision in future we probably have more singular endpoints..

For now this is fine then. (just hate duplication of this function)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i'll try to find a way to streamline it, out of this PR

} 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;
Loading