Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 13 additions & 5 deletions src/backend/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import express from 'express';
import express, { Router } from 'express';
import cors from 'cors';
import cookieParser from 'cookie-parser';
import { getUserAndOrganization, prodHeaders, requireJwtDev, requireJwtProd } from './src/utils/auth.utils';
Expand All @@ -17,14 +17,16 @@ import wbsElementTemplatesRouter from './src/routes/wbs-element-templates.routes
import carsRouter from './src/routes/cars.routes';
import organizationRouter from './src/routes/organizations.routes';
import recruitmentRouter from './src/routes/recruitment.routes';
import { slackEvents } from './src/routes/slack.routes';
import { getReceiver } from './src/integrations/slack';
import announcementsRouter from './src/routes/announcements.routes';
import onboardingRouter from './src/routes/onboarding.routes';
import popUpsRouter from './src/routes/pop-up.routes';
import statisticsRouter from './src/routes/statistics.routes';
import retrospectiveRouter from './src/routes/retrospective.routes';
import partsRouter from './src/routes/parts.routes';
import financeRouter from './src/routes/finance.routes';
// Import slack routes (event listeners will be registered if Slack is configured)
import './src/routes/slack.routes';

const app = express();

Expand Down Expand Up @@ -61,9 +63,15 @@ const options: cors.CorsOptions = {
allowedHeaders
};

// so we can listen to slack messages
// NOTE: must be done before using json
app.use('/slack', slackEvents.requestListener());
// Mount Slack Bolt receiver BEFORE other middleware to handle raw body parsing
// Bolt's receiver handles its own body parsing and request verification
// The receiver is configured to handle requests at /slack/events
// Only mount if Slack is configured (when SLACK_BOT_TOKEN is set)
const receiver = getReceiver();
if (receiver) {
app.use(receiver.router as unknown as Router);
}

