Skip to content
2 changes: 2 additions & 0 deletions .infra/Pulumi.prod.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,8 @@ config:
mapboxAccessToken:
secure: AAABAGQ/EX6kte5JXzyFrBmYlob0zx+IyG7RIbbcpySfexg2C6aqhoAJQ+aaXCw9UaVXMbMlncgnVvCWnhmR361glY6aDeNfUFXEBYARRJRnZJjXWPXw6KJEWinueHakjMeTpTelCQ2bCBXS0TzEtEE/n0HDqynumezR7A==
mapboxGeocodingUrl: https://api.mapbox.com/search/geocode/v6/forward
slackBotToken:
secure: AAABAH+UKbv4/Uoc9jYySYeAr7m+W7OCm/kQa9/3LCrKURh3TcPqgNPqF1ugLg31AAfsT4qVafpb0jiZm+ZCfDTYzrCfPmebxLjV0AAkHAy3kHgLK1v6YNGH
api:k8s:
host: subs.daily.dev
namespace: daily
Expand Down
4 changes: 3 additions & 1 deletion __tests__/integrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,9 @@ jest.mock('@slack/web-api', () => ({
}),
},
chat: {
postMessage: slackPostMessage,
get postMessage() {
return slackPostMessage;
},
},
};
},
Expand Down
181 changes: 180 additions & 1 deletion __tests__/schema/opportunity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,10 @@ import {
opportunityFeedbackQuestionsFixture,
organizationsFixture,
} from '../fixture/opportunity';
import { OpportunityUser } from '../../src/entity/opportunities/user';
import {
OpportunityUser,
OpportunityUserRecruiter,
} from '../../src/entity/opportunities/user';
import {
OpportunityMatchStatus,
OpportunityUserType,
Expand Down Expand Up @@ -75,6 +78,28 @@ import { OpportunityJob } from '../../src/entity/opportunities/OpportunityJob';
import * as brokkrCommon from '../../src/common/brokkr';
import { randomUUID } from 'node:crypto';

// Mock Slack WebClient
const mockConversationsCreate = jest.fn();
const mockConversationsInviteShared = jest.fn();
const mockConversationsJoin = jest.fn();

jest.mock('@slack/web-api', () => ({
...(jest.requireActual('@slack/web-api') as Record<string, unknown>),
WebClient: jest.fn().mockImplementation(() => ({
conversations: {
get create() {
return mockConversationsCreate;
},
get inviteShared() {
return mockConversationsInviteShared;
},
get join() {
return mockConversationsJoin;
},
},
})),
}));

const deleteFileFromBucket = jest.spyOn(googleCloud, 'deleteFileFromBucket');
const uploadEmploymentAgreementFromBuffer = jest.spyOn(
googleCloud,
Expand Down Expand Up @@ -5390,3 +5415,157 @@ describe('mutation parseOpportunity', () => {
);
});
});

