Skip to content

Commit fbbc94d

Browse files
committed
bolt
1 parent 7849791 commit fbbc94d

11 files changed

Lines changed: 537 additions & 191 deletions

File tree

src/backend/index.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import express from 'express';
1+
import express, { Router } from 'express';
22
import cors from 'cors';
33
import cookieParser from 'cookie-parser';
44
import { getUserAndOrganization, prodHeaders, requireJwtDev, requireJwtProd } from './src/utils/auth.utils';
@@ -17,7 +17,7 @@ import wbsElementTemplatesRouter from './src/routes/wbs-element-templates.routes
1717
import carsRouter from './src/routes/cars.routes';
1818
import organizationRouter from './src/routes/organizations.routes';
1919
import recruitmentRouter from './src/routes/recruitment.routes';
20-
import { slackEvents } from './src/routes/slack.routes';
20+
import { receiver } from './src/integrations/slack';
2121
import announcementsRouter from './src/routes/announcements.routes';
2222
import onboardingRouter from './src/routes/onboarding.routes';
2323
import popUpsRouter from './src/routes/pop-up.routes';
@@ -48,9 +48,11 @@ const options: cors.CorsOptions = {
4848
allowedHeaders
4949
};
5050

51-
// so we can listen to slack messages
52-
// NOTE: must be done before using json
53-
app.use('/slack', slackEvents.requestListener());
51+
// Mount Slack Bolt receiver BEFORE other middleware to handle raw body parsing
52+
// Bolt's receiver handles its own body parsing and request verification
53+
// The receiver is configured to handle requests at /slack/events
54+
app.use(receiver.router as unknown as Router);
55+
console.log('Registered Slack Bolt receiver at /slack/events');
5456

5557
// so that we can use cookies and json
5658
app.use(cookieParser());

src/backend/package.json

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,7 @@
1010
},
1111
"dependencies": {
1212
"@prisma/client": "^6.2.1",
13-
"@slack/events-api": "^3.0.1",
14-
"@slack/web-api": "^7.8.0",
13+
"@slack/bolt": "^3.22.0",
1514
"@types/concat-stream": "^2.0.0",
1615
"@types/cookie-parser": "^1.4.3",
1716
"@types/cors": "^2.8.12",

src/backend/src/controllers/slack.controllers.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,41 @@ export default class SlackController {
1515
console.log(error);
1616
}
1717
}
18+
19+
static async handleSaboSubmittedAction(body: any) {
20+
try {
21+
// Extract action details from Bolt's BlockAction payload
22+
const [action] = body.actions;
23+
24+
if (action.type !== 'button') {
25+
// ignore non-button actions for sab submission confirmation
26+
return;
27+
}
28+
29+
const payload = {
30+
type: body.type,
31+
user: {
32+
id: body.user.id,
33+
username: body.user.username,
34+
name: body.user.name
35+
},
36+
actions: [
37+
{
38+
action_id: action.action_id,
39+
value: action.value || '',
40+
type: action.type
41+
}
42+
],
43+
response_url: body.response_url
44+
};
45+
46+
// Handle the action using existing service
47+
await SlackServices.handleSaboSubmittedAction(payload);
48+
} catch (error: unknown) {
49+
console.error('Error handling Slack interactive action:', error);
50+
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
51+
console.error('Error details:', errorMessage);
52+
throw error; // Re-throw to be handled by Bolt's error handler
53+
}
54+
}
1855
}

src/backend/src/integrations/slack.ts

Lines changed: 70 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,19 @@
1-
import { ChatPostMessageResponse, WebClient } from '@slack/web-api';
1+
import { App, ExpressReceiver } from '@slack/bolt';
22
import { HttpException } from '../utils/errors.utils';
33

4-
const slack = new WebClient(process.env.SLACK_BOT_TOKEN);
4+
const receiver = new ExpressReceiver({
5+
signingSecret: process.env.SLACK_SIGNING_SECRET || '',
6+
endpoints: '/slack/events'
7+
});
8+
9+
// Initialize the Bolt app
10+
const slackApp = new App({
11+
token: process.env.SLACK_BOT_TOKEN,
12+
receiver
13+
});
14+
15+
// Get the WebClient from the Bolt app
16+
const slack = slackApp.client;
517

