Skip to content

Commit 3dc5ef3

Browse files
committed
emphemeral messages
1 parent 0275106 commit 3dc5ef3

4 files changed

Lines changed: 236 additions & 61 deletions

File tree

src/backend/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import statisticsRouter from './src/routes/statistics.routes';
2525
import retrospectiveRouter from './src/routes/retrospective.routes';
2626
import partsRouter from './src/routes/parts.routes';
2727
import financeRouter from './src/routes/finance.routes';
28+
import './src/routes/slack.routes';
2829

2930
const app = express();
3031

Lines changed: 58 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import { getWorkspaceId } from '../integrations/slack';
1+
import { getWorkspaceId, replyToMessageInThread } from '../integrations/slack';
22
import OrganizationsService from '../services/organizations.services';
3-
import SlackServices from '../services/slack.services';
3+
import SlackServices, { SlackBlockActionBody, SaboSubmissionActionValue } from '../services/slack.services';
44

55
export default class SlackController {
66
static async processMessageEvent(event: any) {
@@ -16,40 +16,70 @@ export default class SlackController {
1616
}
1717
}
1818

19-
static async handleSaboSubmittedAction(body: any) {
19+
/**
20+
* Handles the Slack block action for SABO submission confirmation.
21+
* Performs action-specific validation and extracts relevant fields from the Slack action body.
22+
* If validation fails, replies to the user in Slack with an error message.
23+
*
24+
* @param body The validated Slack block action body (general structure validated in routes)
25+
*/
26+
static async handleSaboSubmittedAction(body: SlackBlockActionBody) {
27+
const { user, container, actions } = body;
28+
const channelId = container.channel_id;
29+
const threadTs = container.thread_ts || container.message_ts;
30+
const [firstAction] = actions;
31+
2032
try {
21-
// Extract action details from Bolt's BlockAction payload
22-
const [action] = body.actions;
33+
// Action-specific validation: verify action_id
34+
if (firstAction.action_id !== 'sabo_submitted_confirmation') {
35+
console.error('Unexpected action_id:', firstAction.action_id);
36+
await replyToMessageInThread(
37+
channelId,
38+
threadTs,
39+
`❌ An error occurred: Unexpected action type "${firstAction.action_id}". Please contact the software team.`
40+
);
41+
return;
42+
}
43+
44+
// Action-specific validation: verify value format
45+
let actionValue: SaboSubmissionActionValue;
46+
try {
47+
actionValue = JSON.parse(firstAction.value);
48+
} catch (parseError) {
49+
const parseErrorMsg = parseError instanceof Error ? parseError.message : 'Unknown parse error';
50+
await replyToMessageInThread(
51+
channelId,
52+
threadTs,
53+
`❌ An error occurred: Invalid action data format.\n\n*Error:* ${parseErrorMsg}\n*Value:* \`${firstAction.value}\`\n\nPlease contact the software team.`
54+
);
55+
return;
56+
}
2357

24-
if (action.type !== 'button') {
25-
// ignore non-button actions for sab submission confirmation
58+
// Validate that reimbursementRequestId exists in the parsed value
59+
if (!actionValue.reimbursementRequestId || typeof actionValue.reimbursementRequestId !== 'string') {
60+
const actionValueStr = JSON.stringify(actionValue, null, 2);
61+
await replyToMessageInThread(
62+
channelId,
63+
threadTs,
64+
`❌ An error occurred: Missing or invalid reimbursement request ID.\n\n*Parsed value:*\n\`\`\`${actionValueStr}\`\`\`\n\nPlease contact the software team.`
65+
);
2666
return;
2767
}
2868

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-
};
69+
// Extract validated fields
70+
const userSlackId = user.id;
71+
const { reimbursementRequestId } = actionValue;
4572

46-
// Handle the action using existing service
47-
await SlackServices.handleSaboSubmittedAction(payload);
73+
// Pass the extracted fields to the service layer for business logic
74+
await SlackServices.handleSaboSubmittedAction(userSlackId, reimbursementRequestId);
4875
} catch (error: unknown) {
49-
console.error('Error handling Slack interactive action:', error);
5076
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
77+
await replyToMessageInThread(
78+
channelId,
79+
threadTs,
80+
`❌ 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.`
81+
);
82+
throw error;
5383
}
5484
}
5585
}

src/backend/src/routes/slack.routes.ts

Lines changed: 90 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,103 @@ slackApp.message(async ({ message, logger }: any) => {
1111
}
1212
});
1313