describe('mutation createSharedSlackChannel', () => {
const MUTATION = /* GraphQL */ `
mutation CreateSharedSlackChannel($email: String!, $channelName: String!) {
createSharedSlackChannel(email: $email, channelName: $channelName) {
_
}
}
`;

beforeEach(() => {
// Reset all mocks before each test
mockConversationsCreate.mockReset();
mockConversationsInviteShared.mockReset();
});

it('should require authentication', async () => {
await testMutationErrorCode(
client,
{
mutation: MUTATION,
variables: {
email: '[email protected]',
channelName: 'test-channel',
},
},
'UNAUTHENTICATED',
);
});

it('should forbid non-recruiters from creating slack channels', async () => {
loggedUser = '5'; // User 5 is not a recruiter in fixtures

await testMutationErrorCode(
client,
{
mutation: MUTATION,
variables: {
email: '[email protected]',
channelName: 'test-channel',
},
},
'FORBIDDEN',
);
});

it('should create slack channel and invite user successfully', async () => {
loggedUser = '1';

// Create a recruiter record for the logged-in user
await con.getRepository(OpportunityUserRecruiter).save({
opportunityId: '550e8400-e29b-41d4-a716-446655440001',
userId: '1',
type: OpportunityUserType.Recruiter,
});

// Mock successful Slack API responses
mockConversationsCreate.mockResolvedValue({
ok: true,
channel: {
id: 'C1234567890',
name: 'test-channel',
},
});

mockConversationsInviteShared.mockResolvedValue({
ok: true,
});

const res = await client.mutate(MUTATION, {
variables: {
email: '[email protected]',
channelName: 'test-channel',
},
});

expect(res.errors).toBeFalsy();
expect(res.data.createSharedSlackChannel).toEqual({ _: true });

// Verify Slack API calls were made
expect(mockConversationsCreate).toHaveBeenCalledWith({
name: 'test-channel',
is_private: false,
});
expect(mockConversationsInviteShared).toHaveBeenCalledWith({
channel: 'C1234567890',
emails: ['[email protected]'],
external_limited: true,
});
});

it('should handle slack channel creation failure', async () => {
loggedUser = '1';

// Create a recruiter record for the logged-in user
await con.getRepository(OpportunityUserRecruiter).save({
opportunityId: '550e8400-e29b-41d4-a716-446655440001',
userId: '1',
type: OpportunityUserType.Recruiter,
});

// Mock failed channel creation (no channel in response)
mockConversationsCreate.mockResolvedValue({
ok: false,
error: 'name_taken',
channel: undefined,
});

const res = await client.mutate(MUTATION, {
variables: {
email: '[email protected]',
channelName: 'existing-channel',
},
});

expect(res.errors).toBeFalsy();
expect(res.data.createSharedSlackChannel).toEqual({ _: false });

// Should not proceed to invite if channel creation fails
expect(mockConversationsInviteShared).not.toHaveBeenCalled();
});

it('should handle invitation failure', async () => {
loggedUser = '1';

// Create a recruiter record for the logged-in user
await con.getRepository(OpportunityUserRecruiter).save({
opportunityId: '550e8400-e29b-41d4-a716-446655440001',
userId: '1',
type: OpportunityUserType.Recruiter,
});

mockConversationsCreate.mockResolvedValue({
ok: true,
channel: {
id: 'C1234567890',
name: 'test-channel',
},
});

mockConversationsInviteShared.mockRejectedValue(
new Error('Failed to invite user'),
);

const res = await client.mutate(MUTATION, {
variables: {
email: '[email protected]',
channelName: 'test-channel',
},
});

expect(res.errors).toBeTruthy();
});
});
8 changes: 6 additions & 2 deletions __tests__/workers/postAddedSlackChannelSend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,14 @@ jest.mock('@slack/web-api', () => ({
WebClient: function () {
return {
conversations: {
join: conversationsJoin,
get join() {
return conversationsJoin;
},
},
chat: {
postMessage: chatPostMessage,
get postMessage() {
return chatPostMessage;
},
},
};
},
Expand Down
12 changes: 9 additions & 3 deletions __tests__/workers/postAddedSlackChannelSendBrief.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,11 +47,17 @@ jest.mock('@slack/web-api', () => ({
WebClient: function () {
return {
conversations: {
join: conversationsJoin,
get join() {
return conversationsJoin;
},
},
chat: {
postMessage: chatPostMessage,
scheduleMessage: schedulePostMessage,
get postMessage() {
return chatPostMessage;
},
get scheduleMessage() {
return schedulePostMessage;
},
},
};
},
Expand Down
12 changes: 12 additions & 0 deletions src/common/schema/opportunities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,3 +240,15 @@ export const parseOpportunitySchema = z
error: 'Only one of url or file can be provided.',
},
);

export const createSharedSlackChannelSchema = z.object({
email: z.string().email('Email must be a valid email address'),
channelName: z
.string()
.min(1, 'Channel name is required')
.max(80, 'Channel name must be 80 characters or less')
.regex(
/^[a-z0-9-_]+$/,
'Channel name can only contain lowercase letters, numbers, hyphens, and underscores',
),
});
71 changes: 71 additions & 0 deletions src/common/slack.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { IncomingWebhook } from '@slack/webhook';
import { WebClient } from '@slack/web-api';
import type {
ConversationsCreateResponse,
ConversationsInviteSharedResponse,
} from '@slack/web-api';
import { Post, Comment, User, Source, type Campaign } from '../entity';
import { getDiscussionLink, getSourceLink } from './links';
import { NotFoundError } from '../errors';
Expand All @@ -10,6 +15,11 @@ import { PropsParameters } from '../types';
import { getAbsoluteDifferenceInDays } from './users';
import { concatTextToNewline } from './utils';
import { capitalize } from 'lodash';
import {
GarmrService,
IGarmrService,
GarmrNoopService,
} from '../integrations/garmr';

const nullWebhook = { send: (): Promise<void> => Promise.resolve() };
export const webhooks = Object.freeze({
Expand All @@ -33,6 +43,67 @@ export const webhooks = Object.freeze({
: nullWebhook,
});

export class SlackClient {
private readonly client: WebClient;
public readonly garmr: IGarmrService;

constructor(
token: string,
options?: {
garmr?: IGarmrService;
},
) {
this.client = new WebClient(token);
this.garmr = options?.garmr || new GarmrNoopService();
}

async createConversation(
name: string,
isPrivate: boolean = false,
): Promise<ConversationsCreateResponse> {
return this.garmr.execute(async () =>
this.client.conversations.create({
name,
is_private: isPrivate,
}),
);
}

async inviteSharedToConversation(
channel: string,
emails: string[],
externalLimited: boolean = true,
): Promise<ConversationsInviteSharedResponse> {
return this.garmr.execute(async () =>
this.client.conversations.inviteShared({
channel,
emails,
external_limited: externalLimited,
}),
);
}
}

// Configure Garmr service for Slack
const garmrSlackService = new GarmrService({
service: 'slack',
breakerOpts: {
halfOpenAfter: 5 * 1000,
threshold: 0.1,
duration: 10 * 1000,
},
retryOpts: {
maxAttempts: 2,
},
});

export const slackClient = new SlackClient(
process.env.SLACK_BOT_TOKEN as string,
{
garmr: garmrSlackService,
},
);

interface NotifyBoostedProps {
mdLink: string;
campaign: Pick<
Expand Down
Loading
Loading