diff --git a/.infra/common.ts b/.infra/common.ts index 6e6e5444ae..1222d5a5ee 100644 --- a/.infra/common.ts +++ b/.infra/common.ts @@ -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', diff --git a/__tests__/workers/notifications/experienceCompanyEnrichedNotification.ts b/__tests__/workers/notifications/experienceCompanyEnrichedNotification.ts new file mode 100644 index 0000000000..e76a13ebbf --- /dev/null +++ b/__tests__/workers/notifications/experienceCompanyEnrichedNotification.ts @@ -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'); + }); +}); diff --git a/src/common/typedPubsub.ts b/src/common/typedPubsub.ts index 8e103baff1..7bae53057e 100644 --- a/src/common/typedPubsub.ts +++ b/src/common/typedPubsub.ts @@ -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; }; diff --git a/src/notifications/common.ts b/src/notifications/common.ts index d137554833..2def03a267 100644 --- a/src/notifications/common.ts +++ b/src/notifications/common.ts @@ -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 { diff --git a/src/notifications/generate.ts b/src/notifications/generate.ts index 236df25d1e..3c0d1d1326 100644 --- a/src/notifications/generate.ts +++ b/src/notifications/generate.ts @@ -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 { @@ -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'; @@ -219,6 +220,10 @@ export const notificationTitleMap: Record< ctx: NotificationRecruiterOpportunityLiveContext, ) => `Your job opportunity ${ctx.opportunityTitle} is now live!`, + experience_company_enriched: ( + ctx: NotificationExperienceCompanyEnrichedContext, + ) => + `Your ${ctx.experienceType} experience ${ctx.experienceTitle} has been linked to ${ctx.companyName}!`, }; export const generateNotificationMap: Record< @@ -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); + }, }; diff --git a/src/notifications/types.ts b/src/notifications/types.ts index 604becc5d9..77dedf99ed 100644 --- a/src/notifications/types.ts +++ b/src/notifications/types.ts @@ -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 = ChangeObject | T; @@ -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 diff --git a/src/workers/cdc/primary.ts b/src/workers/cdc/primary.ts index b887307e77..374537ce3c 100644 --- a/src/workers/cdc/primary.ts +++ b/src/workers/cdc/primary.ts @@ -1608,7 +1608,7 @@ const onUserExperienceChange = async ( experience.customCompanyName && !experience.companyId ) { - await enrichCompanyForExperience( + const enrichmentResult = await enrichCompanyForExperience( con, { experienceId: experience.id, @@ -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 diff --git a/src/workers/newNotificationV2Mail.ts b/src/workers/newNotificationV2Mail.ts index 503764e045..c3b2236393 100644 --- a/src/workers/newNotificationV2Mail.ts +++ b/src/workers/newNotificationV2Mail.ts @@ -130,6 +130,7 @@ export const notificationToTemplateId: Record = { parsed_cv_profile: '', recruiter_new_candidate: '89', recruiter_opportunity_live: '90', + experience_company_enriched: '', }; type TemplateData = Record & { @@ -1211,6 +1212,9 @@ const notificationToTemplateData: Record = { opportunity_link: notification.targetUrl, }; }, + experience_company_enriched: async () => { + return null; + }, }; const formatTemplateDate = (data: T): T => { diff --git a/src/workers/notifications/experienceCompanyEnrichedNotification.ts b/src/workers/notifications/experienceCompanyEnrichedNotification.ts new file mode 100644 index 0000000000..ad1d0322ba --- /dev/null +++ b/src/workers/notifications/experienceCompanyEnrichedNotification.ts @@ -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, + }, + }, + ]; + }, + }; diff --git a/src/workers/notifications/index.ts b/src/workers/notifications/index.ts index 443fb7a5eb..1b9dfb2cfe 100644 --- a/src/workers/notifications/index.ts +++ b/src/workers/notifications/index.ts @@ -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 @@ -126,6 +127,7 @@ const notificationWorkers: TypedNotificationWorker[] = [ parseCVProfileWorker, recruiterNewCandidateNotification, recruiterOpportunityLiveNotification, + experienceCompanyEnrichedNotification, ]; export const workers = [...notificationWorkers.map(notificationWorkerToWorker)];