app.get('/health', (_req, res) => {
res.status(200).json({ status: 'healthy' });
});
Expand Down
3 changes: 1 addition & 2 deletions src/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,7 @@
},
"dependencies": {
"@prisma/client": "^6.2.1",
"@slack/events-api": "^3.0.1",
"@slack/web-api": "^7.8.0",
"@slack/bolt": "^3.22.0",
"@types/concat-stream": "^2.0.0",
"@types/cookie-parser": "^1.4.3",
"@types/cors": "^2.8.12",
Expand Down
71 changes: 69 additions & 2 deletions src/backend/src/controllers/slack.controllers.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { getWorkspaceId } from '../integrations/slack';
import { getWorkspaceId, replyToMessageInThread } from '../integrations/slack';
import OrganizationsService from '../services/organizations.services';
import SlackServices from '../services/slack.services';
import SlackServices, { SlackBlockActionBody, SaboSubmissionActionValue } from '../services/slack.services';

export default class SlackController {
static async processMessageEvent(event: any) {
Expand All @@ -15,4 +15,71 @@ export default class SlackController {
console.log(error);
}
}

/**
* Handles the Slack block action for SABO submission confirmation.
* Performs action-specific validation and extracts relevant fields from the Slack action body.
* If validation fails, replies to the user in Slack with an error message.
*
* @param body The validated Slack block action body (general structure validated in routes)
*/
static async handleSaboSubmittedAction(body: SlackBlockActionBody) {
const { user, container, actions } = body;
const channelId = container.channel_id;
const threadTs = container.thread_ts || container.message_ts;
const [firstAction] = actions;

try {
// Action-specific validation: verify action_id
if (firstAction.action_id !== 'sabo_submitted_confirmation') {
console.error('Unexpected action_id:', firstAction.action_id);
await replyToMessageInThread(
channelId,
threadTs,
`❌ An error occurred: Unexpected action type "${firstAction.action_id}". Please contact the software team.`
);
return;
}

// Action-specific validation: verify value format
let actionValue: SaboSubmissionActionValue;
try {
actionValue = JSON.parse(firstAction.value);
} catch (parseError) {
const parseErrorMsg = parseError instanceof Error ? parseError.message : 'Unknown parse error';
await replyToMessageInThread(
channelId,
threadTs,
`❌ An error occurred: Invalid action data format.\n\n*Error:* ${parseErrorMsg}\n*Value:* \`${firstAction.value}\`\n\nPlease contact the software team.`
);
return;
}

// Validate that reimbursementRequestId exists in the parsed value
if (!actionValue.reimbursementRequestId || typeof actionValue.reimbursementRequestId !== 'string') {
const actionValueStr = JSON.stringify(actionValue, null, 2);
await replyToMessageInThread(
channelId,
threadTs,
`❌ An error occurred: Missing or invalid reimbursement request ID.\n\n*Parsed value:*\n\`\`\`${actionValueStr}\`\`\`\n\nPlease contact the software team.`
);
return;
}

// Extract validated fields
const userSlackId = user.id;
const { reimbursementRequestId } = actionValue;

// Pass the extracted fields to the service layer for business logic
await SlackServices.handleSaboSubmittedAction(userSlackId, reimbursementRequestId);
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
await replyToMessageInThread(
channelId,
threadTs,
`❌ An unexpected error occurred while processing your request.\n\n*Error message:* ${errorMessage}\n\nPlease contact the software team and provide them with this information.`
);
throw error;
}
}
}
151 changes: 128 additions & 23 deletions src/backend/src/integrations/slack.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,49 @@
import { ChatPostMessageResponse, WebClient } from '@slack/web-api';
import { App, ExpressReceiver } from '@slack/bolt';
import { HttpException } from '../utils/errors.utils';

const slack = new WebClient(process.env.SLACK_BOT_TOKEN);
let receiver: ExpressReceiver | null = null;
let slackApp: App | null = null;
let slack: any = null; // Type will be inferred from slackApp.client (WebClient from Bolt)

/**
* Initializes the Slack Bolt app, receiver, and client if not already initialized
* Only initializes if SLACK_BOT_TOKEN is present
*/
const initializeSlack = () => {
const { SLACK_BOT_TOKEN, SLACK_SIGNING_SECRET } = process.env;

// Don't initialize if no token is configured (e.g., in tests)
if (!SLACK_BOT_TOKEN) {
return;
}

// Don't re-initialize if already initialized
if (slackApp) {
return;
}

// Initialize the receiver, app, and client
receiver = new ExpressReceiver({
signingSecret: SLACK_SIGNING_SECRET || '',
endpoints: '/slack/events'
});

slackApp = new App({
token: SLACK_BOT_TOKEN,
receiver
});

slack = slackApp.client;
};

/**
* Get the Slack WebClient (initializes Slack if needed)
* @returns the Slack WebClient or null if no token is configured
*/
const getSlackClient = () => {
initializeSlack();
return slack;
};

/**
* Send a slack message
Expand All @@ -12,14 +54,13 @@ const slack = new WebClient(process.env.SLACK_BOT_TOKEN);
* @returns the channel id and timestamp of the created slack message
*/
export const sendMessage = async (slackId: string, message: string, link?: string, linkButtonText?: string) => {
const { SLACK_BOT_TOKEN } = process.env;
if (!SLACK_BOT_TOKEN) return;
const client = getSlackClient();
if (!client) return;

const block = generateSlackTextBlock(message, link, linkButtonText);

try {
const response: ChatPostMessageResponse = await slack.chat.postMessage({
token: SLACK_BOT_TOKEN,
const response = await client.chat.postMessage({
channel: slackId,
text: message,
blocks: [block],
Expand Down Expand Up @@ -47,14 +88,13 @@ export const replyToMessageInThread = async (
link?: string,
linkButtonText?: string
) => {
const { SLACK_BOT_TOKEN } = process.env;
if (!SLACK_BOT_TOKEN) return;
const client = getSlackClient();
if (!client) return;

const block = generateSlackTextBlock(message, link, linkButtonText);

try {
await slack.chat.postMessage({
token: SLACK_BOT_TOKEN,
await client.chat.postMessage({
channel: slackId,
thread_ts: parentTimestamp,
text: message,
Expand All @@ -80,14 +120,13 @@ export const editMessage = async (
link?: string,
linkButtonText?: string
) => {
const { SLACK_BOT_TOKEN } = process.env;
if (!SLACK_BOT_TOKEN) return;
const client = getSlackClient();
if (!client) return;

const block = generateSlackTextBlock(message, link, linkButtonText);

try {
await slack.chat.update({
token: SLACK_BOT_TOKEN,
await client.chat.update({
channel: slackId,
ts: timestamp,
text: message,
Expand All @@ -105,12 +144,11 @@ export const editMessage = async (
* @param emoji - the emoji to react with
*/
export const reactToMessage = async (slackId: string, parentTimestamp: string, emoji: string) => {
const { SLACK_BOT_TOKEN } = process.env;
if (!SLACK_BOT_TOKEN) return;
const client = getSlackClient();
if (!client) return;

try {
await slack.reactions.add({
token: SLACK_BOT_TOKEN,
await client.reactions.add({
channel: slackId,
timestamp: parentTimestamp,
name: emoji
Expand Down Expand Up @@ -161,12 +199,15 @@ const generateSlackTextBlock = (message: string, link?: string, linkButtonText?:
* @returns an array of strings of all the slack ids of the users in the given channel
*/
export const getUsersInChannel = async (channelId: string) => {
const client = getSlackClient();
if (!client) return [];

let members: string[] = [];
let cursor: string | undefined;

try {
do {
const response = await slack.conversations.members({
const response = await client.conversations.members({
channel: channelId,
cursor,
limit: 200
Expand All @@ -192,8 +233,11 @@ export const getUsersInChannel = async (channelId: string) => {
* @returns the name of the channel or undefined if it cannot be found
*/
export const getChannelName = async (channelId: string) => {
const client = getSlackClient();
if (!client) return undefined;

try {
const channelRes = await slack.conversations.info({ channel: channelId });
const channelRes = await client.conversations.info({ channel: channelId });
return channelRes.channel?.name;
} catch (error) {
return undefined;
Expand All @@ -206,8 +250,11 @@ export const getChannelName = async (channelId: string) => {
* @returns the name of the user (real name if no display name), undefined if cannot be found
*/
export const getUserName = async (userId: string) => {
const client = getSlackClient();
if (!client) return undefined;

try {
const userRes = await slack.users.info({ user: userId });
const userRes = await client.users.info({ user: userId });
return userRes.user?.profile?.display_name || userRes.user?.real_name;
} catch (error) {
return undefined;
Expand All @@ -219,8 +266,13 @@ export const getUserName = async (userId: string) => {
* @returns the id of the workspace
*/
export const getWorkspaceId = async () => {
const client = getSlackClient();
if (!client) {
throw new HttpException(500, 'Slack client not configured');
}

try {
const response = await slack.auth.test();
const response = await client.auth.test();
if (response.ok) {
return response.team_id;
}
Expand All @@ -230,4 +282,57 @@ export const getWorkspaceId = async () => {
}
};

export default slack;
/**
* Sends a slack ephemeral message to a user
* @param channelId - the channel id of the channel to send to
* @param threadTs - the timestamp of the thread to send to
* @param userId - the id of the user to send to
* @param text - the text of the message to send (should always be populated in case blocks can't be rendered, but if blocks render text will not)
* @param blocks - the blocks of the message to send
*/
export async function sendEphemeralMessage(
channelId: string,
threadTs: string,
userId: string,
text: string,
blocks: any[]
) {
const client = getSlackClient();
if (!client) return;

try {
await client.chat.postEphemeral({
channel: channelId,
user: userId,
thread_ts: threadTs,
text,
blocks
});
} catch (err: unknown) {
if (err instanceof Error) {
throw new HttpException(500, `Failed to send slack notifications: ${err.message}`);
}
}
}

/**
* Get the Slack Bolt app instance (initializes Slack if needed)
* @returns the Slack Bolt App or null if no token is configured
*/
export const getSlackApp = (): App | null => {
initializeSlack();
return slackApp;
};

/**
* Get the Express receiver instance (initializes Slack if needed)
* @returns the ExpressReceiver or null if no token is configured
*/
export const getReceiver = (): ExpressReceiver | null => {
initializeSlack();
return receiver;
};

// Export the getters for any direct usage if needed
export { getSlackClient };
export default getSlackClient;
Loading