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 @@ -464,6 +464,10 @@ export const workers: Worker[] = [
topic: 'api.v1.opportunity-feedback-submitted',
subscription: 'api.parse-opportunity-feedback',
},
{
topic: 'api.v1.experience-company-enriched',
subscription: 'api.experience-company-enriched-notification',
},
{
topic: 'api.v1.opportunity-parse',
subscription: 'api.opportunity-parse',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { DataSource } from 'typeorm';
import { experienceCompanyEnrichedNotification as worker } from '../../../src/workers/notifications/experienceCompanyEnrichedNotification';
import createOrGetConnection from '../../../src/db';
import { usersFixture } from '../../fixture';
import { workers } from '../../../src/workers';
import { invokeTypedNotificationWorker, saveFixtures } from '../../helpers';
import { NotificationType } from '../../../src/notifications/common';
import type { NotificationExperienceCompanyEnrichedContext } from '../../../src/notifications';
import { User } from '../../../src/entity/user/User';
import { UserExperienceType } from '../../../src/entity/user/experiences/types';
import { UserExperience } from '../../../src/entity/user/experiences/UserExperience';
import { Company } from '../../../src/entity/Company';
import { companyFixture } from '../../fixture/company';
import { userExperienceFixture } from '../../fixture/profile/experience';

let con: DataSource;

beforeAll(async () => {
con = await createOrGetConnection();
});

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

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

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

it('should send notification for work experience enrichment', async () => {
// Get the current work experience (the one with no endedAt)
const savedExperience = await con.getRepository(UserExperience).findOne({
where: {
userId: '1',
type: UserExperienceType.Work,
endedAt: null,
},
});

const result =
await invokeTypedNotificationWorker<'api.v1.experience-company-enriched'>(
worker,
{
experienceId: savedExperience!.id,
userId: '1',
companyId: 'dailydev',
},
);

expect(result).toBeDefined();
expect(result!.length).toEqual(1);
expect(result![0].type).toEqual(NotificationType.ExperienceCompanyEnriched);

const ctx = result![0].ctx as NotificationExperienceCompanyEnrichedContext;
expect(ctx.userIds).toEqual(['1']);
expect(ctx.experienceId).toEqual(savedExperience!.id);
expect(ctx.experienceTitle).toEqual('Senior Software Engineer');
expect(ctx.experienceType).toEqual(UserExperienceType.Work);
expect(ctx.companyId).toEqual('dailydev');
expect(ctx.companyName).toEqual('daily.dev');
});

it('should send notification for education experience enrichment', async () => {
// Get the education experience from fixtures (userId '1', type Education)
const savedExperience = await con
.getRepository(UserExperience)
.findOneBy({ userId: '1', type: UserExperienceType.Education });

const result =
await invokeTypedNotificationWorker<'api.v1.experience-company-enriched'>(
worker,
{
experienceId: savedExperience!.id,
userId: '1',
companyId: 'dailydev',
},
);

expect(result).toBeDefined();
expect(result!.length).toEqual(1);
expect(result![0].type).toEqual(NotificationType.ExperienceCompanyEnriched);

const ctx = result![0].ctx as NotificationExperienceCompanyEnrichedContext;
expect(ctx.userIds).toEqual(['1']);
expect(ctx.experienceId).toEqual(savedExperience!.id);
expect(ctx.experienceTitle).toEqual('Computer Science');
expect(ctx.experienceType).toEqual(UserExperienceType.Education);
expect(ctx.companyId).toEqual('dailydev');
expect(ctx.companyName).toEqual('daily.dev');
});
});
5 changes: 5 additions & 0 deletions src/common/typedPubsub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,11 @@ export type PubSubSchema = {
opportunityId: string;
userId: string;
};
'api.v1.experience-company-enriched': {
experienceId: string;
userId: string;
companyId: string;
};
'api.v1.opportunity-parse': {
opportunityId: string;
};
Expand Down
1 change: 1 addition & 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',
ExperienceCompanyEnriched = 'experience_company_enriched',
}

export enum NotificationPreferenceType {
Expand Down
19 changes: 18 additions & 1 deletion src/notifications/generate.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { PostType, FreeformPost, KeywordFlags } from '../entity';
import { PostType, FreeformPost, KeywordFlags, User } from '../entity';
import { NotificationBuilder } from './builder';
import { NotificationIcon } from './icons';
import {
Expand Down Expand Up @@ -35,6 +35,7 @@ import {
type NotificationParsedCVProfileContext,
type NotificationRecruiterNewCandidateContext,
type NotificationRecruiterOpportunityLiveContext,
type NotificationExperienceCompanyEnrichedContext,
} from './types';
import { UPVOTE_TITLES } from '../workers/notifications/utils';
import { checkHasMention } from '../common/markdown';
Expand Down Expand Up @@ -219,6 +220,10 @@ export const notificationTitleMap: Record<
ctx: NotificationRecruiterOpportunityLiveContext,
) =>
`Your job opportunity <b>${ctx.opportunityTitle}</b> is now <span class="text-theme-color-cabbage">live</span>!`,
experience_company_enriched: (
ctx: NotificationExperienceCompanyEnrichedContext,
) =>
`Your ${ctx.experienceType} experience <b>${ctx.experienceTitle}</b> has been linked to <b>${ctx.companyName}</b>!`,
};