618
/**
719
* Send a slack message
@@ -18,8 +30,7 @@ export const sendMessage = async (slackId: string, message: string, link?: strin
1830
const block = generateSlackTextBlock(message, link, linkButtonText);
1931

2032
try {
21-
const response: ChatPostMessageResponse = await slack.chat.postMessage({
22-
token: SLACK_BOT_TOKEN,
33+
const response = await slack.chat.postMessage({
2334
channel: slackId,
2435
text: message,
2536
blocks: [block],
@@ -54,7 +65,6 @@ export const replyToMessageInThread = async (
5465

5566
try {
5667
await slack.chat.postMessage({
57-
token: SLACK_BOT_TOKEN,
5868
channel: slackId,
5969
thread_ts: parentTimestamp,
6070
text: message,
@@ -87,7 +97,6 @@ export const editMessage = async (
8797

8898
try {
8999
await slack.chat.update({
90-
token: SLACK_BOT_TOKEN,
91100
channel: slackId,
92101
ts: timestamp,
93102
text: message,
@@ -110,7 +119,6 @@ export const reactToMessage = async (slackId: string, parentTimestamp: string, e
110119

111120
try {
112121
await slack.reactions.add({
113-
token: SLACK_BOT_TOKEN,
114122
channel: slackId,
115123
timestamp: parentTimestamp,
116124
name: emoji
@@ -230,4 +238,59 @@ export const getWorkspaceId = async () => {
230238
}
231239
};
232240

241+
export async function sendEphemeralConfirmation(
242+
channelId: string,
243+
threadTs: string,
244+
userId: string,
245+
reimbursementRequestId: string
246+
) {
247+
try {
248+
await slack.chat.postEphemeral({
249+
channel: channelId,
250+
user: userId,
251+
thread_ts: threadTs,
252+
text: 'Approve the request on concur and then click the button below to mark it as submitted on Finishline.',
253+
blocks: [
254+
{
255+
type: 'section',
256+
text: {
257+
type: 'mrkdwn',
258+
text: 'Approve the request on concur and then click the button below to mark it as submitted on Finishline.'
259+
}
260+
},
261+
{
262+
type: 'section',
263+
text: {
264+
type: 'mrkdwn',
265+
text: '<https://us2.concursolutions.com/home|*Click here to go to concur*>'
266+
}
267+
},
268+
{
269+
type: 'actions',
270+
elements: [
271+
{
272+
type: 'button',
273+
text: {
274+
type: 'plain_text',
275+
text: "✓ I've approved the request on Concur"
276+
},
277+
style: 'primary',
278+
action_id: 'sabo_submitted_confirmation',
279+
value: JSON.stringify({
280+
reimbursementRequestId
281+
})
282+
}
283+
]
284+
}
285+
]
286+
});
287+
} catch (err: unknown) {
288+
if (err instanceof Error) {
289+
throw new HttpException(500, `Failed to send slack notifications: ${err.message}`);
290+
}
291+
}
292+
}
293+
294+
// Export the slack client, bolt app, and receiver for any direct usage if needed
295+
export { slack, slackApp, receiver };
233296
export default slack;
Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,29 @@
1-
import { createEventAdapter } from '@slack/events-api';
1+
import { slackApp } from '../integrations/slack';
22
import SlackController from '../controllers/slack.controllers';
33

4-
export const slackEvents = createEventAdapter(process.env.SLACK_SIGNING_SECRET || '');
4+
// Register message event listener
5+
slackApp.message(async ({ message, logger }: any) => {
6+
try {
7+
await SlackController.processMessageEvent(message);
8+
} catch (error) {
9+
logger.error('Error processing message event:', error);
10+
console.error(error);
11+
}
12+
});
513

6-
slackEvents.on('message', SlackController.processMessageEvent);
14+
// Register interactive action handler for SABO submission confirmation
15+
slackApp.action('sabo_submitted_confirmation', async ({ ack, body, logger }: any) => {
16+
await ack();
717

8-
slackEvents.on('error', console.log);
18+
try {
19+
await SlackController.handleSaboSubmittedAction(body);
20+
} catch (error) {
21+
logger.error('Error handling sabo_submitted_confirmation action:', error);
22+
console.error(error);
23+
}
24+
});
25+
26+
// Error handler
27+
slackApp.error(async (error: Error) => {
28+
console.error('Slack app error:', error);
29+
});

src/backend/src/services/notifications.services.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,13 @@ export default class NotificationsService {
265265
type: Reimbursement_Status_Type.SABO_SUBMITTED
266266
}
267267
}
268+
},
269+
{
270+
reimbursementStatuses: {
271+
none: {
272+
type: Reimbursement_Status_Type.DENIED
273+
}
274+
}
268275
}
269276
]
270277
},

src/backend/src/services/reimbursement-requests.services.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -418,12 +418,26 @@ export default class ReimbursementRequestService {
418418
totalCost,
419419
accountCodeId: accountCode.accountCodeId,
420420
vendorId: vendor.vendorId
421+
},
422+
include: {
423+
notificationSlackThreads: true
421424
}
422425
});
423426

424427
//set any deleted receipts with a dateDeleted
425428
await removeDeletedReceiptPictures(receiptPictures, oldReimbursementRequest.receiptPictures || [], submitter);
426429

430+
try {
431+
await sendPendingSaboSubmissionNotification(
432+
updatedReimbursementRequest.notificationSlackThreads,
433+
submitter.userId,
434+
updatedReimbursementRequest.recipientId,
435+
updatedReimbursementRequest.reimbursementRequestId
436+
);
437+
} catch (e: unknown) {
438+
console.error('Error sending pending SABO submission notification:', e);
439+
}
440+
427441
return updatedReimbursementRequest;
428442
}
429443

@@ -1292,7 +1306,8 @@ export default class ReimbursementRequestService {
12921306
await sendPendingSaboSubmissionNotification(
12931307
reimbursementRequest.notificationSlackThreads,
12941308
submitter.userId,
1295-
reimbursementRequest.recipientId
1309+
reimbursementRequest.recipientId,
1310+
reimbursementRequest.reimbursementRequestId
12961311
);
12971312
} catch (e: unknown) {
12981313
console.error('Error sending pending SABO submission notification:', e);

src/backend/src/services/slack.services.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,83 @@ export interface SlackRichTextBlock {
6666
usergroup_id?: string;
6767
}
6868

69+
/**
70+
* Represents a Slack interactive payload from a button click
71+
*/
72+
export interface SlackInteractivePayload {
73+
type: string;
74+
user: {
75+
id: string;
76+
username: string;
77+
name: string;
78+
};
79+
actions: Array<{
80+
action_id: string;
81+
value: string;
82+
type: string;
83+
}>;
84+
response_url: string;
85+
}
86+
6987
export default class SlackServices {
88+
/**
89+
* Handles the Slack button click for marking a reimbursement request as SABO submitted
90+
* @param payload the Slack interactive payload
91+
* @param organizationId the organization ID
92+
*/
93+
static async handleSaboSubmittedAction(payload: SlackInteractivePayload): Promise<void> {
94+
const [action] = payload.actions;
95+
if (action.action_id !== 'sabo_submitted_confirmation') {
96+
console.log('Ignoring action with id:', action.action_id);
97+
return;
98+
}
99+
100+
console.log('Processing sabo_submitted_confirmation action');
101+
const { reimbursementRequestId } = JSON.parse(action.value);
102+
const slackUserId = payload.user.id;
103+
104+
console.log('Looking up user with slack ID:', slackUserId);
105+
console.log('Reimbursement Request ID:', reimbursementRequestId);
106+
107+
// Find the user by their slack ID
108+
const user = await prisma.user.findFirst({
109+
where: {
110+
userSettings: {
111+
slackId: slackUserId
112+
}
113+
}
114+
});
115+
116+
if (!user) {
117+
console.error('User not found for slack ID:', slackUserId);
118+
throw new NotFoundException('User', slackUserId);
119+
}
120+
121+
const reimbursementRequest = await prisma.reimbursement_Request.findUnique({
122+
where: {
123+
reimbursementRequestId
124+
},
125+
include: {
126+
organization: true
127+
}
128+
});
129+
130+
if (!reimbursementRequest) {
131+
throw new NotFoundException('Reimbursement Request', reimbursementRequestId);
132+
}
133+
134+
135+
// Import the service dynamically to avoid circular dependencies
136+
const ReimbursementRequestService = (await import('./reimbursement-requests.services')).default;
137+
138+
// Call the service function to mark as SABO submitted
139+
await ReimbursementRequestService.markReimbursementRequestAsSaboSubmitted(
140+
reimbursementRequestId,
141+
user,
142+
reimbursementRequest.organization
143+
);
144+
}
145+
70146
/**
71147
* Given a slack event representing a message in a channel,
72148
* make the appropriate announcement change in prisma.

src/backend/src/utils/auth.utils.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ export const requireJwtProd = (req: Request, res: Response, next: NextFunction)
3333
req.path === '/users/auth/login' || // logins dont have cookies yet
3434
req.path === '/' || // base route is available so aws can listen and check the health
3535
req.method === 'OPTIONS' || // this is a pre-flight request and those don't send cookies
36-
req.path === '/slack' // slack http endpoint is only used from slack api
36+
req.path.startsWith('/slack') // slack endpoints (events and interactions) are only used from slack api
3737
) {
3838
return next();
3939
} else if (
@@ -65,7 +65,7 @@ export const requireJwtDev = (req: Request, res: Response, next: NextFunction) =
6565
req.path === '/' || // base route is available so aws can listen and check the health
6666
req.method === 'OPTIONS' || // this is a pre-flight request and those don't send cookies
6767
req.path === '/users' || // dev login needs the list of users to log in
68-
req.path === '/slack' // slack http endpoint is only used from slack api
68+
req.path.startsWith('/slack') // slack endpoints (events and interactions) are only used from slack api
6969
) {
7070
next();
7171
} else if (
@@ -185,7 +185,7 @@ export const getUserAndOrganization = async (req: Request, res: Response, next:
185185
req.path === '/' || // base route is available so aws can listen and check the health
186186
req.method === 'OPTIONS' || // this is a pre-flight request and those don't send cookies
187187
req.path === '/users' || // dev login needs the list of users to log in
188-
req.path === '/slack' || // slack http endpoint is only used from slack api
188+
req.path.startsWith('/slack') || // slack endpoints (events and interactions) are only used from slack api
189189
req.path.startsWith('/notifications') // Notifications route has its own auth, only called from gh
190190
) {
191191
return next();

0 commit comments

Comments
 (0)