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)];