export const generateNotificationMap: Record<
Expand Down Expand Up @@ -634,4 +639,16 @@ export const generateNotificationMap: Record<
`${process.env.COMMENTS_PREFIX}/opportunity/${ctx.opportunityId}`,
);
},
experience_company_enriched: (
builder: NotificationBuilder,
ctx: NotificationExperienceCompanyEnrichedContext,
) => {
return builder
.icon(NotificationIcon.Bell)
.referenceUser({ id: ctx.userIds[0] } as User)
.targetUrl(
`${process.env.COMMENTS_PREFIX}/settings/profile/experience/${ctx.experienceType}`,
)
.uniqueKey(ctx.experienceId);
},
};
10 changes: 10 additions & 0 deletions src/notifications/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { SourcePostModeration } from '../entity/SourcePostModeration';
import type { UserTransaction } from '../entity/user/UserTransaction';
import type { CampaignUpdateEvent } from '../common/campaign/common';
import type { PostAnalytics } from '../entity/posts/PostAnalytics';
import type { UserExperienceType } from '../entity/user/experiences/types';

export type Reference<T> = ChangeObject<T> | T;

Expand Down Expand Up @@ -189,6 +190,15 @@ export type NotificationRecruiterOpportunityLiveContext =
opportunityTitle: string;
};

export type NotificationExperienceCompanyEnrichedContext =
NotificationBaseContext & {
experienceId: string;
experienceTitle: string;
experienceType: UserExperienceType;
companyId: string;
companyName: string;
};

declare module 'fs' {
interface ReadStream {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
Expand Down
16 changes: 15 additions & 1 deletion src/workers/cdc/primary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1608,7 +1608,7 @@ const onUserExperienceChange = async (
experience.customCompanyName &&
!experience.companyId
) {
await enrichCompanyForExperience(
const enrichmentResult = await enrichCompanyForExperience(
con,
{
experienceId: experience.id,
Expand All @@ -1617,6 +1617,20 @@ const onUserExperienceChange = async (
},
logger,
);

// Notify user if enrichment successfully linked a company
if (enrichmentResult.success && enrichmentResult.companyId) {
const company = await con
.getRepository(Company)
.findOneBy({ id: enrichmentResult.companyId });
if (company) {
await triggerTypedEvent(logger, 'api.v1.experience-company-enriched', {
experienceId: experience.id,
userId: experience.userId,
companyId: enrichmentResult.companyId,
});
}
}
}

// Work-specific verification logic
Expand Down
4 changes: 4 additions & 0 deletions src/workers/newNotificationV2Mail.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ export const notificationToTemplateId: Record<NotificationType, string> = {
parsed_cv_profile: '',
recruiter_new_candidate: '89',
recruiter_opportunity_live: '90',
experience_company_enriched: '',
};

type TemplateData = Record<string, unknown> & {
Expand Down Expand Up @@ -1211,6 +1212,9 @@ const notificationToTemplateData: Record<NotificationType, TemplateDataFunc> = {
opportunity_link: notification.targetUrl,
};
},
experience_company_enriched: async () => {
return null;
},
};

const formatTemplateDate = <T extends TemplateData>(data: T): T => {
Expand Down
42 changes: 42 additions & 0 deletions src/workers/notifications/experienceCompanyEnrichedNotification.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { NotificationType } from '../../notifications/common';
import { TypedNotificationWorker } from '../worker';
import { UserExperience } from '../../entity/user/experiences/UserExperience';
import { Company } from '../../entity/Company';

export const experienceCompanyEnrichedNotification: TypedNotificationWorker<'api.v1.experience-company-enriched'> =
{
subscription: 'api.experience-company-enriched-notification',
handler: async (data, con) => {
const { experienceId, userId, companyId } = data;

const experience = await con
.getRepository(UserExperience)
.findOneBy({ id: experienceId });

if (!experience) {
return [];
}

const company = await con
.getRepository(Company)
.findOneBy({ id: companyId });

if (!company) {
return [];
}

return [
{
type: NotificationType.ExperienceCompanyEnriched,
ctx: {
userIds: [userId],
experienceId,
experienceTitle: experience.title,
experienceType: experience.type,
companyId,
companyName: company.name,
},
},
];
},
};
2 changes: 2 additions & 0 deletions src/workers/notifications/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import { warmIntroNotification } from './warmIntroNotification';
import { parseCVProfileWorker } from '../opportunity/parseCVProfile';
import { recruiterNewCandidateNotification } from './recruiterNewCandidateNotification';
import { recruiterOpportunityLiveNotification } from './recruiterOpportunityLiveNotification';
import { experienceCompanyEnrichedNotification } from './experienceCompanyEnrichedNotification';

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

export const workers = [...notificationWorkers.map(notificationWorkerToWorker)];
Loading