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 @@ -437,6 +437,10 @@ export const workers: Worker[] = [
topic: 'api.v1.opportunity-went-live',
subscription: 'api.recruiter-opportunity-live-notification',
},
{
topic: 'api.v1.opportunity-external-payment',
subscription: 'api.recruiter-external-payment-notification',
},
{
topic: 'api.v1.opportunity-in-review',
subscription: 'api.opportunity-in-review-slack',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { DataSource } from 'typeorm';
import { recruiterExternalPaymentNotification as worker } from '../../../src/workers/notifications/recruiterExternalPaymentNotification';
import createOrGetConnection from '../../../src/db';
import { Organization, User } from '../../../src/entity';
import { OpportunityJob } from '../../../src/entity/opportunities/OpportunityJob';
import { OpportunityUser } from '../../../src/entity/opportunities/user';
import { OpportunityUserType } from '../../../src/entity/opportunities/types';
import { OpportunityType, OpportunityState } from '@dailydotdev/schema';
import { usersFixture } from '../../fixture';
import { workers } from '../../../src/workers';
import { invokeTypedNotificationWorker, saveFixtures } from '../../helpers';
import { NotificationType } from '../../../src/notifications/common';
import type { NotificationRecruiterExternalPaymentContext } from '../../../src/notifications';

let con: DataSource;

describe('recruiterExternalPaymentNotification worker', () => {
beforeAll(async () => {
con = await createOrGetConnection();
});

beforeEach(async () => {
jest.resetAllMocks();
await saveFixtures(con, User, usersFixture);
});

it('should be registered', () => {
const registeredWorker = workers.find(
(item) => item.subscription === worker.subscription,
);

expect(registeredWorker).toBeDefined();
});

it('should send notification to all recruiters when external payment is made', async () => {
const organization = await con.getRepository(Organization).save({
id: 'org-ext-pay-1',
name: 'Test Organization',
});

const opportunity = await con.getRepository(OpportunityJob).save({
id: '123e4567-e89b-12d3-a456-426614174010',
type: OpportunityType.JOB,
state: OpportunityState.DRAFT,
title: 'Senior Software Engineer',
tldr: 'Great opportunity',
content: {},
meta: {},
organizationId: organization.id,
location: [],
});

const recruiter1 = await con.getRepository(User).save({
id: 'ext-recruiter1',
name: 'John Recruiter',
email: 'john-ext@test.com',
});

const recruiter2 = await con.getRepository(User).save({
id: 'ext-recruiter2',
name: 'Jane Recruiter',
email: 'jane-ext@test.com',
});

await con.getRepository(OpportunityUser).save([
{
opportunityId: opportunity.id,
userId: recruiter1.id,
type: OpportunityUserType.Recruiter,
},
{
opportunityId: opportunity.id,
userId: recruiter2.id,
type: OpportunityUserType.Recruiter,
},
]);

const result =
await invokeTypedNotificationWorker<'api.v1.opportunity-external-payment'>(
worker,
{
opportunityId: opportunity.id,
title: 'Senior Software Engineer',
},
);

expect(result).toHaveLength(1);
expect(result![0].type).toEqual(NotificationType.RecruiterExternalPayment);

const context = result![0]
.ctx as NotificationRecruiterExternalPaymentContext;

expect(context.userIds).toHaveLength(2);
expect(context.userIds).toContain('ext-recruiter1');
expect(context.userIds).toContain('ext-recruiter2');
expect(context.opportunityTitle).toEqual('Senior Software Engineer');
});

it('should return empty array when no recruiters found', async () => {
const organization = await con.getRepository(Organization).save({
id: 'org-ext-pay-2',
name: 'Another Organization',
});

await con.getRepository(OpportunityJob).save({
id: '123e4567-e89b-12d3-a456-426614174011',
type: OpportunityType.JOB,
state: OpportunityState.DRAFT,
title: 'Backend Developer',
tldr: 'Backend role',
content: {},
meta: {},
organizationId: organization.id,
location: [],
});

const result =
await invokeTypedNotificationWorker<'api.v1.opportunity-external-payment'>(
worker,
{
opportunityId: '123e4567-e89b-12d3-a456-426614174011',
title: 'Backend Developer',
},
);

expect(result).toEqual([]);
});
});
18 changes: 13 additions & 5 deletions src/common/paddle/recruiter/processing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { Organization } from '../../../entity/Organization';
import { User } from '../../../entity/user/User';
import { DeletedUser } from '../../../entity/user/DeletedUser';
import type { EntityManager } from 'typeorm';
import { triggerTypedEvent } from '../../typedPubsub';

const checkUserValid = async ({
userId,
Expand Down Expand Up @@ -72,9 +73,8 @@ export const createOpportunitySubscription = async ({
}) => {
const data = getPaddleSubscriptionData({ event });
const con = await createOrGetConnection();
const { opportunity_id, user_id } = recruiterPaddleCustomDataSchema.parse(
event.data.customData,
);
const { opportunity_id, user_id, external_pay } =
recruiterPaddleCustomDataSchema.parse(event.data.customData);

const subscriptionType = extractSubscriptionCycle(
data.items as PaddleSubscriptionEvent['data']['items'],
Expand Down Expand Up @@ -105,9 +105,9 @@ export const createOpportunitySubscription = async ({

const opportunity: Pick<
OpportunityJob,
'id' | 'organizationId' | 'organization'
'id' | 'organizationId' | 'organization' | 'title'
> = await con.getRepository(OpportunityJob).findOneOrFail({
select: ['id', 'organizationId', 'organization'],
select: ['id', 'organizationId', 'organization', 'title'],
where: {
id: opportunity_id,
},
Expand Down Expand Up @@ -195,6 +195,14 @@ export const createOpportunitySubscription = async ({
},
);
});

// If this is an external payment, notify existing recruiters
if (external_pay) {
await triggerTypedEvent(logger, 'api.v1.opportunity-external-payment', {
opportunityId: opportunity_id,
title: opportunity.title,
});
}
};

export const cancelRecruiterSubscription = async ({
Expand Down
1 change: 1 addition & 0 deletions src/common/paddle/recruiter/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { zCoerceStringBoolean } from '../../schema/common';
export const recruiterPaddleCustomDataSchema = z.object({
user_id: z.string(),
opportunity_id: z.uuid(),
external_pay: zCoerceStringBoolean.nullish(),
});

export const recruiterPaddlePricingCustomDataSchema = z.object({
Expand Down
1 change: 1 addition & 0 deletions src/common/schema/notificationFlagsSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,4 +51,5 @@ export const notificationFlagsSchema = z.strictObject({
[NotificationType.PollResultAuthor]: notificationPreferenceSchema,
[NotificationType.RecruiterNewCandidate]: notificationPreferenceSchema,
[NotificationType.RecruiterOpportunityLive]: notificationPreferenceSchema,
[NotificationType.RecruiterExternalPayment]: notificationPreferenceSchema,
});
4 changes: 4 additions & 0 deletions src/common/typedPubsub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,10 @@ export type PubSubSchema = {
opportunityId: string;
title: string;
};
'api.v1.opportunity-external-payment': {
opportunityId: string;
title: string;
};
'api.v1.opportunity-flags-change': {
opportunityId: string;
before: string | null;
Expand Down
5 changes: 5 additions & 0 deletions src/notifications/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ export enum NotificationType {
ParsedCVProfile = 'parsed_cv_profile',
RecruiterNewCandidate = 'recruiter_new_candidate',
RecruiterOpportunityLive = 'recruiter_opportunity_live',
RecruiterExternalPayment = 'recruiter_external_payment',
ExperienceCompanyEnriched = 'experience_company_enriched',
}

Expand Down Expand Up @@ -293,6 +294,10 @@ export const DEFAULT_NOTIFICATION_SETTINGS: UserNotificationFlags = {
email: NotificationPreferenceStatus.Subscribed,
inApp: NotificationPreferenceStatus.Subscribed,
},
[NotificationType.RecruiterExternalPayment]: {
email: NotificationPreferenceStatus.Subscribed,
inApp: NotificationPreferenceStatus.Subscribed,
},
};

export const commentReplyNotificationTypes = [
Expand Down
16 changes: 16 additions & 0 deletions src/notifications/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import {
type NotificationRecruiterNewCandidateContext,
type NotificationRecruiterOpportunityLiveContext,
type NotificationExperienceCompanyEnrichedContext,
type NotificationRecruiterExternalPaymentContext,
} from './types';
import { UPVOTE_TITLES } from '../workers/notifications/utils';
import { checkHasMention } from '../common/markdown';
Expand Down Expand Up @@ -224,6 +225,10 @@ export const notificationTitleMap: Record<
ctx: NotificationExperienceCompanyEnrichedContext,
) =>
`Your ${ctx.experienceType} experience <b>${ctx.experienceTitle}</b> has been linked to <b>${ctx.companyName}</b>!`,
recruiter_external_payment: (
ctx: NotificationRecruiterExternalPaymentContext,
) =>
`Your job opportunity <b>${ctx.opportunityTitle}</b> has been <span class="text-theme-color-cabbage">paid</span> for!`,
};

export const generateNotificationMap: Record<
Expand Down Expand Up @@ -651,4 +656,15 @@ export const generateNotificationMap: Record<
)
.uniqueKey(ctx.experienceId);
},
recruiter_external_payment: (
builder: NotificationBuilder,
ctx: NotificationRecruiterExternalPaymentContext,
) => {
return builder
.icon(NotificationIcon.Opportunity)
.referenceOpportunity(ctx.opportunityId)
.targetUrl(
`${process.env.COMMENTS_PREFIX}/opportunity/${ctx.opportunityId}/prepare`,
);
},
};
6 changes: 6 additions & 0 deletions src/notifications/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,12 @@ export type NotificationExperienceCompanyEnrichedContext =
companyName: string;
};

export type NotificationRecruiterExternalPaymentContext =
NotificationBaseContext & {
opportunityId: string;
opportunityTitle: string;
};

declare module 'fs' {
interface ReadStream {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
Expand Down
6 changes: 6 additions & 0 deletions src/workers/newNotificationV2Mail.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ export const notificationToTemplateId: Record<NotificationType, string> = {
recruiter_new_candidate: '89',
recruiter_opportunity_live: '90',
experience_company_enriched: '',
recruiter_external_payment: '91',
};

type TemplateData = Record<string, unknown> & {
Expand Down Expand Up @@ -1212,6 +1213,11 @@ const notificationToTemplateData: Record<NotificationType, TemplateDataFunc> = {
opportunity_link: notification.targetUrl,
};
},
recruiter_external_payment: async (_con, _user, notification) => {
return {
opportunity_link: notification.targetUrl,
};
},
experience_company_enriched: async () => {
return null;
},
Expand Down
2 changes: 2 additions & 0 deletions src/workers/notifications/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import { parseCVProfileWorker } from '../opportunity/parseCVProfile';
import { recruiterNewCandidateNotification } from './recruiterNewCandidateNotification';
import { recruiterOpportunityLiveNotification } from './recruiterOpportunityLiveNotification';
import { experienceCompanyEnrichedNotification } from './experienceCompanyEnrichedNotification';
import { recruiterExternalPaymentNotification } from './recruiterExternalPaymentNotification';

export function notificationWorkerToWorker(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
Expand Down Expand Up @@ -128,6 +129,7 @@ const notificationWorkers: TypedNotificationWorker<any>[] = [
recruiterNewCandidateNotification,
recruiterOpportunityLiveNotification,
experienceCompanyEnrichedNotification,
recruiterExternalPaymentNotification,
];

export const workers = [...notificationWorkers.map(notificationWorkerToWorker)];
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { TypedNotificationWorker } from '../worker';
import { OpportunityUser } from '../../entity/opportunities/user';
import { OpportunityUserType } from '../../entity/opportunities/types';
import { NotificationType } from '../../notifications/common';

export const recruiterExternalPaymentNotification: TypedNotificationWorker<'api.v1.opportunity-external-payment'> =
{
subscription: 'api.recruiter-external-payment-notification',
handler: async (data, con, logger) => {
const { opportunityId, title } = data;

try {
// Fetch recruiters for the opportunity, excluding the payer
const opportunityUsers = await con.getRepository(OpportunityUser).find({
where: {
opportunityId,
type: OpportunityUserType.Recruiter,
},
});

const recruiterIds = opportunityUsers.map((ou) => ou.userId);

if (recruiterIds.length === 0) {
logger.info(
{ opportunityId },
'No other recruiters found to notify for external payment',
);
return [];
}

return [
{
type: NotificationType.RecruiterExternalPayment,
ctx: {
userIds: recruiterIds,
opportunityId,
opportunityTitle: title,
},
},
];
} catch (err) {
logger.error(
{ data, err },
'failed to generate recruiter external payment notification',
);
return [];
}
},
};
Loading