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
66 changes: 65 additions & 1 deletion src/backend/src/controllers/slack.controllers.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { getWorkspaceId, replyToMessageInThread } from '../integrations/slack.js';
import OrganizationsService from '../services/organizations.services.js';
import SlackServices, { SlackBlockActionBody, SaboSubmissionActionValue } from '../services/slack.services.js';
import SlackServices, {
SlackBlockActionBody,
SaboSubmissionActionValue,
CrApprovalActionValue
} from '../services/slack.services.js';

export default class SlackController {
static async processMessageEvent(event: any) {
Expand Down Expand Up @@ -82,4 +86,64 @@ export default class SlackController {
throw error;
}
}

static async handleApproveCRAction(body: SlackBlockActionBody, respond: any) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

use the type for respond like in the service function

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 !== 'approve_cr') {
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: CrApprovalActionValue;
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 changeRequestId exists in the parsed value
if (!actionValue.crId || typeof actionValue.crId !== '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 { crId } = actionValue;

// Pass the extracted fields to the service layer for business logic
await SlackServices.handleApproveCRAction(userSlackId, crId, respond);
} 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;
}
}
}
16 changes: 16 additions & 0 deletions src/backend/src/routes/slack.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,22 @@ if (slackApp) {
}
});

// Register interactive action handler for CR approval
slackApp.action('approve_cr', async ({ ack, body, logger, respond }: any) => {
await ack();

try {
if (!validateSlackActionBody(body)) {
logger.error('Invalid Slack action body structure');
return;
}

await SlackController.handleApproveCRAction(body, respond);
} catch (error) {
logger.error('Error handling approve_cr action:', error);
}
});

// Error handler
slackApp.error(async (error: Error) => {
console.error('Slack app error:', error);
Expand Down
105 changes: 104 additions & 1 deletion src/backend/src/services/slack.services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,17 @@ import AnnouncementService from './announcement.services.js';
import { Announcement, ReimbursementStatusType } from 'shared';
import prisma from '../prisma/prisma.js';
import { blockToMentionedUsers, blockToString } from '../utils/slack.utils.js';
import { InvalidOrganizationException, NotFoundException } from '../utils/errors.utils.js';
import {
AccessDeniedException,
HttpException,
InvalidOrganizationException,
NotFoundException
} from '../utils/errors.utils.js';
import ReimbursementRequestService from './reimbursement-requests.services.js';
import ChangeRequestsService from './change-requests.services.js';
import { userTransformer } from '../transformers/user.transformer.js';
import { getUserQueryArgs } from '../prisma-query-args/user.query-args.js';
import { User } from 'shared';

/**
* Represents a slack event for a message in a channel.
Expand Down Expand Up @@ -125,6 +134,13 @@ export interface SaboSubmissionActionValue {
reimbursementRequestId: string;
}

/**
* Represents the parsed value from a CR approval action
*/
export interface CrApprovalActionValue {
crId: string;
}

export default class SlackServices {
/**
* Handles the Slack button click for marking a reimbursement request as SABO submitted.
Expand Down Expand Up @@ -199,6 +215,93 @@ export default class SlackServices {
);
}

/**
* Approves a change request from a Slack interactive button click.
* Auth (admin/head/requested-reviewer) is enforced inside reviewChangeRequest.
*
* @param userSlackId Slack id of the user who clicked the button
* @param crId the change request to approve
* @param respond Bolt response callback bound to this interaction's response_url
*/
static async handleApproveCRAction(
userSlackId: string,
crId: string,
respond: (msg: {
response_type?: 'ephemeral';
text?: string;
replace_original?: boolean;
delete_original?: boolean;
}) => Promise<unknown>
): Promise<void> {
const cr = await prisma.change_Request.findUnique({
where: {
crId
}
});

if (!cr) {
throw new NotFoundException('Change Request', crId);
}

const reviewer = await prisma.user.findFirst({
where: {
userSettings: {
slackId: userSlackId
}
},
...getUserQueryArgs(cr.organizationId)
});

if (!reviewer) {
console.error('User not found for slack ID:', userSlackId);
throw new NotFoundException('User', userSlackId);
}

const org = await prisma.organization.findUnique({
where: {
organizationId: cr.organizationId
}
});

if (!org) {
throw new NotFoundException('Organization', cr.organizationId);
}

const reviewerShared: User = userTransformer(reviewer);

try {
await ChangeRequestsService.reviewChangeRequest(reviewerShared, crId, true, org);
await respond({
replace_original: true,
text: `✅ CR #${cr.identifier} approved by ${reviewer.firstName} ${reviewer.lastName}.`
});
} catch (error) {
if (error instanceof AccessDeniedException) {
await respond({
response_type: 'ephemeral',
text: `❌ You're not authorized to approve this CR. Only admins, team heads, or requested reviewers can approve.`
});
} else if (error instanceof NotFoundException) {
await respond({
response_type: 'ephemeral',
text: `❌ ${error.message}`
});
} else if (error instanceof HttpException) {
await respond({
response_type: 'ephemeral',
text: `❌ ${error.message}`
});
} else {
const msg = error instanceof Error ? error.message : 'Unknown error';
console.error('Error approving CR via Slack:', error);
await respond({
response_type: 'ephemeral',
text: `❌ An unexpected error occurred while approving this CR.\n\n*Error:* ${msg}`
});
}
}
}

/**
* Given a slack event representing a message in a channel,
* make the appropriate announcement change in prisma.
Expand Down
29 changes: 29 additions & 0 deletions src/backend/src/utils/slack.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -608,6 +608,35 @@ export const sendStandardCRCreatedNotification = async (
notifications.map((n) => replyToMessageInThread(n.channelId, n.ts, reviewMsg, crLink, `View CR #${cr.identifier}`))
);
}

// Send the approve button as an ephemeral message to each head and requested reviewer,
// so only authorized approvers see it. reviewChangeRequest still enforces auth on click.
const approveBlocks = [
{
type: 'section',
text: { type: 'mrkdwn', text: `Approve CR #${cr.identifier}?` }
},
{
type: 'actions',
elements: [
{
type: 'button',
text: { type: 'plain_text', text: 'Approve Change Request' },
style: 'primary',
action_id: 'approve_cr',
value: JSON.stringify({ crId: cr.crId, organizationId: cr.organizationId })
}
]
}
];

await Promise.all(
notifications.flatMap((n) =>
[...allSlackIds].map((slackId) =>
sendEphemeralMessage(n.channelId, n.ts, slackId, `Approve CR #${cr.identifier}?`, approveBlocks)
)
)
);
};

/**
Expand Down
Loading