diff --git a/.infra/common.ts b/.infra/common.ts index b4bdeeb3c6..fd4f5f2dfa 100644 --- a/.infra/common.ts +++ b/.infra/common.ts @@ -433,6 +433,10 @@ export const workers: Worker[] = [ topic: 'api.v1.candidate-accepted-opportunity', subscription: 'api.recruiter-new-candidate-notification', }, + { + topic: 'api.v1.candidate-review-opportunity', + subscription: 'api.candidate-review-opportunity-slack', + }, { topic: 'api.v1.opportunity-went-live', subscription: 'api.recruiter-opportunity-live-notification', diff --git a/AGENTS.md b/AGENTS.md index 4707a4df4d..cfadaf021d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -117,6 +117,20 @@ The migration generator compares entities against the local database schema. Ens - `.infra/common.ts` - Worker subscription definitions - `.infra/index.ts` - Main Pulumi deployment configuration +## Code Style Preferences + +**Keep implementations concise:** +- Prefer short, readable implementations over verbose ones +- Avoid excessive logging - errors will propagate naturally +- Use early returns instead of nested conditionals +- Extract repeated patterns into small inline helpers (e.g., `const respond = (text) => ...`) +- Combine related checks (e.g., `if (!match || match.status !== X)` instead of separate blocks) + +**PubSub topics should be general-purpose:** +- Topics should contain only essential identifiers (e.g., `{ opportunityId, userId }`) +- Subscribers fetch their own data - don't optimize topic payloads for specific consumers +- This allows multiple subscribers with different data needs + ## Best Practices & Lessons Learned **Avoiding Code Duplication:** diff --git a/__tests__/integrations/slack.ts b/__tests__/integrations/slack.ts index 9d16d687b0..2cf29f2979 100644 --- a/__tests__/integrations/slack.ts +++ b/__tests__/integrations/slack.ts @@ -1,7 +1,7 @@ import appFunc from '../../src'; import { FastifyInstance } from 'fastify'; import { authorizeRequest, saveFixtures } from '../helpers'; -import { User } from '../../src/entity'; +import { Organization, User } from '../../src/entity'; import { usersFixture } from '../fixture'; import { DataSource } from 'typeorm'; import createOrGetConnection from '../../src/db'; @@ -11,11 +11,20 @@ import { UserIntegration, UserIntegrationType, } from '../../src/entity/UserIntegration'; -import { SlackEvent } from '../../src/common'; +import { SlackEvent, verifySlackSignature } from '../../src/common'; import { AnalyticsEventName, sendAnalyticsEvent, } from '../../src/integrations/analytics'; +import { DatasetLocation } from '../../src/entity/dataset/DatasetLocation'; +import { OpportunityJob } from '../../src/entity/opportunities/OpportunityJob'; +import { + datasetLocationsFixture, + opportunitiesFixture, + organizationsFixture, +} from '../fixture/opportunity'; +import { OpportunityMatchStatus } from '../../src/entity/opportunities/types'; +import { OpportunityMatch } from '../../src/entity/OpportunityMatch'; jest.mock('../../src/integrations/analytics', () => ({ ...(jest.requireActual('../../src/integrations/analytics') as Record< @@ -25,6 +34,20 @@ jest.mock('../../src/integrations/analytics', () => ({ sendAnalyticsEvent: jest.fn(), })); +const actualVerifySlackSignature = + jest.requireActual( + '../../src/common', + ).verifySlackSignature; + +jest.mock('../../src/common', () => ({ + ...(jest.requireActual('../../src/common') as Record), + verifySlackSignature: jest.fn(), +})); + +const mockVerifySlackSignature = verifySlackSignature as jest.MockedFunction< + typeof verifySlackSignature +>; + let app: FastifyInstance; let con: DataSource; @@ -39,6 +62,8 @@ afterAll(() => app.close()); beforeEach(async () => { nock.cleanAll(); jest.resetAllMocks(); + // Use the actual implementation by default (for events tests) + mockVerifySlackSignature.mockImplementation(actualVerifySlackSignature); await saveFixtures(con, User, usersFixture); }); @@ -482,3 +507,129 @@ describe('POST /integrations/slack/events', () => { expect(await teamIntegrationsQuery.getCount()).toBe(1); }); }); + +describe('POST /integrations/slack/interactions', () => { + const createInteractionPayload = ( + actionId: string, + opportunityId: string, + userId: string, + ) => + `payload=${encodeURIComponent( + JSON.stringify({ + type: 'block_actions', + actions: [ + { + action_id: actionId, + value: JSON.stringify({ opportunityId, userId }), + }, + ], + response_url: 'https://hooks.slack.com/actions/test', + user: { id: 'U123', username: 'testuser' }, + }), + )}`; + + beforeEach(async () => { + await saveFixtures(con, DatasetLocation, datasetLocationsFixture); + await saveFixtures(con, Organization, organizationsFixture); + await saveFixtures(con, OpportunityJob, opportunitiesFixture); + mockVerifySlackSignature.mockReturnValue(true); + }); + + it('should return 403 when signature is invalid', async () => { + mockVerifySlackSignature.mockReturnValue(false); + + const { body } = await request(app.server) + .post('/integrations/slack/interactions') + .set('Content-Type', 'application/x-www-form-urlencoded') + .send(createInteractionPayload('candidate_review_accept', 'opp1', 'u1')) + .expect(403); + + expect(body).toEqual({ error: 'invalid signature' }); + }); + + it('should accept candidate and update match status', async () => { + const match = { + opportunityId: '550e8400-e29b-41d4-a716-446655440001', + userId: '1', + status: OpportunityMatchStatus.CandidateReview, + createdAt: new Date(), + updatedAt: new Date(), + }; + await saveFixtures(con, OpportunityMatch, [match]); + + nock('https://hooks.slack.com').post('/actions/test').reply(200); + + await request(app.server) + .post('/integrations/slack/interactions') + .set('Content-Type', 'application/x-www-form-urlencoded') + .set('x-slack-request-timestamp', '1722461509') + .set( + 'x-slack-signature', + 'v0=test', // Signature validation is mocked in test env + ) + .send( + createInteractionPayload( + 'candidate_review_accept', + match.opportunityId, + match.userId, + ), + ) + .expect(200); + + const updatedMatch = await con.getRepository(OpportunityMatch).findOneBy({ + opportunityId: match.opportunityId, + userId: match.userId, + }); + expect(updatedMatch?.status).toBe(OpportunityMatchStatus.CandidateAccepted); + }); + + it('should reject candidate and update match status', async () => { + const match = { + opportunityId: '550e8400-e29b-41d4-a716-446655440001', + userId: '1', + status: OpportunityMatchStatus.CandidateReview, + createdAt: new Date(), + updatedAt: new Date(), + }; + await saveFixtures(con, OpportunityMatch, [match]); + + nock('https://hooks.slack.com').post('/actions/test').reply(200); + + await request(app.server) + .post('/integrations/slack/interactions') + .set('Content-Type', 'application/x-www-form-urlencoded') + .set('x-slack-request-timestamp', '1722461509') + .set('x-slack-signature', 'v0=test') + .send( + createInteractionPayload( + 'candidate_review_reject', + match.opportunityId, + match.userId, + ), + ) + .expect(200); + + const updatedMatch = await con.getRepository(OpportunityMatch).findOneBy({ + opportunityId: match.opportunityId, + userId: match.userId, + }); + expect(updatedMatch?.status).toBe(OpportunityMatchStatus.RecruiterRejected); + }); + + it('should return 200 for unknown action types', async () => { + await request(app.server) + .post('/integrations/slack/interactions') + .set('Content-Type', 'application/x-www-form-urlencoded') + .set('x-slack-request-timestamp', '1722461509') + .set('x-slack-signature', 'v0=test') + .send( + `payload=${encodeURIComponent( + JSON.stringify({ + type: 'block_actions', + actions: [{ action_id: 'unknown_action', value: '{}' }], + }), + )}`, + ) + .expect(200); + }); +}); diff --git a/__tests__/schema/opportunity.ts b/__tests__/schema/opportunity.ts index 72484419f2..6589e20e90 100644 --- a/__tests__/schema/opportunity.ts +++ b/__tests__/schema/opportunity.ts @@ -2477,7 +2477,7 @@ describe('mutation acceptOpportunityMatch', () => { await con.getRepository(OpportunityMatch).countBy({ opportunityId: '550e8400-e29b-41d4-a716-446655440001', userId: '1', - status: OpportunityMatchStatus.CandidateAccepted, + status: OpportunityMatchStatus.CandidateReview, }), ).toEqual(1); }); @@ -2553,7 +2553,7 @@ describe('mutation rejectOpportunityMatch', () => { ); }); - it('should accept opportunity match for authenticated user', async () => { + it('should reject opportunity match for authenticated user', async () => { loggedUser = '1'; expect( diff --git a/__tests__/workers/candidateReviewOpportunitySlack.ts b/__tests__/workers/candidateReviewOpportunitySlack.ts new file mode 100644 index 0000000000..41e48c4c25 --- /dev/null +++ b/__tests__/workers/candidateReviewOpportunitySlack.ts @@ -0,0 +1,55 @@ +import { expectSuccessfulTypedBackground, saveFixtures } from '../helpers'; +import worker from '../../src/workers/candidateReviewOpportunitySlack'; +import { OpportunityMatch } from '../../src/entity/OpportunityMatch'; +import { User } from '../../src/entity'; +import createOrGetConnection from '../../src/db'; +import { webhooks } from '../../src/common/slack'; +import { OpportunityMatchStatus } from '../../src/entity/opportunities/types'; +import { OpportunityJob } from '../../src/entity/opportunities/OpportunityJob'; +import { Organization } from '../../src/entity/Organization'; +import { + organizationsFixture, + opportunitiesFixture, + datasetLocationsFixture, +} from '../fixture/opportunity'; +import { usersFixture } from '../fixture/user'; +import { DatasetLocation } from '../../src/entity/dataset/DatasetLocation'; + +jest.spyOn(webhooks.recruiter, 'send').mockResolvedValue(undefined); + +beforeEach(async () => { + jest.clearAllMocks(); + const con = await createOrGetConnection(); + await saveFixtures(con, DatasetLocation, datasetLocationsFixture); + await saveFixtures(con, Organization, organizationsFixture); + await saveFixtures(con, OpportunityJob, opportunitiesFixture); + await saveFixtures(con, User, usersFixture); +}); + +describe('candidateReviewOpportunitySlack worker', () => { + it('should send slack notification with accept/reject buttons', async () => { + const con = await createOrGetConnection(); + await saveFixtures(con, OpportunityMatch, [ + { + opportunityId: '550e8400-e29b-41d4-a716-446655440001', + userId: '1', + status: OpportunityMatchStatus.CandidateReview, + screening: [{ screening: 'Favorite language?', answer: 'TypeScript' }], + applicationRank: { score: 85 }, + }, + ]); + + await expectSuccessfulTypedBackground(worker, { + opportunityId: '550e8400-e29b-41d4-a716-446655440001', + userId: '1', + }); + + expect(webhooks.recruiter.send).toHaveBeenCalledTimes(1); + const blocks = (webhooks.recruiter.send as jest.Mock).mock.calls[0][0] + .blocks; + const actions = blocks.find((b: { type: string }) => b.type === 'actions'); + expect(actions.elements).toHaveLength(2); + expect(actions.elements[0].action_id).toBe('candidate_review_accept'); + expect(actions.elements[1].action_id).toBe('candidate_review_reject'); + }); +}); diff --git a/src/common/opportunity/pubsub.ts b/src/common/opportunity/pubsub.ts index 11e522761d..e409296305 100644 --- a/src/common/opportunity/pubsub.ts +++ b/src/common/opportunity/pubsub.ts @@ -551,3 +551,17 @@ export const notifyCandidatePreferenceChange = async ({ ); } }; + +export const notifyOpportunityMatchCandidateReview = async ({ + logger, + data, +}: { + con: DataSource; + logger: FastifyBaseLogger; + data: ChangeObject; +}) => { + await triggerTypedEvent(logger, 'api.v1.candidate-review-opportunity', { + opportunityId: data.opportunityId, + userId: data.userId, + }); +}; diff --git a/src/common/schema/slack.ts b/src/common/schema/slack.ts new file mode 100644 index 0000000000..f867f67a3f --- /dev/null +++ b/src/common/schema/slack.ts @@ -0,0 +1,28 @@ +import { z } from 'zod'; + +export const slackOpportunityActionValueSchema = z.object({ + opportunityId: z.string(), + userId: z.string(), +}); + +export const slackOpportunityCandidateReviewPayloadSchema = z.object({ + type: z.literal('block_actions'), + actions: z + .array( + z.object({ + action_id: z.enum([ + 'candidate_review_accept', + 'candidate_review_reject', + ]), + value: z.string(), + }), + ) + .min(1), + response_url: z.string().url().optional(), + user: z + .object({ + id: z.string(), + username: z.string(), + }) + .optional(), +}); diff --git a/src/common/typedPubsub.ts b/src/common/typedPubsub.ts index b38daa5d1a..c859651c9e 100644 --- a/src/common/typedPubsub.ts +++ b/src/common/typedPubsub.ts @@ -191,6 +191,10 @@ export type PubSubSchema = { payload: ChangeObject; }; 'api.v1.candidate-accepted-opportunity': CandidateAcceptedOpportunityMessage; + 'api.v1.candidate-review-opportunity': { + opportunityId: string; + userId: string; + }; 'api.v1.opportunity-added': OpportunityMessage; 'api.v1.opportunity-updated': OpportunityMessage; 'api.v1.opportunity-in-review': { diff --git a/src/common/utils.ts b/src/common/utils.ts index fec9674d67..cfa7b1cb7c 100644 --- a/src/common/utils.ts +++ b/src/common/utils.ts @@ -321,6 +321,16 @@ export const textToSlug = (text: string): string => replacement: '-', }).substring(0, 100); +export const truncateText = ( + text: string | null | undefined, + maxLength = 500, +): string | null => + text + ? text.length > maxLength + ? `${text.slice(0, maxLength - 3)}...` + : text + : null; + export const updateRecruiterSubscriptionFlags = < Entity extends { recruiterSubscriptionFlags: object; diff --git a/src/entity/opportunities/types.ts b/src/entity/opportunities/types.ts index fc18f2e290..b6e51bac21 100644 --- a/src/entity/opportunities/types.ts +++ b/src/entity/opportunities/types.ts @@ -4,6 +4,7 @@ export enum OpportunityUserType { export enum OpportunityMatchStatus { Pending = 'pending', + CandidateReview = 'candidate_review', CandidateAccepted = 'candidate_accepted', CandidateRejected = 'candidate_rejected', CandidateTimeOut = 'candidate_time_out', diff --git a/src/routes/integrations/slack.ts b/src/routes/integrations/slack.ts index bebe11b833..243400d046 100644 --- a/src/routes/integrations/slack.ts +++ b/src/routes/integrations/slack.ts @@ -19,6 +19,12 @@ import { AnalyticsEventName, sendAnalyticsEvent, } from '../../integrations/analytics'; +import { OpportunityMatch } from '../../entity/OpportunityMatch'; +import { OpportunityMatchStatus } from '../../entity/opportunities/types'; +import { + slackOpportunityActionValueSchema, + slackOpportunityCandidateReviewPayloadSchema, +} from '../../common/schema/slack'; const redirectResponse = ({ res, @@ -293,4 +299,83 @@ export default async function (fastify: FastifyInstance): Promise { } }, }); + + // Handle Slack interactive component payloads (button clicks) + // Slack sends form-urlencoded data with a JSON payload + fastify.addContentTypeParser( + 'application/x-www-form-urlencoded', + { parseAs: 'string' }, + (_req, body, done) => done(null, body), + ); + + fastify.post<{ + Body: string; + Headers: { + 'x-slack-request-timestamp': string; + 'x-slack-signature': string; + }; + }>('/interactions', { + config: { rawBody: true }, + handler: async (req, res) => { + try { + if (!verifySlackSignature({ req })) { + return res.status(403).send({ error: 'invalid signature' }); + } + } catch { + return res.status(403).send({ error: 'invalid signature' }); + } + + try { + const payloadString = + typeof req.body === 'string' + ? new URLSearchParams(req.body).get('payload') + : (req.body as { payload?: string }).payload; + + const payload = slackOpportunityCandidateReviewPayloadSchema.parse( + JSON.parse(payloadString || '{}'), + ); + const action = payload.actions[0]; + const { opportunityId, userId } = + slackOpportunityActionValueSchema.parse(JSON.parse(action.value)); + + const con = await createOrGetConnection(); + const match = await con + .getRepository(OpportunityMatch) + .findOne({ where: { opportunityId, userId } }); + + const respond = (text: string) => + payload.response_url && + fetch(payload.response_url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ replace_original: true, text }), + }); + + if (!match || match.status !== OpportunityMatchStatus.CandidateReview) { + await respond( + match ? `Already processed: ${match.status}` : 'Match not found', + ); + return res.status(200).send(); + } + + const isAccept = action.action_id === 'candidate_review_accept'; + await con.getRepository(OpportunityMatch).update( + { opportunityId, userId }, + { + status: isAccept + ? OpportunityMatchStatus.CandidateAccepted + : OpportunityMatchStatus.RecruiterRejected, + }, + ); + + await respond( + `${isAccept ? ':white_check_mark: Accepted' : ':x: Rejected'} by @${payload.user?.username || 'unknown'}`, + ); + return res.status(200).send(); + } catch { + // Return 200 for invalid payloads to avoid Slack retries + return res.status(200).send(); + } + }, + }); } diff --git a/src/schema/opportunity.ts b/src/schema/opportunity.ts index ea7ae0a2e9..cee03bb161 100644 --- a/src/schema/opportunity.ts +++ b/src/schema/opportunity.ts @@ -1925,7 +1925,7 @@ export const resolvers: IResolvers = traceResolvers< await updateCandidateMatchStatus( id, ctx.userId, - OpportunityMatchStatus.CandidateAccepted, + OpportunityMatchStatus.CandidateReview, ctx, ); diff --git a/src/workers/candidateReviewOpportunitySlack.ts b/src/workers/candidateReviewOpportunitySlack.ts new file mode 100644 index 0000000000..577982a569 --- /dev/null +++ b/src/workers/candidateReviewOpportunitySlack.ts @@ -0,0 +1,165 @@ +import { TypedWorker } from './worker'; +import { truncateText, webhooks } from '../common'; +import { OpportunityMatch } from '../entity/OpportunityMatch'; +import { OpportunityJob } from '../entity/opportunities/OpportunityJob'; +import { UserCandidatePreference } from '../entity/user/UserCandidatePreference'; +import { UserCandidateKeyword } from '../entity/user/UserCandidateKeyword'; + +const worker: TypedWorker<'api.v1.candidate-review-opportunity'> = { + subscription: 'api.candidate-review-opportunity-slack', + handler: async ({ data }, con): Promise => { + if (process.env.NODE_ENV === 'development') return; + + const { opportunityId, userId } = data; + const match = await con.getRepository(OpportunityMatch).findOne({ + where: { opportunityId, userId }, + relations: ['opportunity', 'user'], + }); + if (!match) return; + + const [opportunity, user, pref, keywords] = await Promise.all([ + match.opportunity, + match.user, + con + .getRepository(UserCandidatePreference) + .findOne({ where: { userId }, relations: ['location'] }), + con + .getRepository(UserCandidateKeyword) + .find({ where: { userId }, select: ['keyword'] }), + ]); + + const org = + opportunity instanceof OpportunityJob + ? (await opportunity.organization)?.name + : null; + const loc = await pref?.location; + const location = [loc?.city, loc?.subdivision, loc?.country] + .filter(Boolean) + .join(', '); + const salary = pref?.salaryExpectation; + const matchScore = match.description?.matchScore; + const applicationScore = match.applicationRank?.score; + + await webhooks.recruiter.send({ + blocks: [ + { + type: 'header', + text: { + type: 'plain_text', + text: 'Candidate Ready for Internal Review', + emoji: true, + }, + }, + { + type: 'section', + fields: [ + { + type: 'mrkdwn', + text: `*Opportunity:*\n<${process.env.COMMENTS_PREFIX}/jobs/${opportunityId}|${opportunity?.title || opportunityId}>`, + }, + { type: 'mrkdwn', text: `*Organization:*\n${org || 'N/A'}` }, + ], + }, + { + type: 'section', + fields: [ + { + type: 'mrkdwn', + text: `*Candidate:*\n<${process.env.COMMENTS_PREFIX}/${user?.username || userId}|${user?.name || user?.username || 'Unknown'}>`, + }, + { + type: 'mrkdwn', + text: `*Match Score:*\n${matchScore != null ? `${Math.round(matchScore * 100)}%` : 'N/A'}`, + }, + ], + }, + { + type: 'section', + fields: [ + { + type: 'mrkdwn', + text: `*Application Score:*\n${applicationScore != null ? `${Math.round(applicationScore)}%` : 'N/A'}`, + }, + { + type: 'mrkdwn', + text: `*Salary:*\n${salary?.min ? `$${salary.min}+${salary.period ? `/${salary.period}` : ''}` : 'N/A'}`, + }, + ], + }, + { + type: 'section', + fields: [ + { type: 'mrkdwn', text: `*Location:*\n${location || 'N/A'}` }, + ], + }, + { + type: 'section', + text: { + type: 'mrkdwn', + text: `*Skills:*\n${keywords.map((k) => k.keyword).join(', ') || 'N/A'}`, + }, + }, + { + type: 'section', + text: { + type: 'mrkdwn', + text: `*CV Summary:*\n${truncateText(pref?.cvParsedMarkdown) || 'N/A'}`, + }, + }, + ...(match.applicationRank?.description + ? [ + { + type: 'section' as const, + text: { + type: 'mrkdwn' as const, + text: `*Application Summary:*\n${truncateText(match.applicationRank.description)}`, + }, + }, + ] + : []), + ...(match.screening?.length + ? [ + { + type: 'section' as const, + text: { + type: 'mrkdwn' as const, + text: `*Screening:*\n\`\`\`${truncateText( + JSON.stringify( + match.screening.map((s) => ({ + q: s.screening, + a: s.answer, + })), + null, + 2, + ), + )}\`\`\``, + }, + }, + ] + : []), + { type: 'divider' }, + { + type: 'actions', + elements: [ + { + type: 'button', + text: { type: 'plain_text', text: 'Accept', emoji: true }, + style: 'primary', + action_id: 'candidate_review_accept', + value: JSON.stringify({ opportunityId, userId }), + }, + { + type: 'button', + text: { type: 'plain_text', text: 'Reject', emoji: true }, + style: 'danger', + action_id: 'candidate_review_reject', + value: JSON.stringify({ opportunityId, userId }), + }, + ], + }, + ], + }); + }, +}; + +export default worker; diff --git a/src/workers/cdc/primary.ts b/src/workers/cdc/primary.ts index 58bb77420b..e519b37874 100644 --- a/src/workers/cdc/primary.ts +++ b/src/workers/cdc/primary.ts @@ -144,6 +144,7 @@ import { notifyCandidateOpportunityMatchRejected, notifyCandidatePreferenceChange, notifyOpportunityMatchAccepted, + notifyOpportunityMatchCandidateReview, notifyRecruiterCandidateMatchAccepted, notifyRecruiterCandidateMatchRejected, } from '../../common/opportunity/pubsub'; @@ -1308,6 +1309,16 @@ const onOpportunityMatchChange = async ( data: data.payload.after!, }); } + if ( + data.payload.after?.status === OpportunityMatchStatus.CandidateReview && + data?.payload.before?.status !== OpportunityMatchStatus.CandidateReview + ) { + await notifyOpportunityMatchCandidateReview({ + con, + logger, + data: data.payload.after!, + }); + } if ( data.payload.after?.status === OpportunityMatchStatus.RecruiterAccepted && data?.payload.before?.status !== OpportunityMatchStatus.RecruiterAccepted diff --git a/src/workers/index.ts b/src/workers/index.ts index 6eb1684f1a..70a496d107 100644 --- a/src/workers/index.ts +++ b/src/workers/index.ts @@ -73,6 +73,7 @@ import { storeCandidateApplicationScore } from './opportunity/storeCandidateAppl import { syncOpportunityRemindersCio } from './opportunity/syncOpportunityRemindersCio'; import { extractCVMarkdown } from './extractCVMarkdown'; import candidateAcceptedOpportunitySlack from './candidateAcceptedOpportunitySlack'; +import candidateReviewOpportunitySlack from './candidateReviewOpportunitySlack'; import recruiterRejectedCandidateMatchEmail from './recruiterRejectedCandidateMatchEmail'; import { opportunityPreviewResultWorker } from './opportunity/opportunityPreviewResult'; import opportunityInReviewSlack from './opportunityInReviewSlack'; @@ -152,6 +153,7 @@ export const typedWorkers: BaseTypedWorker[] = [ syncOpportunityRemindersCio, extractCVMarkdown, candidateAcceptedOpportunitySlack, + candidateReviewOpportunitySlack, recruiterRejectedCandidateMatchEmail, opportunityPreviewResultWorker, opportunityInReviewSlack,