Skip to content

Commit 13533bb

Browse files
authored
feat: only sync reminder opportunities (#3383)
1 parent 4bf354b commit 13533bb

11 files changed

Lines changed: 432 additions & 3 deletions

File tree

.infra/common.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -441,6 +441,10 @@ export const workers: Worker[] = [
441441
topic: 'api.v1.opportunity-in-review',
442442
subscription: 'api.opportunity-in-review-slack',
443443
},
444+
{
445+
topic: 'api.v1.opportunity-flags-change',
446+
subscription: 'sync-opportunity-reminders-cio',
447+
},
444448
{
445449
topic: 'api.v1.recruiter-rejected-candidate-match',
446450
subscription: 'api.recruiter-rejected-candidate-match-email',

__tests__/cio.ts

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import { identifyUserOpportunities } from '../src/cio';
2+
import { OpportunityMatchStatus } from '../src/entity/opportunities/types';
3+
import type { ConnectionManager } from '../src/entity';
4+
import type { TrackClient } from 'customerio-node';
5+
6+
// Mock isProd to return true so the function doesn't early return
7+
jest.mock('../src/common/utils', () => ({
8+
...jest.requireActual('../src/common/utils'),
9+
isProd: true,
10+
}));
11+
12+
describe('identifyUserOpportunities', () => {
13+
it('should call cio.identify with filtered opportunities', async () => {
14+
const mockMatches = [
15+
{
16+
userId: '1',
17+
opportunityId: 'opp-1',
18+
opportunity: Promise.resolve({
19+
id: 'opp-1',
20+
title: 'Job 1',
21+
flags: { reminders: true },
22+
}),
23+
status: OpportunityMatchStatus.Pending,
24+
},
25+
{
26+
userId: '1',
27+
opportunityId: 'opp-2',
28+
opportunity: Promise.resolve({
29+
id: 'opp-2',
30+
title: 'Job 2',
31+
flags: { reminders: false },
32+
}),
33+
status: OpportunityMatchStatus.Pending,
34+
},
35+
];
36+
37+
const mockFind = jest.fn().mockResolvedValue(mockMatches);
38+
const mockCioIdentify = jest.fn().mockResolvedValue(undefined);
39+
40+
const mockCon = {
41+
getRepository: jest.fn().mockReturnValue({
42+
find: mockFind,
43+
}),
44+
} as unknown as ConnectionManager;
45+
46+
const mockCio = {
47+
identify: mockCioIdentify,
48+
} as unknown as TrackClient;
49+
50+
await identifyUserOpportunities({
51+
cio: mockCio,
52+
con: mockCon,
53+
userId: '1',
54+
});
55+
56+
// Verify repository was called correctly
57+
expect(mockFind).toHaveBeenCalledWith({
58+
where: { userId: '1', status: OpportunityMatchStatus.Pending },
59+
relations: ['opportunity'],
60+
order: { createdAt: 'ASC' },
61+
});
62+
63+
// Verify CIO was called with only opportunities where reminders=true
64+
expect(mockCioIdentify).toHaveBeenCalledWith('1', {
65+
opportunities: ['opp-1'],
66+
});
67+
});
68+
69+
it('should call cio.identify with null when no reminders enabled', async () => {
70+
const mockMatches = [
71+
{
72+
userId: '1',
73+
opportunityId: 'opp-1',
74+
opportunity: Promise.resolve({
75+
id: 'opp-1',
76+
title: 'Job 1',
77+
flags: { reminders: false },
78+
}),
79+
status: OpportunityMatchStatus.Pending,
80+
},
81+
];
82+
83+
const mockFind = jest.fn().mockResolvedValue(mockMatches);
84+
const mockCioIdentify = jest.fn().mockResolvedValue(undefined);
85+
86+
const mockCon = {
87+
getRepository: jest.fn().mockReturnValue({
88+
find: mockFind,
89+
}),
90+
} as unknown as ConnectionManager;
91+
92+
const mockCio = {
93+
identify: mockCioIdentify,
94+
} as unknown as TrackClient;
95+
96+
await identifyUserOpportunities({
97+
cio: mockCio,
98+
con: mockCon,
99+
userId: '1',
100+
});
101+
102+
// Verify CIO was called with null when no reminders
103+
expect(mockCioIdentify).toHaveBeenCalledWith('1', {
104+
opportunities: null,
105+
});
106+
});
107+
108+
it('should only include pending matches', async () => {
109+
const mockMatches = [
110+
{
111+
userId: '1',
112+
opportunityId: 'opp-1',
113+
opportunity: Promise.resolve({
114+
id: 'opp-1',
115+
title: 'Job 1',
116+
flags: { reminders: true },
117+
}),
118+
status: OpportunityMatchStatus.Pending,
119+
},
120+
];
121+
122+
const mockFind = jest.fn().mockResolvedValue(mockMatches);
123+
const mockCioIdentify = jest.fn().mockResolvedValue(undefined);
124+
125+
const mockCon = {
126+
getRepository: jest.fn().mockReturnValue({
127+
find: mockFind,
128+
}),
129+
} as unknown as ConnectionManager;
130+
131+
const mockCio = {
132+
identify: mockCioIdentify,
133+
} as unknown as TrackClient;
134+
135+
await identifyUserOpportunities({
136+
cio: mockCio,
137+
con: mockCon,
138+
userId: '1',
139+
});
140+
141+
// Verify the where clause filters by Pending status
142+
expect(mockFind).toHaveBeenCalledWith(
143+
expect.objectContaining({
144+
where: expect.objectContaining({
145+
status: OpportunityMatchStatus.Pending,
146+
}),
147+
}),
148+
);
149+
});
150+
});
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import { expectSuccessfulTypedBackground, saveFixtures } from '../../helpers';
2+
import { syncOpportunityRemindersCio as worker } from '../../../src/workers/opportunity/syncOpportunityRemindersCio';
3+
import { DataSource } from 'typeorm';
4+
import createOrGetConnection from '../../../src/db';
5+
import { OpportunityMatch } from '../../../src/entity/OpportunityMatch';
6+
import { User, Organization } from '../../../src/entity';
7+
import { Opportunity } from '../../../src/entity/opportunities/Opportunity';
8+
import { usersFixture } from '../../fixture';
9+
import {
10+
datasetLocationsFixture,
11+
opportunitiesFixture,
12+
organizationsFixture,
13+
opportunityMatchesFixture,
14+
} from '../../fixture/opportunity';
15+
import { DatasetLocation } from '../../../src/entity/dataset/DatasetLocation';
16+
import { identifyUserOpportunities } from '../../../src/cio';
17+
18+
jest.mock('../../../src/cio', () => ({
19+
...jest.requireActual('../../../src/cio'),
20+
identifyUserOpportunities: jest.fn(),
21+
}));
22+
23+
const mockIdentifyUserOpportunities =
24+
identifyUserOpportunities as jest.MockedFunction<
25+
typeof identifyUserOpportunities
26+
>;
27+
28+
let con: DataSource;
29+
30+
beforeAll(async () => {
31+
con = await createOrGetConnection();
32+
});
33+
34+
describe('syncOpportunityRemindersCio worker', () => {
35+
beforeEach(async () => {
36+
jest.resetAllMocks();
37+
await saveFixtures(con, DatasetLocation, datasetLocationsFixture);
38+
await saveFixtures(con, Organization, organizationsFixture);
39+
await saveFixtures(con, User, usersFixture);
40+
await saveFixtures(con, Opportunity, opportunitiesFixture);
41+
await saveFixtures(con, OpportunityMatch, opportunityMatchesFixture);
42+
});
43+
44+
it('should skip syncing when reminders flag did not change', async () => {
45+
const flagsJson = JSON.stringify({ reminders: true, batchSize: 10 });
46+
47+
await expectSuccessfulTypedBackground<'api.v1.opportunity-flags-change'>(
48+
worker,
49+
{
50+
opportunityId: '550e8400-e29b-41d4-a716-446655440001',
51+
before: flagsJson,
52+
after: flagsJson, // Same flags - reminders didn't change
53+
},
54+
);
55+
56+
// Should not have called identifyUserOpportunities
57+
expect(mockIdentifyUserOpportunities).not.toHaveBeenCalled();
58+
});
59+
60+
it('should sync CIO when reminders flag changes from false to true', async () => {
61+
const beforeFlags = JSON.stringify({ reminders: false });
62+
const afterFlags = JSON.stringify({ reminders: true });
63+
64+
await expectSuccessfulTypedBackground<'api.v1.opportunity-flags-change'>(
65+
worker,
66+
{
67+
opportunityId: '550e8400-e29b-41d4-a716-446655440001',
68+
before: beforeFlags,
69+
after: afterFlags,
70+
},
71+
);
72+
73+
// Should have called identifyUserOpportunities for users with pending matches
74+
expect(mockIdentifyUserOpportunities).toHaveBeenCalledTimes(1);
75+
expect(mockIdentifyUserOpportunities).toHaveBeenCalledWith(
76+
expect.objectContaining({
77+
userId: '1',
78+
}),
79+
);
80+
});
81+
82+
it('should sync CIO when reminders flag changes from true to false', async () => {
83+
const beforeFlags = JSON.stringify({ reminders: true });
84+
const afterFlags = JSON.stringify({ reminders: false });
85+
86+
await expectSuccessfulTypedBackground<'api.v1.opportunity-flags-change'>(
87+
worker,
88+
{
89+
opportunityId: '550e8400-e29b-41d4-a716-446655440001',
90+
before: beforeFlags,
91+
after: afterFlags,
92+
},
93+
);
94+
95+
// Should have called identifyUserOpportunities
96+
expect(mockIdentifyUserOpportunities).toHaveBeenCalledTimes(1);
97+
expect(mockIdentifyUserOpportunities).toHaveBeenCalledWith(
98+
expect.objectContaining({
99+
userId: '1',
100+
}),
101+
);
102+
});
103+
104+
it('should skip syncing when other flags change but reminders stays the same', async () => {
105+
const beforeFlags = JSON.stringify({ reminders: true, batchSize: 10 });
106+
const afterFlags = JSON.stringify({ reminders: true, batchSize: 20 });
107+
108+
await expectSuccessfulTypedBackground<'api.v1.opportunity-flags-change'>(
109+
worker,
110+
{
111+
opportunityId: '550e8400-e29b-41d4-a716-446655440001',
112+
before: beforeFlags,
113+
after: afterFlags,
114+
},
115+
);
116+
117+
// Should not have called identifyUserOpportunities since reminders didn't change
118+
expect(mockIdentifyUserOpportunities).not.toHaveBeenCalled();
119+
});
120+
121+
it('should handle null before flags (opportunity creation)', async () => {
122+
const afterFlags = JSON.stringify({ reminders: true });
123+
124+
await expectSuccessfulTypedBackground<'api.v1.opportunity-flags-change'>(
125+
worker,
126+
{
127+
opportunityId: '550e8400-e29b-41d4-a716-446655440001',
128+
before: null,
129+
after: afterFlags,
130+
},
131+
);
132+
133+
// Should have called identifyUserOpportunities since reminders changed from undefined to true
134+
expect(mockIdentifyUserOpportunities).toHaveBeenCalledTimes(1);
135+
});
136+
});

src/cio.ts

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -167,15 +167,37 @@ export const identifyUserOpportunities = async ({
167167
if (!isProd) {
168168
return;
169169
}
170-
const opportunities = await con.getRepository(OpportunityMatch).find({
170+
171+
// Get all pending opportunity matches for the user
172+
const matches = await con.getRepository(OpportunityMatch).find({
171173
where: {
172174
userId,
173175
status: OpportunityMatchStatus.Pending,
174176
},
175-
select: ['opportunityId'],
177+
relations: ['opportunity'],
176178
order: { createdAt: 'ASC' },
177179
});
178-
const ids = opportunities.map((opportunity) => opportunity.opportunityId);
180+
181+
const opportunitiesWithReminders = (
182+
await Promise.all(
183+
matches.map(async (match) => {
184+
const opportunity = await match.opportunity;
185+
return {
186+
match,
187+
opportunity,
188+
hasReminders: opportunity?.flags?.reminders === true,
189+
};
190+
}),
191+
)
192+
)
193+
.filter((item) => item.hasReminders)
194+
.map((item) => ({
195+
id: item.match.opportunityId,
196+
title: item.opportunity?.title || '',
197+
}));
198+
199+
const ids = opportunitiesWithReminders.map((opp) => opp.id);
200+
179201
try {
180202
await cio.identify(userId, {
181203
opportunities: ids?.length > 0 ? ids : null,

src/common/paddle/recruiter/processing.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,8 @@ export const createOpportunitySubscription = async ({
141141
flags: updateFlagsStatement<OpportunityJob>({
142142
batchSize: priceCustomData.batch_size,
143143
plan: price.id,
144+
reminders: priceCustomData.reminders,
145+
showSlack: priceCustomData.show_slack,
144146
}),
145147
},
146148
);

src/common/paddle/recruiter/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,6 @@ export const recruiterPaddleCustomDataSchema = z.object({
77

88
export const recruiterPaddlePricingCustomDataSchema = z.object({
99
batch_size: z.coerce.number().nonnegative().max(10_000),
10+
reminders: z.boolean(),
11+
show_slack: z.boolean(),
1012
});

src/common/typedPubsub.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,11 @@ export type PubSubSchema = {
202202
opportunityId: string;
203203
title: string;
204204
};
205+
'api.v1.opportunity-flags-change': {
206+
opportunityId: string;
207+
before: string | null;
208+
after: string | null;
209+
};
205210
'gondul.v1.candidate-opportunity-match': MatchedCandidate;
206211
'api.v1.candidate-preference-updated': CandidatePreferenceUpdated;
207212
'api.v1.delayed-notification-reminder': z.infer<typeof entityReminderSchema>;

src/entity/opportunities/Opportunity.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ export type OpportunityFlags = Partial<{
3131
};
3232
batchSize: number;
3333
plan: string;
34+
reminders: boolean;
35+
showSlack: boolean;
3436
}>;
3537

3638
export type OpportunityFlagsPublic = Pick<

0 commit comments

Comments
 (0)