14+
/**
15+
* Validates the general structure of a Slack block action payload.
16+
* This validation is action-agnostic and only checks that the required fields exist.
17+
* Action-specific validation (like action_id and value format) happens in the controller.
18+
*
19+
* @param body The Slack action body to validate
20+
* @returns true if valid, false otherwise
21+
*/
22+
function validateSlackActionBody(body: any): boolean {
23+
// Check required top-level fields
24+
if (!body || typeof body !== 'object') {
25+
console.error('Invalid body: not an object');
26+
return false;
27+
}
28+
29+
if (body.type !== 'block_actions') {
30+
console.error('Invalid body type:', body.type);
31+
return false;
32+
}
33+
34+
// Validate user object
35+
if (!body.user || typeof body.user !== 'object') {
36+
console.error('Invalid or missing user object');
37+
return false;
38+
}
39+
40+
if (!body.user.id || typeof body.user.id !== 'string') {
41+
console.error('Invalid or missing user.id');
42+
return false;
43+
}
44+
45+
if (!body.user.team_id || typeof body.user.team_id !== 'string') {
46+
console.error('Invalid or missing user.team_id');
47+
return false;
48+
}
49+
50+
// Validate actions array exists and has at least one action
51+
if (!Array.isArray(body.actions) || body.actions.length === 0) {
52+
console.error('Invalid or empty actions array');
53+
return false;
54+
}
55+
56+
const [action] = body.actions;
57+
if (!action.action_id || typeof action.action_id !== 'string') {
58+
console.error('Invalid or missing action_id');
59+
return false;
60+
}
61+
62+
if (!action.value || typeof action.value !== 'string') {
63+
console.error('Invalid or missing action value');
64+
return false;
65+
}
66+
67+
// Validate container object (for message timestamp and channel)
68+
if (!body.container || typeof body.container !== 'object') {
69+
console.error('Invalid or missing container object');
70+
return false;
71+
}
72+
73+
if (!body.container.message_ts || typeof body.container.message_ts !== 'string') {
74+
console.error('Invalid or missing container.message_ts');
75+
return false;
76+
}
77+
78+
if (!body.container.channel_id || typeof body.container.channel_id !== 'string') {
79+
console.error('Invalid or missing container.channel_id');
80+
return false;
81+
}
82+
83+
// Validate thread_ts if it exists (optional but if present should be string)
84+
if (body.container.thread_ts && typeof body.container.thread_ts !== 'string') {
85+
console.error('Invalid container.thread_ts type');
86+
return false;
87+
}
88+
89+
return true;
90+
}
91+
1492
// Register interactive action handler for SABO submission confirmation
15-
slackApp.action('sabo_submitted_confirmation', async ({ ack, body, logger }: any) => {
93+
slackApp.action('sabo_submitted_confirmation', async ({ ack, body, logger, respond }: any) => {
1694
await ack();
1795

1896
try {
97+
98+
// Validate the incoming action body structure
99+
if (!validateSlackActionBody(body)) {
100+
logger.error('Invalid Slack action body structure');
101+
return;
102+
}
103+
19104
await SlackController.handleSaboSubmittedAction(body);
105+
106+
// If no error, delete the original message
107+
await respond({ delete_original: true });
20108
} catch (error) {
109+
// Can't pass to normal middleware because not normal request
21110
logger.error('Error handling sabo_submitted_confirmation action:', error);
22-
console.error(error);
23111
}
24112
});
25113

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

Lines changed: 87 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import { getChannelName, getUserName } from '../integrations/slack';
22
import AnnouncementService from './announcement.services';
3-
import { Announcement } from 'shared';
3+
import { Announcement, ReimbursementStatusType } from 'shared';
44
import prisma from '../prisma/prisma';
55
import { blockToMentionedUsers, blockToString } from '../utils/slack.utils';
6-
import { NotFoundException } from '../utils/errors.utils';
6+
import { InvalidOrganizationException, NotFoundException } from '../utils/errors.utils';
7+
import ReimbursementRequestService from './reimbursement-requests.services';
78

89
/**
910
* Represents a slack event for a message in a channel.
@@ -67,73 +68,128 @@ export interface SlackRichTextBlock {
6768
}
6869

6970
/**
70-
* Represents a Slack interactive payload from a button click
71+
* Represents a Slack block action body structure.
72+
* The general structure is validated in routes, while action-specific fields
73+
* (action_id and value format) are validated in controllers.
7174
*/
72-
export interface SlackInteractivePayload {
73-
type: string;
75+
export interface SlackBlockActionBody {
76+
type: 'block_actions';
7477
user: {
7578
id: string;
7679
username: string;
7780
name: string;
81+
team_id: string;
7882
};
83+
api_app_id: string;
84+
token: string;
85+
container: {
86+
type: string;
87+
message_ts: string;
88+
channel_id: string;
89+
is_ephemeral: boolean;
90+
thread_ts?: string; // Optional - if present, the message is in a thread
91+
};
92+
trigger_id: string;
93+
team: {
94+
id: string;
95+
domain: string;
96+
};
97+
enterprise: null | {
98+
id: string;
99+
name: string;
100+
};
101+
is_enterprise_install: boolean;
102+
channel: {
103+
id: string;
104+
name: string;
105+
};
106+
state: {
107+
values: Record<string, any>;
108+
};
109+
response_url: string;
79110
actions: Array<{
80-
action_id: string;
81-
value: string;
111+
action_id: string; // Validated in controller, not routes
112+
block_id: string;
113+
text?: any;
114+
value: string; // Validated for format in controller, not routes
115+
style?: string;
82116
type: string;
117+
action_ts: string;
83118
}>;
84-
response_url: string;
119+
}
120+
121+
/**
122+
* Represents the parsed value from a SABO submission action
123+
*/
124+
export interface SaboSubmissionActionValue {
125+
reimbursementRequestId: string;
85126
}
86127

87128
export default class SlackServices {
88129
/**
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
130+
* Handles the Slack button click for marking a reimbursement request as SABO submitted.
131+
* This performs the business logic for processing the SABO submission confirmation.
132+
*
133+
* @param userSlackId The Slack user ID of the user who clicked the button
134+
* @param teamSlackId The Slack team ID (workspace ID) where the action occurred
135+
* @param reimbursementRequestId The ID of the reimbursement request to mark as submitted
136+
* @param interactiveMessageTs The timestamp of the interactive message (to delete after processing)
92137
*/
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-
138+
static async handleSaboSubmittedAction(userSlackId: string, reimbursementRequestId: string): Promise<void> {
107139
// Find the user by their slack ID
108140
const user = await prisma.user.findFirst({
109141
where: {
110142
userSettings: {
111-
slackId: slackUserId
143+
slackId: userSlackId
112144
}
113145
}
114146
});
115147

116148
if (!user) {
117-
console.error('User not found for slack ID:', slackUserId);
118-
throw new NotFoundException('User', slackUserId);
149+
console.error('User not found for slack ID:', userSlackId);
150+
throw new NotFoundException('User', userSlackId);
119151
}
120152

153+
// Find the reimbursement request
121154
const reimbursementRequest = await prisma.reimbursement_Request.findUnique({
122155
where: {
123156
reimbursementRequestId
124157
},
125158
include: {
126-
organization: true
159+
organization: true,
160+
reimbursementStatuses: true,
161+
notificationSlackThreads: true
127162
}
128163
});
129164

130165
if (!reimbursementRequest) {
131166
throw new NotFoundException('Reimbursement Request', reimbursementRequestId);
132167
}
133168

169+
// Verify that the user's organization matches the reimbursement request's organization
170+
const userOrganization = await prisma.user.findFirst({
171+
where: {
172+
userId: user.userId
173+
},
174+
include: {
175+
organizations: true
176+
}
177+
});
178+
179+
const hasAccess = userOrganization?.organizations.some(
180+
(org) => org.organizationId === reimbursementRequest.organizationId
181+
);
134182

135-
// Import the service dynamically to avoid circular dependencies
136-
const ReimbursementRequestService = (await import('./reimbursement-requests.services')).default;
183+
if (!hasAccess) {
184+
throw new InvalidOrganizationException('Reimbursement Request');
185+
}
186+
187+
// If the reimbursement request has already been submitted to SABO, just return (message will be deleted by route)
188+
if (
189+
reimbursementRequest.reimbursementStatuses.some((status) => status.type === ReimbursementStatusType.SABO_SUBMITTED)
190+
) {
191+
return;
192+
}
137193

138194
// Call the service function to mark as SABO submitted
139195
await ReimbursementRequestService.markReimbursementRequestAsSaboSubmitted(

0 commit comments

Comments
 (0)