|
1 | 1 | import { getChannelName, getUserName } from '../integrations/slack'; |
2 | 2 | import AnnouncementService from './announcement.services'; |
3 | | -import { Announcement } from 'shared'; |
| 3 | +import { Announcement, ReimbursementStatusType } from 'shared'; |
4 | 4 | import prisma from '../prisma/prisma'; |
5 | 5 | 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'; |
7 | 8 |
|
8 | 9 | /** |
9 | 10 | * Represents a slack event for a message in a channel. |
@@ -67,73 +68,128 @@ export interface SlackRichTextBlock { |
67 | 68 | } |
68 | 69 |
|
69 | 70 | /** |
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. |
71 | 74 | */ |
72 | | -export interface SlackInteractivePayload { |
73 | | - type: string; |
| 75 | +export interface SlackBlockActionBody { |
| 76 | + type: 'block_actions'; |
74 | 77 | user: { |
75 | 78 | id: string; |
76 | 79 | username: string; |
77 | 80 | name: string; |
| 81 | + team_id: string; |
78 | 82 | }; |
| 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; |
79 | 110 | 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; |
82 | 116 | type: string; |
| 117 | + action_ts: string; |
83 | 118 | }>; |
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; |
85 | 126 | } |
86 | 127 |
|
87 | 128 | export default class SlackServices { |
88 | 129 | /** |
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) |
92 | 137 | */ |
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> { |
107 | 139 | // Find the user by their slack ID |
108 | 140 | const user = await prisma.user.findFirst({ |
109 | 141 | where: { |
110 | 142 | userSettings: { |
111 | | - slackId: slackUserId |
| 143 | + slackId: userSlackId |
112 | 144 | } |
113 | 145 | } |
114 | 146 | }); |
115 | 147 |
|
116 | 148 | 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); |
119 | 151 | } |
120 | 152 |
|
| 153 | + // Find the reimbursement request |
121 | 154 | const reimbursementRequest = await prisma.reimbursement_Request.findUnique({ |
122 | 155 | where: { |
123 | 156 | reimbursementRequestId |
124 | 157 | }, |
125 | 158 | include: { |
126 | | - organization: true |
| 159 | + organization: true, |
| 160 | + reimbursementStatuses: true, |
| 161 | + notificationSlackThreads: true |
127 | 162 | } |
128 | 163 | }); |
129 | 164 |
|
130 | 165 | if (!reimbursementRequest) { |
131 | 166 | throw new NotFoundException('Reimbursement Request', reimbursementRequestId); |
132 | 167 | } |
133 | 168 |
|
| 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 | + ); |
134 | 182 |
|
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 | + } |
137 | 193 |
|
138 | 194 | // Call the service function to mark as SABO submitted |
139 | 195 | await ReimbursementRequestService.markReimbursementRequestAsSaboSubmitted( |
|
0 commit comments