From fbbc94dcafd3d1fe2448f0ab074c2ef49031b83e Mon Sep 17 00:00:00 2001 From: Chris Pyle Date: Sat, 1 Nov 2025 10:00:46 -0400 Subject: [PATCH 1/7] bolt --- src/backend/index.ts | 12 +- src/backend/package.json | 3 +- .../src/controllers/slack.controllers.ts | 37 ++ src/backend/src/integrations/slack.ts | 77 ++- src/backend/src/routes/slack.routes.ts | 29 +- .../src/services/notifications.services.ts | 7 + .../reimbursement-requests.services.ts | 17 +- src/backend/src/services/slack.services.ts | 76 +++ src/backend/src/utils/auth.utils.ts | 6 +- src/backend/src/utils/slack.utils.ts | 11 +- yarn.lock | 453 +++++++++++------- 11 files changed, 537 insertions(+), 191 deletions(-) diff --git a/src/backend/index.ts b/src/backend/index.ts index ffe251ec65..e2744cb38e 100644 --- a/src/backend/index.ts +++ b/src/backend/index.ts @@ -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'; @@ -17,7 +17,7 @@ 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 { receiver } 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'; @@ -48,9 +48,11 @@ 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 +app.use(receiver.router as unknown as Router); +console.log('Registered Slack Bolt receiver at /slack/events'); // so that we can use cookies and json app.use(cookieParser()); diff --git a/src/backend/package.json b/src/backend/package.json index 92ec84d984..6f1f592282 100644 --- a/src/backend/package.json +++ b/src/backend/package.json @@ -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", diff --git a/src/backend/src/controllers/slack.controllers.ts b/src/backend/src/controllers/slack.controllers.ts index e7336711d2..dba18dec79 100644 --- a/src/backend/src/controllers/slack.controllers.ts +++ b/src/backend/src/controllers/slack.controllers.ts @@ -15,4 +15,41 @@ export default class SlackController { console.log(error); } } + + static async handleSaboSubmittedAction(body: any) { + try { + // Extract action details from Bolt's BlockAction payload + const [action] = body.actions; + + if (action.type !== 'button') { + // ignore non-button actions for sab submission confirmation + return; + } + + const payload = { + type: body.type, + user: { + id: body.user.id, + username: body.user.username, + name: body.user.name + }, + actions: [ + { + action_id: action.action_id, + value: action.value || '', + type: action.type + } + ], + response_url: body.response_url + }; + + // Handle the action using existing service + await SlackServices.handleSaboSubmittedAction(payload); + } catch (error: unknown) { + console.error('Error handling Slack interactive action:', error); + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + console.error('Error details:', errorMessage); + throw error; // Re-throw to be handled by Bolt's error handler + } + } } diff --git a/src/backend/src/integrations/slack.ts b/src/backend/src/integrations/slack.ts index 6e855acae7..1cbcbbb049 100644 --- a/src/backend/src/integrations/slack.ts +++ b/src/backend/src/integrations/slack.ts @@ -1,7 +1,19 @@ -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); +const receiver = new ExpressReceiver({ + signingSecret: process.env.SLACK_SIGNING_SECRET || '', + endpoints: '/slack/events' +}); + +// Initialize the Bolt app +const slackApp = new App({ + token: process.env.SLACK_BOT_TOKEN, + receiver +}); + +// Get the WebClient from the Bolt app +const slack = slackApp.client; /** * Send a slack message @@ -18,8 +30,7 @@ export const sendMessage = async (slackId: string, message: string, link?: strin const block = generateSlackTextBlock(message, link, linkButtonText); try { - const response: ChatPostMessageResponse = await slack.chat.postMessage({ - token: SLACK_BOT_TOKEN, + const response = await slack.chat.postMessage({ channel: slackId, text: message, blocks: [block], @@ -54,7 +65,6 @@ export const replyToMessageInThread = async ( try { await slack.chat.postMessage({ - token: SLACK_BOT_TOKEN, channel: slackId, thread_ts: parentTimestamp, text: message, @@ -87,7 +97,6 @@ export const editMessage = async ( try { await slack.chat.update({ - token: SLACK_BOT_TOKEN, channel: slackId, ts: timestamp, text: message, @@ -110,7 +119,6 @@ export const reactToMessage = async (slackId: string, parentTimestamp: string, e try { await slack.reactions.add({ - token: SLACK_BOT_TOKEN, channel: slackId, timestamp: parentTimestamp, name: emoji @@ -230,4 +238,59 @@ export const getWorkspaceId = async () => { } }; +export async function sendEphemeralConfirmation( + channelId: string, + threadTs: string, + userId: string, + reimbursementRequestId: string +) { + try { + await slack.chat.postEphemeral({ + channel: channelId, + user: userId, + thread_ts: threadTs, + text: 'Approve the request on concur and then click the button below to mark it as submitted on Finishline.', + blocks: [ + { + type: 'section', + text: { + type: 'mrkdwn', + text: 'Approve the request on concur and then click the button below to mark it as submitted on Finishline.' + } + }, + { + type: 'section', + text: { + type: 'mrkdwn', + text: '' + } + }, + { + type: 'actions', + elements: [ + { + type: 'button', + text: { + type: 'plain_text', + text: "✓ I've approved the request on Concur" + }, + style: 'primary', + action_id: 'sabo_submitted_confirmation', + value: JSON.stringify({ + reimbursementRequestId + }) + } + ] + } + ] + }); + } catch (err: unknown) { + if (err instanceof Error) { + throw new HttpException(500, `Failed to send slack notifications: ${err.message}`); + } + } +} + +// Export the slack client, bolt app, and receiver for any direct usage if needed +export { slack, slackApp, receiver }; export default slack; diff --git a/src/backend/src/routes/slack.routes.ts b/src/backend/src/routes/slack.routes.ts index 6878b176b1..381b983445 100644 --- a/src/backend/src/routes/slack.routes.ts +++ b/src/backend/src/routes/slack.routes.ts @@ -1,8 +1,29 @@ -import { createEventAdapter } from '@slack/events-api'; +import { slackApp } from '../integrations/slack'; import SlackController from '../controllers/slack.controllers'; -export const slackEvents = createEventAdapter(process.env.SLACK_SIGNING_SECRET || ''); +// Register message event listener +slackApp.message(async ({ message, logger }: any) => { + try { + await SlackController.processMessageEvent(message); + } catch (error) { + logger.error('Error processing message event:', error); + console.error(error); + } +}); -slackEvents.on('message', SlackController.processMessageEvent); +// Register interactive action handler for SABO submission confirmation +slackApp.action('sabo_submitted_confirmation', async ({ ack, body, logger }: any) => { + await ack(); -slackEvents.on('error', console.log); + try { + await SlackController.handleSaboSubmittedAction(body); + } catch (error) { + logger.error('Error handling sabo_submitted_confirmation action:', error); + console.error(error); + } +}); + +// Error handler +slackApp.error(async (error: Error) => { + console.error('Slack app error:', error); +}); diff --git a/src/backend/src/services/notifications.services.ts b/src/backend/src/services/notifications.services.ts index 599d182553..d0365b840b 100644 --- a/src/backend/src/services/notifications.services.ts +++ b/src/backend/src/services/notifications.services.ts @@ -265,6 +265,13 @@ export default class NotificationsService { type: Reimbursement_Status_Type.SABO_SUBMITTED } } + }, + { + reimbursementStatuses: { + none: { + type: Reimbursement_Status_Type.DENIED + } + } } ] }, diff --git a/src/backend/src/services/reimbursement-requests.services.ts b/src/backend/src/services/reimbursement-requests.services.ts index 9f9f38a4a6..9ff9aea885 100644 --- a/src/backend/src/services/reimbursement-requests.services.ts +++ b/src/backend/src/services/reimbursement-requests.services.ts @@ -418,12 +418,26 @@ export default class ReimbursementRequestService { totalCost, accountCodeId: accountCode.accountCodeId, vendorId: vendor.vendorId + }, + include: { + notificationSlackThreads: true } }); //set any deleted receipts with a dateDeleted await removeDeletedReceiptPictures(receiptPictures, oldReimbursementRequest.receiptPictures || [], submitter); + try { + await sendPendingSaboSubmissionNotification( + updatedReimbursementRequest.notificationSlackThreads, + submitter.userId, + updatedReimbursementRequest.recipientId, + updatedReimbursementRequest.reimbursementRequestId + ); + } catch (e: unknown) { + console.error('Error sending pending SABO submission notification:', e); + } + return updatedReimbursementRequest; } @@ -1292,7 +1306,8 @@ export default class ReimbursementRequestService { await sendPendingSaboSubmissionNotification( reimbursementRequest.notificationSlackThreads, submitter.userId, - reimbursementRequest.recipientId + reimbursementRequest.recipientId, + reimbursementRequest.reimbursementRequestId ); } catch (e: unknown) { console.error('Error sending pending SABO submission notification:', e); diff --git a/src/backend/src/services/slack.services.ts b/src/backend/src/services/slack.services.ts index a289124013..a913987c27 100644 --- a/src/backend/src/services/slack.services.ts +++ b/src/backend/src/services/slack.services.ts @@ -66,7 +66,83 @@ export interface SlackRichTextBlock { usergroup_id?: string; } +/** + * Represents a Slack interactive payload from a button click + */ +export interface SlackInteractivePayload { + type: string; + user: { + id: string; + username: string; + name: string; + }; + actions: Array<{ + action_id: string; + value: string; + type: string; + }>; + response_url: string; +} + export default class SlackServices { + /** + * Handles the Slack button click for marking a reimbursement request as SABO submitted + * @param payload the Slack interactive payload + * @param organizationId the organization ID + */ + static async handleSaboSubmittedAction(payload: SlackInteractivePayload): Promise { + const [action] = payload.actions; + if (action.action_id !== 'sabo_submitted_confirmation') { + console.log('Ignoring action with id:', action.action_id); + return; + } + + console.log('Processing sabo_submitted_confirmation action'); + const { reimbursementRequestId } = JSON.parse(action.value); + const slackUserId = payload.user.id; + + console.log('Looking up user with slack ID:', slackUserId); + console.log('Reimbursement Request ID:', reimbursementRequestId); + + // Find the user by their slack ID + const user = await prisma.user.findFirst({ + where: { + userSettings: { + slackId: slackUserId + } + } + }); + + if (!user) { + console.error('User not found for slack ID:', slackUserId); + throw new NotFoundException('User', slackUserId); + } + + const reimbursementRequest = await prisma.reimbursement_Request.findUnique({ + where: { + reimbursementRequestId + }, + include: { + organization: true + } + }); + + if (!reimbursementRequest) { + throw new NotFoundException('Reimbursement Request', reimbursementRequestId); + } + + + // Import the service dynamically to avoid circular dependencies + const ReimbursementRequestService = (await import('./reimbursement-requests.services')).default; + + // Call the service function to mark as SABO submitted + await ReimbursementRequestService.markReimbursementRequestAsSaboSubmitted( + reimbursementRequestId, + user, + reimbursementRequest.organization + ); + } + /** * Given a slack event representing a message in a channel, * make the appropriate announcement change in prisma. diff --git a/src/backend/src/utils/auth.utils.ts b/src/backend/src/utils/auth.utils.ts index ed51775e1d..4517b3a88a 100644 --- a/src/backend/src/utils/auth.utils.ts +++ b/src/backend/src/utils/auth.utils.ts @@ -33,7 +33,7 @@ export const requireJwtProd = (req: Request, res: Response, next: NextFunction) req.path === '/users/auth/login' || // logins dont have cookies yet req.path === '/' || // base route is available so aws can listen and check the health req.method === 'OPTIONS' || // this is a pre-flight request and those don't send cookies - req.path === '/slack' // slack http endpoint is only used from slack api + req.path.startsWith('/slack') // slack endpoints (events and interactions) are only used from slack api ) { return next(); } else if ( @@ -65,7 +65,7 @@ export const requireJwtDev = (req: Request, res: Response, next: NextFunction) = req.path === '/' || // base route is available so aws can listen and check the health req.method === 'OPTIONS' || // this is a pre-flight request and those don't send cookies req.path === '/users' || // dev login needs the list of users to log in - req.path === '/slack' // slack http endpoint is only used from slack api + req.path.startsWith('/slack') // slack endpoints (events and interactions) are only used from slack api ) { next(); } else if ( @@ -185,7 +185,7 @@ export const getUserAndOrganization = async (req: Request, res: Response, next: req.path === '/' || // base route is available so aws can listen and check the health req.method === 'OPTIONS' || // this is a pre-flight request and those don't send cookies req.path === '/users' || // dev login needs the list of users to log in - req.path === '/slack' || // slack http endpoint is only used from slack api + req.path.startsWith('/slack') || // slack endpoints (events and interactions) are only used from slack api req.path.startsWith('/notifications') // Notifications route has its own auth, only called from gh ) { return next(); diff --git a/src/backend/src/utils/slack.utils.ts b/src/backend/src/utils/slack.utils.ts index 4d22d7a2e0..16a93631f0 100644 --- a/src/backend/src/utils/slack.utils.ts +++ b/src/backend/src/utils/slack.utils.ts @@ -16,6 +16,7 @@ import { getUsersInChannel, reactToMessage, replyToMessageInThread, + sendEphemeralConfirmation, sendMessage } from '../integrations/slack'; import { getUserSlackId, getUserSlackMentionOrName } from './users.utils'; @@ -232,12 +233,20 @@ export const sendSubmittedToSaboNotification = async (threads: SlackMessageThrea export const sendPendingSaboSubmissionNotification = async ( threads: SlackMessageThread[], financeUserId: string, - pendingSubmissionFromId: string + pendingSubmissionFromId: string, + reimbursementRequestId: string ) => { await sendThreadResponse( threads, `${await getUserSlackMentionOrName(financeUserId)} has added this reimbursement request to Concur. ${await getUserSlackMentionOrName(pendingSubmissionFromId)}, please check your email to approve the request in Concur and mark it as submitted on Finishline.` ); + const userId = await getUserSlackId(financeUserId); + if (threads && threads.length !== 0 && userId) { + const msgs = threads.map((thread) => + sendEphemeralConfirmation(thread.channelId, thread.timestamp, userId, reimbursementRequestId) + ); + await Promise.all(msgs); + } }; export const sendSlackDesignReviewConfirmNotification = async ( diff --git a/yarn.lock b/yarn.lock index 6e5ea4207e..e0d2d26377 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4816,27 +4816,34 @@ __metadata: languageName: node linkType: hard -"@slack/events-api@npm:^3.0.1": - version: 3.0.1 - resolution: "@slack/events-api@npm:3.0.1" - dependencies: - "@types/debug": ^4.1.4 - "@types/express": ^4.17.0 - "@types/lodash.isstring": ^4.0.6 - "@types/node": ">=12.13.0 < 13" - "@types/yargs": ^15.0.4 - debug: ^2.6.1 - express: ^4.0.0 - lodash.isstring: ^4.0.1 +"@slack/bolt@npm:^3.22.0": + version: 3.22.0 + resolution: "@slack/bolt@npm:3.22.0" + dependencies: + "@slack/logger": ^4.0.0 + "@slack/oauth": ^2.6.3 + "@slack/socket-mode": ^1.3.6 + "@slack/types": ^2.13.0 + "@slack/web-api": ^6.13.0 + "@types/express": ^4.16.1 + "@types/promise.allsettled": ^1.0.3 + "@types/tsscmp": ^1.0.0 + axios: ^1.7.4 + express: ^4.21.0 + path-to-regexp: ^8.1.0 + promise.allsettled: ^1.0.2 raw-body: ^2.3.3 tsscmp: ^1.0.6 - yargs: ^15.3.1 - dependenciesMeta: - express: - optional: true - bin: - slack-verify: dist/verify.js - checksum: ce62dc2ee9dd93b88820e18f88f543228740243dc390caf49b3a7e1ad351b298e3961898bd78f5eb43e9f6acac067458257cd34c9661089f684bb5cf4af468c3 + checksum: edd5c7cf658808effde87c936f19a0cc2b7d49ac97471651f2b1bb3db0074b92dc8ad3c9657577105d93c48df9ba16c382902c0d90082854cbbe86bfc7753827 + languageName: node + linkType: hard + +"@slack/logger@npm:^3.0.0": + version: 3.0.0 + resolution: "@slack/logger@npm:3.0.0" + dependencies: + "@types/node": ">=12.0.0" + checksum: 6512d0e9e4be47ea465705ab9b6e6901f36fa981da0d4a657fde649d452b567b351002049b5ee0a22569b5119bf6c2f61befd5b8022d878addb7a99c91b03389 languageName: node linkType: hard @@ -4849,30 +4856,58 @@ __metadata: languageName: node linkType: hard -"@slack/types@npm:^2.17.0": - version: 2.17.0 - resolution: "@slack/types@npm:2.17.0" - checksum: 57cad4b3153589707fef50c7a231921364d10d8f4a3e4d342c718d4aa69f5b3541fee686e8b2d93d46dd4c9842adebe5d3bae6530e4ad8719c1ec5d46b0ec157 +"@slack/oauth@npm:^2.6.3": + version: 2.6.3 + resolution: "@slack/oauth@npm:2.6.3" + dependencies: + "@slack/logger": ^3.0.0 + "@slack/web-api": ^6.12.1 + "@types/jsonwebtoken": ^8.3.7 + "@types/node": ">=12" + jsonwebtoken: ^9.0.0 + lodash.isstring: ^4.0.1 + checksum: 6b556da01bd2b026177b4074cd44bdeff00165fb4297ef8f350035ca79ababfff0c0993a297a46ab742bb97469c6c1c8f5790c328ecf6370290fe31014ba3c5e languageName: node linkType: hard -"@slack/web-api@npm:^7.8.0": - version: 7.11.0 - resolution: "@slack/web-api@npm:7.11.0" +"@slack/socket-mode@npm:^1.3.6": + version: 1.3.6 + resolution: "@slack/socket-mode@npm:1.3.6" dependencies: - "@slack/logger": ^4.0.0 - "@slack/types": ^2.17.0 - "@types/node": ">=18.0.0" - "@types/retry": 0.12.0 - axios: ^1.11.0 - eventemitter3: ^5.0.1 - form-data: ^4.0.4 + "@slack/logger": ^3.0.0 + "@slack/web-api": ^6.12.1 + "@types/node": ">=12.0.0" + "@types/ws": ^7.4.7 + eventemitter3: ^5 + finity: ^0.5.4 + ws: ^7.5.3 + checksum: a84c15a6d25a21f76258d1ccebeec1d78b0a0dac0b02ffdfcb3596e7acda5459e4b99a42207eab7e57bed7a2a1d85ac173adf5e07aa66949eac9cc9df3b43947 + languageName: node + linkType: hard + +"@slack/types@npm:^2.11.0, @slack/types@npm:^2.13.0": + version: 2.18.0 + resolution: "@slack/types@npm:2.18.0" + checksum: c425b528924be74fb8e8de0e1883199b088ce427a8d7217998ee77c0cce817464be102383801e142ab531430b7fd1a1f9b9deed2b7a9e3df1c67a24a1d7348a1 + languageName: node + linkType: hard + +"@slack/web-api@npm:^6.12.1, @slack/web-api@npm:^6.13.0": + version: 6.13.0 + resolution: "@slack/web-api@npm:6.13.0" + dependencies: + "@slack/logger": ^3.0.0 + "@slack/types": ^2.11.0 + "@types/is-stream": ^1.1.0 + "@types/node": ">=12.0.0" + axios: ^1.7.4 + eventemitter3: ^3.1.0 + form-data: ^2.5.0 is-electron: 2.2.2 - is-stream: ^2 - p-queue: ^6 - p-retry: ^4 - retry: ^0.13.1 - checksum: 52c26b169111d15a6ef0701b947291ee97a8f4e795f9ee509ec90c56a4ef72f9776a3d35e54d7fa329b0479e7f193539e2e095b3ee0704a775bf66d960dfacf0 + is-stream: ^1.1.0 + p-queue: ^6.6.1 + p-retry: ^4.0.0 + checksum: 77f0d506bbb011ae43d322e5152e8b1ec2b88aa01256da6b3c9ff8ce106d2284f887cad2d9f044e0fe34dc865d60f2bce1c6bb5c4117150ff71a7ef341f5dfeb languageName: node linkType: hard @@ -5853,7 +5888,7 @@ __metadata: languageName: node linkType: hard -"@types/debug@npm:^4.0.0, @types/debug@npm:^4.1.4": +"@types/debug@npm:^4.0.0": version: 4.1.12 resolution: "@types/debug@npm:4.1.12" dependencies: @@ -5976,7 +6011,19 @@ __metadata: languageName: node linkType: hard -"@types/express@npm:^4.17.0, @types/express@npm:^4.17.13": +"@types/express@npm:^4.16.1": + version: 4.17.25 + resolution: "@types/express@npm:4.17.25" + dependencies: + "@types/body-parser": "*" + "@types/express-serve-static-core": ^4.17.33 + "@types/qs": "*" + "@types/serve-static": ^1 + checksum: 285d16008489d37b2be03e2e050bcf201d5d6ed9278ca13619d9029efd2055b192b2445f769116f716cfcf53d9d799a03f4e76199af9cea0ea3dee3d88595931 + languageName: node + linkType: hard + +"@types/express@npm:^4.17.13": version: 4.17.23 resolution: "@types/express@npm:4.17.23" dependencies: @@ -6043,6 +6090,15 @@ __metadata: languageName: node linkType: hard +"@types/is-stream@npm:^1.1.0": + version: 1.1.0 + resolution: "@types/is-stream@npm:1.1.0" + dependencies: + "@types/node": "*" + checksum: 23fcb06cd8adc0124d4c44071bd4b447c41f5e4c2eccb6166789c7fc0992b566e2e8b628a3800ff4472b686d9085adbec203925068bf72e350e085650e83adec + languageName: node + linkType: hard + "@types/istanbul-lib-coverage@npm:*, @types/istanbul-lib-coverage@npm:^2.0.0, @types/istanbul-lib-coverage@npm:^2.0.1": version: 2.0.6 resolution: "@types/istanbul-lib-coverage@npm:2.0.6" @@ -6092,7 +6148,7 @@ __metadata: languageName: node linkType: hard -"@types/jsonwebtoken@npm:^8.5.8, @types/jsonwebtoken@npm:^8.5.9": +"@types/jsonwebtoken@npm:^8.3.7, @types/jsonwebtoken@npm:^8.5.8, @types/jsonwebtoken@npm:^8.5.9": version: 8.5.9 resolution: "@types/jsonwebtoken@npm:8.5.9" dependencies: @@ -6101,22 +6157,6 @@ __metadata: languageName: node linkType: hard -"@types/lodash.isstring@npm:^4.0.6": - version: 4.0.9 - resolution: "@types/lodash.isstring@npm:4.0.9" - dependencies: - "@types/lodash": "*" - checksum: ef381be69b459caa42d7c5dc4ff5b3653e6b3c9b2393f6e92848efeafe7690438e058b26f036b11b4e535fc7645ff12d1203847b9a82e9ae0593bdd3b25a971b - languageName: node - linkType: hard - -"@types/lodash@npm:*": - version: 4.17.20 - resolution: "@types/lodash@npm:4.17.20" - checksum: dc7bb4653514dd91117a4c4cec2c37e2b5a163d7643445e4757d76a360fabe064422ec7a42dde7450c5e7e0e7e678d5e6eae6d2a919abcddf581d81e63e63839 - languageName: node - linkType: hard - "@types/mdast@npm:^4.0.0": version: 4.0.4 resolution: "@types/mdast@npm:4.0.4" @@ -6188,10 +6228,12 @@ __metadata: languageName: node linkType: hard -"@types/node@npm:>=12.13.0 < 13": - version: 12.20.55 - resolution: "@types/node@npm:12.20.55" - checksum: e4f86785f4092706e0d3b0edff8dca5a13b45627e4b36700acd8dfe6ad53db71928c8dee914d4276c7fd3b6ccd829aa919811c9eb708a2c8e4c6eb3701178c37 +"@types/node@npm:>=12, @types/node@npm:>=12.0.0": + version: 24.9.2 + resolution: "@types/node@npm:24.9.2" + dependencies: + undici-types: ~7.16.0 + checksum: 6f1d2c66ce14ef58934c7140b8b7003b3e55fc3b23128bfdabdf59a02f4ff4dbb89a58cd95cc11310cce6c6ffeb5cacc3afaa8753d4a9cd4afdc447a6ab61bee languageName: node linkType: hard @@ -6228,6 +6270,13 @@ __metadata: languageName: node linkType: hard +"@types/promise.allsettled@npm:^1.0.3": + version: 1.0.6 + resolution: "@types/promise.allsettled@npm:1.0.6" + checksum: 07dca8da25b49c0dc323201095552d86159483dc910dc61c345357c9c196b8498e6be4bf260cc2a9a539a725108df61b53db1d82723ed9886bb7c72fedd65f14 + languageName: node + linkType: hard + "@types/prop-types@npm:*, @types/prop-types@npm:^15.7.12, @types/prop-types@npm:^15.7.14, @types/prop-types@npm:^15.7.15": version: 15.7.15 resolution: "@types/prop-types@npm:15.7.15" @@ -6385,6 +6434,17 @@ __metadata: languageName: node linkType: hard +"@types/serve-static@npm:^1": + version: 1.15.10 + resolution: "@types/serve-static@npm:1.15.10" + dependencies: + "@types/http-errors": "*" + "@types/node": "*" + "@types/send": <1 + checksum: f216eef2aaf2c8eff09f431c420c5c2989eaf0dfc15d106db9fb64c14577a4059af24fb0ae2eba7984d6360950c8cbc1fb52f65608106477729d251481bc96fe + languageName: node + linkType: hard + "@types/sockjs@npm:^0.3.33": version: 0.3.36 resolution: "@types/sockjs@npm:0.3.36" @@ -6436,6 +6496,13 @@ __metadata: languageName: node linkType: hard +"@types/tsscmp@npm:^1.0.0": + version: 1.0.2 + resolution: "@types/tsscmp@npm:1.0.2" + checksum: c02c0bb9f14f550947fea9fa6f9f3c28e6b2d47a6d049a5450ed466fb0c8a685b6ff37d070d4c43d930a5affc9d828f5e16e35cde1e734de228ffd2df76ac2a8 + languageName: node + linkType: hard + "@types/unist@npm:*, @types/unist@npm:^3.0.0": version: 3.0.3 resolution: "@types/unist@npm:3.0.3" @@ -6464,6 +6531,15 @@ __metadata: languageName: node linkType: hard +"@types/ws@npm:^7.4.7": + version: 7.4.7 + resolution: "@types/ws@npm:7.4.7" + dependencies: + "@types/node": "*" + checksum: b4c9b8ad209620c9b21e78314ce4ff07515c0cadab9af101c1651e7bfb992d7fd933bd8b9c99d110738fd6db523ed15f82f29f50b45510288da72e964dedb1a3 + languageName: node + linkType: hard + "@types/ws@npm:^8.5.5": version: 8.18.1 resolution: "@types/ws@npm:8.18.1" @@ -6480,15 +6556,6 @@ __metadata: languageName: node linkType: hard -"@types/yargs@npm:^15.0.4": - version: 15.0.19 - resolution: "@types/yargs@npm:15.0.19" - dependencies: - "@types/yargs-parser": "*" - checksum: 6a509db36304825674f4f00300323dce2b4d850e75819c3db87e9e9f213ac2c4c6ed3247a3e4eed6e8e45b3f191b133a356d3391dd694d9ea27a0507d914ef4c - languageName: node - linkType: hard - "@types/yargs@npm:^16.0.0": version: 16.0.9 resolution: "@types/yargs@npm:16.0.9" @@ -7533,6 +7600,21 @@ __metadata: languageName: node linkType: hard +"array.prototype.map@npm:^1.0.5": + version: 1.0.8 + resolution: "array.prototype.map@npm:1.0.8" + dependencies: + call-bind: ^1.0.8 + call-bound: ^1.0.3 + define-properties: ^1.2.1 + es-abstract: ^1.23.6 + es-array-method-boxes-properly: ^1.0.0 + es-object-atoms: ^1.0.0 + is-string: ^1.1.1 + checksum: df321613636ec8461965d72421569ece78f269460535ced5ec88db9aaa4fc58a9f26e597d72e726f105c55fa4b4b6db0d3156489dc13dfbc7a098b4f1d17b5ab + languageName: node + linkType: hard + "array.prototype.reduce@npm:^1.0.6": version: 1.0.8 resolution: "array.prototype.reduce@npm:1.0.8" @@ -7681,7 +7763,18 @@ __metadata: languageName: node linkType: hard -"axios@npm:^1.11.0, axios@npm:^1.7.9": +"axios@npm:^1.7.4": + version: 1.13.1 + resolution: "axios@npm:1.13.1" + dependencies: + follow-redirects: ^1.15.6 + form-data: ^4.0.4 + proxy-from-env: ^1.1.0 + checksum: fd34e26d22adaba5ce59b02963ecc4f7a6a4a44950014512f3f86dde10ab30df377dd10260ea9d36aafe9f1f87191a95f5b50c3979485be50f10b465c7b1a164 + languageName: node + linkType: hard + +"axios@npm:^1.7.9": version: 1.12.2 resolution: "axios@npm:1.12.2" dependencies: @@ -7887,8 +7980,7 @@ __metadata: resolution: "backend@workspace:src/backend" 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 @@ -8284,7 +8376,7 @@ __metadata: languageName: node linkType: hard -"call-bind@npm:^1.0.7, call-bind@npm:^1.0.8": +"call-bind@npm:^1.0.2, call-bind@npm:^1.0.7, call-bind@npm:^1.0.8": version: 1.0.8 resolution: "call-bind@npm:1.0.8" dependencies: @@ -8330,7 +8422,7 @@ __metadata: languageName: node linkType: hard -"camelcase@npm:^5.0.0, camelcase@npm:^5.3.1": +"camelcase@npm:^5.3.1": version: 5.3.1 resolution: "camelcase@npm:5.3.1" checksum: e6effce26b9404e3c0f301498184f243811c30dfe6d0b9051863bd8e4034d09c8c2923794f280d6827e5aa055f6c434115ff97864a16a963366fb35fd673024b @@ -8629,17 +8721,6 @@ __metadata: languageName: node linkType: hard -"cliui@npm:^6.0.0": - version: 6.0.0 - resolution: "cliui@npm:6.0.0" - dependencies: - string-width: ^4.2.0 - strip-ansi: ^6.0.0 - wrap-ansi: ^6.2.0 - checksum: 4fcfd26d292c9f00238117f39fc797608292ae36bac2168cfee4c85923817d0607fe21b3329a8621e01aedf512c99b7eaa60e363a671ffd378df6649fb48ae42 - languageName: node - linkType: hard - "cliui@npm:^7.0.2": version: 7.0.4 resolution: "cliui@npm:7.0.4" @@ -9565,7 +9646,7 @@ __metadata: languageName: node linkType: hard -"debug@npm:2.6.9, debug@npm:^2.6.0, debug@npm:^2.6.1": +"debug@npm:2.6.9, debug@npm:^2.6.0": version: 2.6.9 resolution: "debug@npm:2.6.9" dependencies: @@ -9595,13 +9676,6 @@ __metadata: languageName: node linkType: hard -"decamelize@npm:^1.2.0": - version: 1.2.0 - resolution: "decamelize@npm:1.2.0" - checksum: ad8c51a7e7e0720c70ec2eeb1163b66da03e7616d7b98c9ef43cce2416395e84c1e9548dd94f5f6ffecfee9f8b94251fc57121a8b021f2ff2469b2bae247b8aa - languageName: node - linkType: hard - "decimal.js-light@npm:^2.4.1": version: 2.5.1 resolution: "decimal.js-light@npm:2.5.1" @@ -9703,7 +9777,7 @@ __metadata: languageName: node linkType: hard -"define-properties@npm:^1.1.3, define-properties@npm:^1.2.1": +"define-properties@npm:^1.1.3, define-properties@npm:^1.2.0, define-properties@npm:^1.2.1": version: 1.2.1 resolution: "define-properties@npm:1.2.1" dependencies: @@ -10236,7 +10310,7 @@ __metadata: languageName: node linkType: hard -"es-abstract@npm:^1.17.2, es-abstract@npm:^1.17.5, es-abstract@npm:^1.23.2, es-abstract@npm:^1.23.3, es-abstract@npm:^1.23.5, es-abstract@npm:^1.23.6, es-abstract@npm:^1.23.9, es-abstract@npm:^1.24.0": +"es-abstract@npm:^1.17.2, es-abstract@npm:^1.17.5, es-abstract@npm:^1.22.1, es-abstract@npm:^1.23.2, es-abstract@npm:^1.23.3, es-abstract@npm:^1.23.5, es-abstract@npm:^1.23.6, es-abstract@npm:^1.23.9, es-abstract@npm:^1.24.0": version: 1.24.0 resolution: "es-abstract@npm:1.24.0" dependencies: @@ -10319,6 +10393,23 @@ __metadata: languageName: node linkType: hard +"es-get-iterator@npm:^1.0.2": + version: 1.1.3 + resolution: "es-get-iterator@npm:1.1.3" + dependencies: + call-bind: ^1.0.2 + get-intrinsic: ^1.1.3 + has-symbols: ^1.0.3 + is-arguments: ^1.1.1 + is-map: ^2.0.2 + is-set: ^2.0.2 + is-string: ^1.0.7 + isarray: ^2.0.5 + stop-iteration-iterator: ^1.0.0 + checksum: 8fa118da42667a01a7c7529f8a8cca514feeff243feec1ce0bb73baaa3514560bd09d2b3438873cf8a5aaec5d52da248131de153b28e2638a061b6e4df13267d + languageName: node + linkType: hard + "es-iterator-helpers@npm:^1.2.1": version: 1.2.1 resolution: "es-iterator-helpers@npm:1.2.1" @@ -11299,6 +11390,13 @@ __metadata: languageName: node linkType: hard +"eventemitter3@npm:^3.1.0": + version: 3.1.2 + resolution: "eventemitter3@npm:3.1.2" + checksum: 81e4e82b8418f5cfd986d2b4a2fa5397ac4eb8134e09bcb47005545e22fdf8e9e61d5c053d34651112245aae411bdfe6d0ad5511da0400743fef5fc38bfcfbe3 + languageName: node + linkType: hard + "eventemitter3@npm:^4.0.0, eventemitter3@npm:^4.0.1, eventemitter3@npm:^4.0.4": version: 4.0.7 resolution: "eventemitter3@npm:4.0.7" @@ -11306,7 +11404,7 @@ __metadata: languageName: node linkType: hard -"eventemitter3@npm:^5.0.1": +"eventemitter3@npm:^5": version: 5.0.1 resolution: "eventemitter3@npm:5.0.1" checksum: 543d6c858ab699303c3c32e0f0f47fc64d360bf73c3daf0ac0b5079710e340d6fe9f15487f94e66c629f5f82cd1a8678d692f3dbb6f6fcd1190e1b97fcad36f8 @@ -11418,7 +11516,7 @@ __metadata: languageName: node linkType: hard -"express@npm:^4.0.0, express@npm:^4.17.3": +"express@npm:^4.17.3, express@npm:^4.21.0": version: 4.21.2 resolution: "express@npm:4.21.2" dependencies: @@ -11795,6 +11893,13 @@ __metadata: languageName: unknown linkType: soft +"finity@npm:^0.5.4": + version: 0.5.4 + resolution: "finity@npm:0.5.4" + checksum: eeea74de356ba963231108c3f8e2de44b4114497389121d603f8c3e8316b8d0772ff06b731af08ef5d6ca6b0e3a0fffab452122eca48837a98a2f7e5548b6be2 + languageName: node + linkType: hard + "flat-cache@npm:^3.0.4": version: 3.2.0 resolution: "flat-cache@npm:3.2.0" @@ -11873,6 +11978,20 @@ __metadata: languageName: node linkType: hard +"form-data@npm:^2.5.0": + version: 2.5.5 + resolution: "form-data@npm:2.5.5" + dependencies: + asynckit: ^0.4.0 + combined-stream: ^1.0.8 + es-set-tostringtag: ^2.1.0 + hasown: ^2.0.2 + mime-types: ^2.1.35 + safe-buffer: ^5.2.1 + checksum: ba6d8467f959c9bf36a52e423256c1e8055a8e650416760f54fa5db261529c3de698a4ce8378dd4fdb71b44be190906d6b73446556cc74e58de8bda01d09e9e7 + languageName: node + linkType: hard + "form-data@npm:^3.0.0": version: 3.0.4 resolution: "form-data@npm:3.0.4" @@ -12184,14 +12303,14 @@ __metadata: languageName: node linkType: hard -"get-caller-file@npm:^2.0.1, get-caller-file@npm:^2.0.5": +"get-caller-file@npm:^2.0.5": version: 2.0.5 resolution: "get-caller-file@npm:2.0.5" checksum: b9769a836d2a98c3ee734a88ba712e62703f1df31b94b784762c433c27a386dd6029ff55c2a920c392e33657d80191edbf18c61487e198844844516f843496b9 languageName: node linkType: hard -"get-intrinsic@npm:^1.2.4, get-intrinsic@npm:^1.2.5, get-intrinsic@npm:^1.2.6, get-intrinsic@npm:^1.2.7, get-intrinsic@npm:^1.3.0": +"get-intrinsic@npm:^1.1.3, get-intrinsic@npm:^1.2.1, get-intrinsic@npm:^1.2.4, get-intrinsic@npm:^1.2.5, get-intrinsic@npm:^1.2.6, get-intrinsic@npm:^1.2.7, get-intrinsic@npm:^1.3.0": version: 1.3.1 resolution: "get-intrinsic@npm:1.3.1" dependencies: @@ -13144,6 +13263,16 @@ __metadata: languageName: node linkType: hard +"is-arguments@npm:^1.1.1": + version: 1.2.0 + resolution: "is-arguments@npm:1.2.0" + dependencies: + call-bound: ^1.0.2 + has-tostringtag: ^1.0.2 + checksum: aae9307fedfe2e5be14aebd0f48a9eeedf6b8c8f5a0b66257b965146d1e94abdc3f08e3dce3b1d908e1fa23c70039a88810ee1d753905758b9b6eebbab0bafeb + languageName: node + linkType: hard + "is-array-buffer@npm:^3.0.4, is-array-buffer@npm:^3.0.5": version: 3.0.5 resolution: "is-array-buffer@npm:3.0.5" @@ -13322,7 +13451,7 @@ __metadata: languageName: node linkType: hard -"is-map@npm:^2.0.3": +"is-map@npm:^2.0.2, is-map@npm:^2.0.3": version: 2.0.3 resolution: "is-map@npm:2.0.3" checksum: e6ce5f6380f32b141b3153e6ba9074892bbbbd655e92e7ba5ff195239777e767a976dcd4e22f864accaf30e53ebf961ab1995424aef91af68788f0591b7396cc @@ -13435,7 +13564,7 @@ __metadata: languageName: node linkType: hard -"is-set@npm:^2.0.3": +"is-set@npm:^2.0.2, is-set@npm:^2.0.3": version: 2.0.3 resolution: "is-set@npm:2.0.3" checksum: 36e3f8c44bdbe9496c9689762cc4110f6a6a12b767c5d74c0398176aa2678d4467e3bf07595556f2dba897751bde1422480212b97d973c7b08a343100b0c0dfe @@ -13451,14 +13580,21 @@ __metadata: languageName: node linkType: hard -"is-stream@npm:^2, is-stream@npm:^2.0.0": +"is-stream@npm:^1.1.0": + version: 1.1.0 + resolution: "is-stream@npm:1.1.0" + checksum: 063c6bec9d5647aa6d42108d4c59723d2bd4ae42135a2d4db6eadbd49b7ea05b750fd69d279e5c7c45cf9da753ad2c00d8978be354d65aa9f6bb434969c6a2ae + languageName: node + linkType: hard + +"is-stream@npm:^2.0.0": version: 2.0.1 resolution: "is-stream@npm:2.0.1" checksum: b8e05ccdf96ac330ea83c12450304d4a591f9958c11fd17bed240af8d5ffe08aedafa4c0f4cfccd4d28dc9d4d129daca1023633d5c11601a6cbc77521f6fae66 languageName: node linkType: hard -"is-string@npm:^1.1.1": +"is-string@npm:^1.0.7, is-string@npm:^1.1.1": version: 1.1.1 resolution: "is-string@npm:1.1.1" dependencies: @@ -13617,6 +13753,23 @@ __metadata: languageName: node linkType: hard +"iterate-iterator@npm:^1.0.1": + version: 1.0.2 + resolution: "iterate-iterator@npm:1.0.2" + checksum: 97b3ed4f2bebe038be57d03277879e406b2c537ceeeab7f82d4167f9a3cff872cc2cc5da3dc9920ff544ca247329d2a4d44121bb8ef8d0807a72176bdbc17c84 + languageName: node + linkType: hard + +"iterate-value@npm:^1.0.2": + version: 1.0.2 + resolution: "iterate-value@npm:1.0.2" + dependencies: + es-get-iterator: ^1.0.2 + iterate-iterator: ^1.0.1 + checksum: 446a4181657df1872e5020713206806757157db6ab375dee05eb4565b66e1244d7a99cd36ce06862261ad4bd059e66ba8192f62b5d1ff41d788c3b61953af6c3 + languageName: node + linkType: hard + "iterator.prototype@npm:^1.1.4": version: 1.1.5 resolution: "iterator.prototype@npm:1.1.5" @@ -14555,7 +14708,7 @@ __metadata: languageName: node linkType: hard -"jsonwebtoken@npm:^9.0.2": +"jsonwebtoken@npm:^9.0.0, jsonwebtoken@npm:^9.0.2": version: 9.0.2 resolution: "jsonwebtoken@npm:9.0.2" dependencies: @@ -16411,7 +16564,7 @@ __metadata: languageName: node linkType: hard -"p-queue@npm:^6": +"p-queue@npm:^6.6.1": version: 6.6.2 resolution: "p-queue@npm:6.6.2" dependencies: @@ -16421,7 +16574,7 @@ __metadata: languageName: node linkType: hard -"p-retry@npm:^4, p-retry@npm:^4.5.0": +"p-retry@npm:^4.0.0, p-retry@npm:^4.5.0": version: 4.6.2 resolution: "p-retry@npm:4.6.2" dependencies: @@ -16619,7 +16772,7 @@ __metadata: languageName: node linkType: hard -"path-to-regexp@npm:^8.0.0": +"path-to-regexp@npm:^8.0.0, path-to-regexp@npm:^8.1.0": version: 8.3.0 resolution: "path-to-regexp@npm:8.3.0" checksum: 73e0d3db449f9899692b10be8480bbcfa294fd575be2d09bce3e63f2f708d1fccd3aaa8591709f8b82062c528df116e118ff9df8f5c52ccc4c2443a90be73e10 @@ -17807,6 +17960,20 @@ __metadata: languageName: node linkType: hard +"promise.allsettled@npm:^1.0.2": + version: 1.0.7 + resolution: "promise.allsettled@npm:1.0.7" + dependencies: + array.prototype.map: ^1.0.5 + call-bind: ^1.0.2 + define-properties: ^1.2.0 + es-abstract: ^1.22.1 + get-intrinsic: ^1.2.1 + iterate-value: ^1.0.2 + checksum: 96186392286e5ab9aef1a1a725c061c8cf268b6cf141f151daa3834bb8e1680f3b159af6536ce59cf80d4a6a5ad1d8371d05759980cc6c90d58800ddb0a7c119 + languageName: node + linkType: hard + "promise@npm:^8.1.0": version: 8.3.0 resolution: "promise@npm:8.3.0" @@ -18769,13 +18936,6 @@ __metadata: languageName: node linkType: hard -"require-main-filename@npm:^2.0.0": - version: 2.0.0 - resolution: "require-main-filename@npm:2.0.0" - checksum: e9e294695fea08b076457e9ddff854e81bffbe248ed34c1eec348b7abbd22a0d02e8d75506559e2265e96978f3c4720bd77a6dad84755de8162b357eb6c778c7 - languageName: node - linkType: hard - "requires-port@npm:^1.0.0": version: 1.0.0 resolution: "requires-port@npm:1.0.0" @@ -19118,7 +19278,7 @@ __metadata: languageName: node linkType: hard -"safe-buffer@npm:5.2.1, safe-buffer@npm:>=5.1.0, safe-buffer@npm:^5.0.1, safe-buffer@npm:^5.1.0, safe-buffer@npm:~5.2.0": +"safe-buffer@npm:5.2.1, safe-buffer@npm:>=5.1.0, safe-buffer@npm:^5.0.1, safe-buffer@npm:^5.1.0, safe-buffer@npm:^5.2.1, safe-buffer@npm:~5.2.0": version: 5.2.1 resolution: "safe-buffer@npm:5.2.1" checksum: b99c4b41fdd67a6aaf280fcd05e9ffb0813654894223afb78a31f14a19ad220bba8aba1cb14eddce1fcfb037155fe6de4e861784eb434f7d11ed58d1e70dd491 @@ -19438,13 +19598,6 @@ __metadata: languageName: node linkType: hard -"set-blocking@npm:^2.0.0": - version: 2.0.0 - resolution: "set-blocking@npm:2.0.0" - checksum: 6e65a05f7cf7ebdf8b7c75b101e18c0b7e3dff4940d480efed8aad3a36a4005140b660fa1d804cb8bce911cac290441dc728084a30504d3516ac2ff7ad607b02 - languageName: node - linkType: hard - "set-function-length@npm:^1.2.2": version: 1.2.2 resolution: "set-function-length@npm:1.2.2" @@ -19897,7 +20050,7 @@ __metadata: languageName: node linkType: hard -"stop-iteration-iterator@npm:^1.1.0": +"stop-iteration-iterator@npm:^1.0.0, stop-iteration-iterator@npm:^1.1.0": version: 1.1.0 resolution: "stop-iteration-iterator@npm:1.1.0" dependencies: @@ -21220,6 +21373,13 @@ __metadata: languageName: node linkType: hard +"undici-types@npm:~7.16.0": + version: 7.16.0 + resolution: "undici-types@npm:7.16.0" + checksum: 1ef68fc6c5bad200c8b6f17de8e5bc5cfdcadc164ba8d7208cd087cfa8583d922d8316a7fd76c9a658c22b4123d3ff847429185094484fbc65377d695c905857 + languageName: node + linkType: hard + "unicode-canonical-property-names-ecmascript@npm:^2.0.0": version: 2.0.1 resolution: "unicode-canonical-property-names-ecmascript@npm:2.0.1" @@ -22251,13 +22411,6 @@ __metadata: languageName: node linkType: hard -"which-module@npm:^2.0.0": - version: 2.0.1 - resolution: "which-module@npm:2.0.1" - checksum: 1967b7ce17a2485544a4fdd9063599f0f773959cca24176dbe8f405e55472d748b7c549cd7920ff6abb8f1ab7db0b0f1b36de1a21c57a8ff741f4f1e792c52be - languageName: node - linkType: hard - "which-typed-array@npm:^1.1.16, which-typed-array@npm:^1.1.19": version: 1.1.19 resolution: "which-typed-array@npm:1.1.19" @@ -22582,7 +22735,7 @@ __metadata: languageName: node linkType: hard -"ws@npm:^7.4.6": +"ws@npm:^7.4.6, ws@npm:^7.5.3": version: 7.5.10 resolution: "ws@npm:7.5.10" peerDependencies: @@ -22633,13 +22786,6 @@ __metadata: languageName: node linkType: hard -"y18n@npm:^4.0.0": - version: 4.0.3 - resolution: "y18n@npm:4.0.3" - checksum: 014dfcd9b5f4105c3bb397c1c8c6429a9df004aa560964fb36732bfb999bfe83d45ae40aeda5b55d21b1ee53d8291580a32a756a443e064317953f08025b1aa4 - languageName: node - linkType: hard - "y18n@npm:^5.0.5": version: 5.0.8 resolution: "y18n@npm:5.0.8" @@ -22675,16 +22821,6 @@ __metadata: languageName: node linkType: hard -"yargs-parser@npm:^18.1.2": - version: 18.1.3 - resolution: "yargs-parser@npm:18.1.3" - dependencies: - camelcase: ^5.0.0 - decamelize: ^1.2.0 - checksum: 60e8c7d1b85814594d3719300ecad4e6ae3796748b0926137bfec1f3042581b8646d67e83c6fc80a692ef08b8390f21ddcacb9464476c39bbdf52e34961dd4d9 - languageName: node - linkType: hard - "yargs-parser@npm:^20.2.2": version: 20.2.9 resolution: "yargs-parser@npm:20.2.9" @@ -22714,25 +22850,6 @@ __metadata: languageName: node linkType: hard -"yargs@npm:^15.3.1": - version: 15.4.1 - resolution: "yargs@npm:15.4.1" - dependencies: - cliui: ^6.0.0 - decamelize: ^1.2.0 - find-up: ^4.1.0 - get-caller-file: ^2.0.1 - require-directory: ^2.1.1 - require-main-filename: ^2.0.0 - set-blocking: ^2.0.0 - string-width: ^4.2.0 - which-module: ^2.0.0 - y18n: ^4.0.0 - yargs-parser: ^18.1.2 - checksum: 40b974f508d8aed28598087720e086ecd32a5fd3e945e95ea4457da04ee9bdb8bdd17fd91acff36dc5b7f0595a735929c514c40c402416bbb87c03f6fb782373 - languageName: node - linkType: hard - "yargs@npm:^16.2.0": version: 16.2.0 resolution: "yargs@npm:16.2.0" From 3dc5ef3c58351555da1cc506bbbc520b6ea98c7c Mon Sep 17 00:00:00 2001 From: Chris Pyle Date: Sun, 21 Dec 2025 19:34:20 -0500 Subject: [PATCH 2/7] emphemeral messages --- src/backend/index.ts | 1 + .../src/controllers/slack.controllers.ts | 86 ++++++++----- src/backend/src/routes/slack.routes.ts | 92 +++++++++++++- src/backend/src/services/slack.services.ts | 118 +++++++++++++----- 4 files changed, 236 insertions(+), 61 deletions(-) diff --git a/src/backend/index.ts b/src/backend/index.ts index ebfe9806af..a1ffa8b3d6 100644 --- a/src/backend/index.ts +++ b/src/backend/index.ts @@ -25,6 +25,7 @@ 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 './src/routes/slack.routes'; const app = express(); diff --git a/src/backend/src/controllers/slack.controllers.ts b/src/backend/src/controllers/slack.controllers.ts index dba18dec79..cec8a072ae 100644 --- a/src/backend/src/controllers/slack.controllers.ts +++ b/src/backend/src/controllers/slack.controllers.ts @@ -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) { @@ -16,40 +16,70 @@ export default class SlackController { } } - static async handleSaboSubmittedAction(body: any) { + /** + * 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 { - // Extract action details from Bolt's BlockAction payload - const [action] = body.actions; + // 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; + } - if (action.type !== 'button') { - // ignore non-button actions for sab submission confirmation + // 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; } - const payload = { - type: body.type, - user: { - id: body.user.id, - username: body.user.username, - name: body.user.name - }, - actions: [ - { - action_id: action.action_id, - value: action.value || '', - type: action.type - } - ], - response_url: body.response_url - }; + // Extract validated fields + const userSlackId = user.id; + const { reimbursementRequestId } = actionValue; - // Handle the action using existing service - await SlackServices.handleSaboSubmittedAction(payload); + // Pass the extracted fields to the service layer for business logic + await SlackServices.handleSaboSubmittedAction(userSlackId, reimbursementRequestId); } catch (error: unknown) { - console.error('Error handling Slack interactive action:', error); const errorMessage = error instanceof Error ? error.message : 'Unknown error'; - console.error('Error details:', errorMessage); - throw error; // Re-throw to be handled by Bolt's error handler + 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; } } } diff --git a/src/backend/src/routes/slack.routes.ts b/src/backend/src/routes/slack.routes.ts index 381b983445..7e796fe8ad 100644 --- a/src/backend/src/routes/slack.routes.ts +++ b/src/backend/src/routes/slack.routes.ts @@ -11,15 +11,103 @@ slackApp.message(async ({ message, logger }: any) => { } }); +/** + * Validates the general structure of a Slack block action payload. + * This validation is action-agnostic and only checks that the required fields exist. + * Action-specific validation (like action_id and value format) happens in the controller. + * + * @param body The Slack action body to validate + * @returns true if valid, false otherwise + */ +function validateSlackActionBody(body: any): boolean { + // Check required top-level fields + if (!body || typeof body !== 'object') { + console.error('Invalid body: not an object'); + return false; + } + + if (body.type !== 'block_actions') { + console.error('Invalid body type:', body.type); + return false; + } + + // Validate user object + if (!body.user || typeof body.user !== 'object') { + console.error('Invalid or missing user object'); + return false; + } + + if (!body.user.id || typeof body.user.id !== 'string') { + console.error('Invalid or missing user.id'); + return false; + } + + if (!body.user.team_id || typeof body.user.team_id !== 'string') { + console.error('Invalid or missing user.team_id'); + return false; + } + + // Validate actions array exists and has at least one action + if (!Array.isArray(body.actions) || body.actions.length === 0) { + console.error('Invalid or empty actions array'); + return false; + } + + const [action] = body.actions; + if (!action.action_id || typeof action.action_id !== 'string') { + console.error('Invalid or missing action_id'); + return false; + } + + if (!action.value || typeof action.value !== 'string') { + console.error('Invalid or missing action value'); + return false; + } + + // Validate container object (for message timestamp and channel) + if (!body.container || typeof body.container !== 'object') { + console.error('Invalid or missing container object'); + return false; + } + + if (!body.container.message_ts || typeof body.container.message_ts !== 'string') { + console.error('Invalid or missing container.message_ts'); + return false; + } + + if (!body.container.channel_id || typeof body.container.channel_id !== 'string') { + console.error('Invalid or missing container.channel_id'); + return false; + } + + // Validate thread_ts if it exists (optional but if present should be string) + if (body.container.thread_ts && typeof body.container.thread_ts !== 'string') { + console.error('Invalid container.thread_ts type'); + return false; + } + + return true; +} + // Register interactive action handler for SABO submission confirmation -slackApp.action('sabo_submitted_confirmation', async ({ ack, body, logger }: any) => { +slackApp.action('sabo_submitted_confirmation', async ({ ack, body, logger, respond }: any) => { await ack(); try { + + // Validate the incoming action body structure + if (!validateSlackActionBody(body)) { + logger.error('Invalid Slack action body structure'); + return; + } + await SlackController.handleSaboSubmittedAction(body); + + // If no error, delete the original message + await respond({ delete_original: true }); } catch (error) { + // Can't pass to normal middleware because not normal request logger.error('Error handling sabo_submitted_confirmation action:', error); - console.error(error); } }); diff --git a/src/backend/src/services/slack.services.ts b/src/backend/src/services/slack.services.ts index a913987c27..16e379a98c 100644 --- a/src/backend/src/services/slack.services.ts +++ b/src/backend/src/services/slack.services.ts @@ -1,9 +1,10 @@ import { getChannelName, getUserName } from '../integrations/slack'; import AnnouncementService from './announcement.services'; -import { Announcement } from 'shared'; +import { Announcement, ReimbursementStatusType } from 'shared'; import prisma from '../prisma/prisma'; import { blockToMentionedUsers, blockToString } from '../utils/slack.utils'; -import { NotFoundException } from '../utils/errors.utils'; +import { InvalidOrganizationException, NotFoundException } from '../utils/errors.utils'; +import ReimbursementRequestService from './reimbursement-requests.services'; /** * Represents a slack event for a message in a channel. @@ -67,63 +68,97 @@ export interface SlackRichTextBlock { } /** - * Represents a Slack interactive payload from a button click + * Represents a Slack block action body structure. + * The general structure is validated in routes, while action-specific fields + * (action_id and value format) are validated in controllers. */ -export interface SlackInteractivePayload { - type: string; +export interface SlackBlockActionBody { + type: 'block_actions'; user: { id: string; username: string; name: string; + team_id: string; }; + api_app_id: string; + token: string; + container: { + type: string; + message_ts: string; + channel_id: string; + is_ephemeral: boolean; + thread_ts?: string; // Optional - if present, the message is in a thread + }; + trigger_id: string; + team: { + id: string; + domain: string; + }; + enterprise: null | { + id: string; + name: string; + }; + is_enterprise_install: boolean; + channel: { + id: string; + name: string; + }; + state: { + values: Record; + }; + response_url: string; actions: Array<{ - action_id: string; - value: string; + action_id: string; // Validated in controller, not routes + block_id: string; + text?: any; + value: string; // Validated for format in controller, not routes + style?: string; type: string; + action_ts: string; }>; - response_url: string; +} + +/** + * Represents the parsed value from a SABO submission action + */ +export interface SaboSubmissionActionValue { + reimbursementRequestId: string; } export default class SlackServices { /** - * Handles the Slack button click for marking a reimbursement request as SABO submitted - * @param payload the Slack interactive payload - * @param organizationId the organization ID + * Handles the Slack button click for marking a reimbursement request as SABO submitted. + * This performs the business logic for processing the SABO submission confirmation. + * + * @param userSlackId The Slack user ID of the user who clicked the button + * @param teamSlackId The Slack team ID (workspace ID) where the action occurred + * @param reimbursementRequestId The ID of the reimbursement request to mark as submitted + * @param interactiveMessageTs The timestamp of the interactive message (to delete after processing) */ - static async handleSaboSubmittedAction(payload: SlackInteractivePayload): Promise { - const [action] = payload.actions; - if (action.action_id !== 'sabo_submitted_confirmation') { - console.log('Ignoring action with id:', action.action_id); - return; - } - - console.log('Processing sabo_submitted_confirmation action'); - const { reimbursementRequestId } = JSON.parse(action.value); - const slackUserId = payload.user.id; - - console.log('Looking up user with slack ID:', slackUserId); - console.log('Reimbursement Request ID:', reimbursementRequestId); - + static async handleSaboSubmittedAction(userSlackId: string, reimbursementRequestId: string): Promise { // Find the user by their slack ID const user = await prisma.user.findFirst({ where: { userSettings: { - slackId: slackUserId + slackId: userSlackId } } }); if (!user) { - console.error('User not found for slack ID:', slackUserId); - throw new NotFoundException('User', slackUserId); + console.error('User not found for slack ID:', userSlackId); + throw new NotFoundException('User', userSlackId); } + // Find the reimbursement request const reimbursementRequest = await prisma.reimbursement_Request.findUnique({ where: { reimbursementRequestId }, include: { - organization: true + organization: true, + reimbursementStatuses: true, + notificationSlackThreads: true } }); @@ -131,9 +166,30 @@ export default class SlackServices { throw new NotFoundException('Reimbursement Request', reimbursementRequestId); } + // Verify that the user's organization matches the reimbursement request's organization + const userOrganization = await prisma.user.findFirst({ + where: { + userId: user.userId + }, + include: { + organizations: true + } + }); + + const hasAccess = userOrganization?.organizations.some( + (org) => org.organizationId === reimbursementRequest.organizationId + ); - // Import the service dynamically to avoid circular dependencies - const ReimbursementRequestService = (await import('./reimbursement-requests.services')).default; + if (!hasAccess) { + throw new InvalidOrganizationException('Reimbursement Request'); + } + + // If the reimbursement request has already been submitted to SABO, just return (message will be deleted by route) + if ( + reimbursementRequest.reimbursementStatuses.some((status) => status.type === ReimbursementStatusType.SABO_SUBMITTED) + ) { + return; + } // Call the service function to mark as SABO submitted await ReimbursementRequestService.markReimbursementRequestAsSaboSubmitted( From bbee16b0b5fafec4f1795a968713117d4acf4a57 Mon Sep 17 00:00:00 2001 From: Chris Pyle Date: Sun, 21 Dec 2025 20:00:29 -0500 Subject: [PATCH 3/7] abstract ephemeral msg function --- src/backend/src/integrations/slack.ts | 49 +++++++-------------------- src/backend/src/utils/slack.utils.ts | 42 +++++++++++++++++++++-- 2 files changed, 53 insertions(+), 38 deletions(-) diff --git a/src/backend/src/integrations/slack.ts b/src/backend/src/integrations/slack.ts index 1cbcbbb049..0f7f8cb3a6 100644 --- a/src/backend/src/integrations/slack.ts +++ b/src/backend/src/integrations/slack.ts @@ -238,51 +238,28 @@ export const getWorkspaceId = async () => { } }; -export async function sendEphemeralConfirmation( +/** + * 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, - reimbursementRequestId: string + text: string, + blocks: any[] ) { try { await slack.chat.postEphemeral({ channel: channelId, user: userId, thread_ts: threadTs, - text: 'Approve the request on concur and then click the button below to mark it as submitted on Finishline.', - blocks: [ - { - type: 'section', - text: { - type: 'mrkdwn', - text: 'Approve the request on concur and then click the button below to mark it as submitted on Finishline.' - } - }, - { - type: 'section', - text: { - type: 'mrkdwn', - text: '' - } - }, - { - type: 'actions', - elements: [ - { - type: 'button', - text: { - type: 'plain_text', - text: "✓ I've approved the request on Concur" - }, - style: 'primary', - action_id: 'sabo_submitted_confirmation', - value: JSON.stringify({ - reimbursementRequestId - }) - } - ] - } - ] + text, + blocks }); } catch (err: unknown) { if (err instanceof Error) { diff --git a/src/backend/src/utils/slack.utils.ts b/src/backend/src/utils/slack.utils.ts index 8da5750c4e..e5ed33dbe4 100644 --- a/src/backend/src/utils/slack.utils.ts +++ b/src/backend/src/utils/slack.utils.ts @@ -16,7 +16,7 @@ import { getUsersInChannel, reactToMessage, replyToMessageInThread, - sendEphemeralConfirmation, + sendEphemeralMessage, sendMessage } from '../integrations/slack'; import { getUserSlackId, getUserSlackMentionOrName } from './users.utils'; @@ -246,7 +246,45 @@ export const sendPendingSaboSubmissionNotification = async ( const userId = await getUserSlackId(financeUserId); if (threads && threads.length !== 0 && userId) { const msgs = threads.map((thread) => - sendEphemeralConfirmation(thread.channelId, thread.timestamp, userId, reimbursementRequestId) + sendEphemeralMessage( + thread.channelId, + thread.timestamp, + userId, + 'Approve the request on concur and then click the button below to mark it as submitted on Finishline.', + [ + { + type: 'section', + text: { + type: 'mrkdwn', + text: 'Approve the request on concur and then click the button below to mark it as submitted on Finishline.' + } + }, + { + type: 'section', + text: { + type: 'mrkdwn', + text: '' + } + }, + { + type: 'actions', + elements: [ + { + type: 'button', + text: { + type: 'plain_text', + text: "✓ I've approved the request on Concur" + }, + style: 'primary', + action_id: 'sabo_submitted_confirmation', + value: JSON.stringify({ + reimbursementRequestId + }) + } + ] + } + ] + ) ); await Promise.all(msgs); } From 4765adfe653a65b34a08c8131f6d128a4cbf8a23 Mon Sep 17 00:00:00 2001 From: Chris Pyle Date: Sun, 21 Dec 2025 20:03:49 -0500 Subject: [PATCH 4/7] optional slack secret --- src/backend/src/integrations/slack.ts | 2 +- src/backend/src/routes/slack.routes.ts | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/backend/src/integrations/slack.ts b/src/backend/src/integrations/slack.ts index 0f7f8cb3a6..64fd7d1f58 100644 --- a/src/backend/src/integrations/slack.ts +++ b/src/backend/src/integrations/slack.ts @@ -8,7 +8,7 @@ const receiver = new ExpressReceiver({ // Initialize the Bolt app const slackApp = new App({ - token: process.env.SLACK_BOT_TOKEN, + token: process.env.SLACK_BOT_TOKEN || '', receiver }); diff --git a/src/backend/src/routes/slack.routes.ts b/src/backend/src/routes/slack.routes.ts index 7e796fe8ad..3f0410b5dc 100644 --- a/src/backend/src/routes/slack.routes.ts +++ b/src/backend/src/routes/slack.routes.ts @@ -94,7 +94,6 @@ slackApp.action('sabo_submitted_confirmation', async ({ ack, body, logger, respo await ack(); try { - // Validate the incoming action body structure if (!validateSlackActionBody(body)) { logger.error('Invalid Slack action body structure'); From f7d78180986a3940cae76d45d2ccffcdef53d71d Mon Sep 17 00:00:00 2001 From: Chris Pyle Date: Tue, 23 Dec 2025 17:05:15 -0500 Subject: [PATCH 5/7] lazy load slack client for testing --- infrastructure/ARCHITECTURE.md | 445 ++++++++ infrastructure/GUIDE.md | 984 ++++++++++++++++++ src/backend/index.ts | 10 +- src/backend/src/integrations/slack.ts | 127 ++- src/backend/src/routes/slack.routes.ts | 73 +- .../reimbursement-requests.services.ts | 2 +- src/backend/src/utils/finance.utils.ts | 44 +- 7 files changed, 1599 insertions(+), 86 deletions(-) create mode 100644 infrastructure/ARCHITECTURE.md create mode 100644 infrastructure/GUIDE.md diff --git a/infrastructure/ARCHITECTURE.md b/infrastructure/ARCHITECTURE.md new file mode 100644 index 0000000000..4d080fa773 --- /dev/null +++ b/infrastructure/ARCHITECTURE.md @@ -0,0 +1,445 @@ +# FinishLine Infrastructure Architecture + +## Overview + +FinishLine's infrastructure is deployed on AWS using Terraform for infrastructure-as-code management. The architecture follows cloud best practices with proper separation of concerns, security isolation, and comprehensive monitoring. + +### Architecture Diagram + +``` + ┌─────────────────────────────────────────┐ + │ Internet │ + └──────────────┬──────────────────────────┘ + │ + ┌──────────────┴──────────────┐ + │ │ + │ HTTPS │ HTTPS + ▼ │ + ┌───────────────────────┐ │ + │ AWS Amplify │ │ + │ (Frontend CDN) │ │ + │ CloudFront + S3 │ │ + └───────────────────────┘ │ + │ + ┌──────────────────────────────────────────┼───────────────────────────┐ + │ VPC (10.0.0.0/16) │ │ + │ │ │ + │ ┌───────────────────────────────────────┼─────────────────────────┐ │ + │ │ Public Subnets (us-east-1a, 1b) │ │ │ + │ │ │ │ │ + │ │ ┌────────────────────────▼───────────────┐ │ │ + │ │ │ Application Load Balancer │ │ │ + │ │ │ (Port 80/443) │ │ │ + │ │ └────────────────┬───────────────────────┘ │ │ + │ │ │ │ │ + │ │ │ HTTP (3001) │ │ + │ │ │ │ │ + │ │ ┌────────────────▼───────────────┐ │ │ + │ │ │ Auto Scaling Group │ │ │ + │ │ │ ┌──────────────────────────┐ │ │ │ + │ │ │ │ EC2 Instance │ │ │ │ + │ │ │ │ t3.small (Docker) │ │ │ │ + │ │ │ │ Backend API │ │ │ │ + │ │ │ └──────────┬───────────────┘ │ │ │ + │ │ │ │ │ │ │ + │ │ │ ┌──────────▼───────────────┐ │ │ │ + │ │ │ │ EC2 Instance │ │ │ │ + │ │ │ │ t3.small (Docker) │ │ │ │ + │ │ │ │ Backend API │ │ │ │ + │ │ │ └──────────┬───────────────┘ │ │ │ + │ │ └─────────────┼──────────────────┘ │ │ + │ └────────────────────────────┼────────────────────────────────────┘ │ + │ │ │ + │ │ PostgreSQL (5432) │ + │ │ │ + │ ┌────────────────────────────┼────────────────────────────────────┐ │ + │ │ Private Subnets (us-east-1a, 1b) │ │ + │ │ │ │ │ + │ │ ┌─────────────▼───────────────┐ │ │ + │ │ │ RDS PostgreSQL │ │ │ + │ │ │ db.t4g.medium │ │ │ + │ │ │ - Multi-AZ Optional │ │ │ + │ │ │ - Automated Backups │ │ │ + │ │ │ - Performance Insights │ │ │ + │ │ └─────────────────────────────┘ │ │ + │ └──────────────────────────────────────────────────────────────────┘ │ + └─────────────────────────────────────────────────────────────────────────┘ + + ┌──────────────────────┐ ┌──────────────────────┐ + │ AWS Secrets Manager │ │ CloudWatch │ + │ - DB Password │ │ - Logs │ + │ - API Keys │ │ - Metrics │ + │ - OAuth Secrets │ │ - Alarms │ + └──────────────────────┘ └──────────────────────┘ +``` + +### Traffic Flow + +1. **Frontend Request**: User browser → AWS Amplify (CloudFront CDN) → Serves static React app +2. **API Request**: User browser → Application Load Balancer (HTTPS:443) → EC2 instances in Auto Scaling Group (HTTP:3001) +3. **Database Query**: Backend application → RDS PostgreSQL in private subnet (port 5432) +4. **Monitoring**: All components → CloudWatch Logs and Metrics +5. **Secrets**: Backend instances retrieve secrets from AWS Secrets Manager at runtime + +## Core Components + +The diagram above shows the complete AWS architecture. Key design decisions include separating frontend and backend hosting, isolating the database in private subnets, and using an Application Load Balancer for traffic distribution and SSL termination. + +### 1. Network Infrastructure (VPC) + +**What it is:** A Virtual Private Cloud providing isolated network space for all infrastructure components. + +**Architecture:** +- VPC with CIDR block `10.0.0.0/16` +- Two Availability Zones for high availability (us-east-1a, us-east-1b) +- Public subnets (for ALB and Elastic Beanstalk instances) +- Private subnets (for RDS database) +- Internet Gateway for external connectivity +- Security groups controlling traffic between components + +**Why this design:** +- **Multi-AZ deployment** ensures the Application Load Balancer can distribute traffic across multiple availability zones, providing fault tolerance +- **Private subnets for RDS** ensure the database is not directly accessible from the internet, following security best practices +- **Public subnets for EB instances** allow them to communicate with external services (Google APIs, Slack) while the ALB handles incoming traffic +- **Security groups** provide stateful firewalls that only allow necessary traffic between components + +**Security Groups:** +- **ALB Security Group:** Allows HTTP (80) and HTTPS (443) from internet +- **EB Instance Security Group:** Allows HTTP and application port (3001) only from ALB +- **RDS Security Group:** Allows PostgreSQL (5432) only from EB instances + +### 2. RDS PostgreSQL Database + +**What it is:** Amazon Relational Database Service running PostgreSQL, providing managed database hosting. + +**Architecture:** +- PostgreSQL 16 (minor versions auto-update) +- Instance class: `db.t4g.medium` (ARM-based, cost-effective) +- 20 GB storage with automatic scaling +- Located in private subnets +- Not publicly accessible (security requirement) +- Automated daily backups with 7-day retention +- Deletion protection enabled + +**Why this design:** +- **Managed service** eliminates operational overhead of database maintenance, patching, and backups +- **Private subnet placement** ensures database cannot be accessed directly from internet +- **ARM-based instances (t4g)** provide better price-performance ratio than x86 instances +- **Automated backups** protect against data loss with point-in-time recovery +- **Performance Insights** enabled for query performance monitoring and optimization +- **Multi-AZ disabled by default** to save costs (can be enabled for true high availability with automatic failover) + +**Access Pattern:** +- Applications connect via SSH tunnel through Elastic Beanstalk instances (see GUIDE.md) +- Backend application connects directly via VPC networking +- No public IP address assigned + +### 3. Elastic Beanstalk (Backend) + +**What it is:** AWS Elastic Beanstalk provides a Platform-as-a-Service for deploying and scaling the Node.js backend application. + +**Architecture:** +- Single-container Docker platform +- Auto-scaling group: 1-4 EC2 instances (t3.small) +- Application Load Balancer distributing traffic +- Rolling deployment with additional batch +- Enhanced health reporting +- CloudWatch Logs integration + +**Why this design:** +- **Elastic Beanstalk abstracts infrastructure management** while maintaining control over underlying resources +- **Docker deployment** provides consistent environment between development and production +- **Auto-scaling** automatically adjusts capacity based on CPU utilization (20-70% thresholds) +- **Rolling deployment with additional batch** ensures zero-downtime deployments by launching new instances before terminating old ones +- **Health checks** continuously monitor application health, automatically replacing unhealthy instances +- **t3.small instances** provide burstable performance suitable for typical application workloads + +**Deployment Process:** +1. Docker image built via GitHub Actions CI/CD +2. Image pushed to Amazon ECR (Elastic Container Registry) +3. Elastic Beanstalk pulls image and deploys to instances +4. Load balancer gradually shifts traffic to new instances +5. Old instances terminated after health checks pass + +**Environment Variables:** +Elastic Beanstalk injects environment variables including: +- Database connection string (from RDS) +- Application secrets (from AWS Secrets Manager) +- Google OAuth credentials +- Slack integration tokens +- Feature flags and configuration + +### 4. AWS Amplify (Frontend) + +**What it is:** AWS Amplify provides modern CI/CD and hosting for the React frontend application. + +**Architecture:** +- Integrated with GitHub repository +- Automatic builds on push to main branch +- Global CDN distribution +- Custom domain support (finishlinebyner.com) +- Environment variable injection at build time + +**Why this design:** +- **GitHub integration** enables automatic deployments on code push, eliminating manual deployment steps +- **CDN distribution** provides fast content delivery globally with edge caching +- **Build-time environment variables** allow different configurations per environment +- **Automatic HTTPS** with managed SSL certificates +- **Atomic deployments** ensure users never see partially deployed code + +**Build Process:** +1. Push to main branch triggers webhook +2. Amplify clones repository and installs dependencies +3. Builds shared package, then frontend package (monorepo support) +4. Deploys to CDN with cache invalidation +5. Updates DNS to point to new deployment + +**Environment Variables Injected:** +- `VITE_REACT_APP_BACKEND_URL`: Backend API endpoint +- `VITE_REACT_APP_GOOGLE_AUTH_CLIENT_ID`: Google OAuth client ID +- `VITE_REACT_APP_CLARITY_PROJECT_ID`: Microsoft Clarity analytics + +### 5. AWS Secrets Manager + +**What it is:** Secure storage for sensitive configuration values and credentials. + +**Secrets Stored:** +- Database master password +- Session secret for application session management +- Google OAuth client secret +- Google Drive and Calendar refresh tokens +- Slack API credentials (bot token, signing secret) +- Application encryption key +- Notification endpoint secret + +**Why this design:** +- **Centralized secret management** eliminates hardcoded credentials in code or configuration files +- **Encryption at rest** using AWS KMS (Key Management Service) +- **Access control via IAM** ensures only authorized services can retrieve secrets +- **Automatic rotation support** (not currently configured but available) +- **7-day recovery window** protects against accidental deletion + +**Access Pattern:** +- Terraform reads secrets from environment variables during deployment +- Terraform passes secrets to Elastic Beanstalk as environment variables +- Backend application reads secrets from environment variables at runtime + +### 6. CloudWatch Monitoring & Logging + +**What it is:** Centralized logging, metrics, and alerting for all infrastructure components. + +**Architecture:** +- **CloudWatch Logs** for application and platform logs +- **CloudWatch Metrics** for performance monitoring +- **CloudWatch Alarms** for automated alerting +- **CloudWatch Dashboard** for real-time visualization +- **SNS Topics** for alarm notifications + +**Monitored Metrics:** + +**Elastic Beanstalk / EC2:** +- CPU utilization (alarm threshold: >80%) +- Memory utilization via CloudWatch Agent (alarm threshold: >75%) +- Disk utilization via CloudWatch Agent (monitoring root filesystem) + +**Application Load Balancer:** +- Request count +- HTTP 5xx error count (alarm threshold: >10 errors in 5 minutes) +- Response times + +**RDS Database:** +- CPU utilization (alarm threshold: >75%) +- Freeable memory (alarm threshold: <500MB) +- Database connections +- Disk I/O (read/write IOPS, latency, throughput) +- Network throughput + +**Why this design:** +- **CloudWatch Agent on EC2 instances** provides visibility into memory and disk metrics not available by default +- **Metric-based alarms** enable proactive issue detection before users are impacted +- **SNS integration** allows email, SMS, or automated remediation responses +- **Log aggregation** simplifies debugging across multiple instances +- **30-day log retention** balances cost and audit requirements + +**CloudWatch Insights Queries:** +Available for advanced log analysis, including: +- Request performance analysis (endpoint duration, database query timing) +- Error rate tracking +- Payload size distribution + +### 7. IAM Roles & Policies + +**What it is:** Identity and Access Management controls defining permissions for AWS services. + +**Roles Created:** + +**Elastic Beanstalk Service Role:** +- Allows EB to manage EC2, load balancers, and auto-scaling +- AWS managed policies: `AWSElasticBeanstalkService`, `AWSElasticBeanstalkEnhancedHealth` + +**EC2 Instance Profile:** +- Used by EB instances to access AWS services +- Permissions for: + - Pulling Docker images from ECR + - Reading secrets from Secrets Manager + - Writing logs to CloudWatch + - Pushing custom metrics via CloudWatch Agent + - SSM Session Manager (for secure shell access) + +**Why this design:** +- **Principle of least privilege** ensures each component has only necessary permissions +- **Service roles** eliminate need for hardcoded AWS credentials +- **Instance profiles** automatically provide credentials to applications running on EC2 +- **SSM Session Manager** provides secure shell access without managing SSH keys + +## Infrastructure State Management + +### Terraform State Backend + +**What it is:** S3 bucket and DynamoDB table for storing and locking Terraform state. + +**Bootstrap Resources:** +- S3 bucket: `finishline-terraform-state` + - Versioning enabled for state history + - Encryption enabled + - All public access blocked + - Lifecycle policy to delete old versions after 90 days +- DynamoDB table: `finishline-terraform-locks` + - On-demand billing + - Prevents concurrent Terraform operations + +**Why this design:** +- **Remote state** enables team collaboration and CI/CD integration +- **State locking** prevents race conditions when multiple users/processes run Terraform +- **Versioning** provides rollback capability for state corruption +- **Encryption** protects sensitive values in state file (database passwords, API keys) + +## Security Considerations + +### Network Security + +1. **Database in private subnet:** RDS has no public IP and cannot be accessed directly from internet +2. **Security group restrictions:** Each component only accepts traffic from authorized sources +3. **Application Load Balancer:** Single entry point for external traffic with HTTPS termination +4. **HTTPS enforcement:** ALB can redirect HTTP to HTTPS (when SSL certificate configured) + +### Secret Management + +1. **No secrets in code:** All sensitive values stored in AWS Secrets Manager +2. **Environment variable injection:** Secrets passed to application at runtime, never committed to Git +3. **IAM-based access control:** Only authorized services can read secrets +4. **Terraform variable protection:** Secrets passed via environment variables, marked as sensitive + +### Access Control + +1. **SSH via SSM:** EC2 instances accessible via AWS Systems Manager Session Manager (no SSH keys required) +2. **Database tunneling:** RDS access only via SSH tunnel through EB instances +3. **IAM role separation:** Different roles for service management vs. application runtime +4. **Deletion protection:** RDS has deletion protection enabled to prevent accidental data loss + +### Monitoring & Compliance + +1. **CloudWatch Logs:** All application and platform logs centralized +2. **CloudWatch Alarms:** Automated alerting for security and performance issues +3. **Automated backups:** RDS daily backups with 7-day retention +4. **Resource tagging:** All resources tagged with Project, Environment, ManagedBy for tracking + +## Module Structure + +The infrastructure is organized into reusable Terraform modules: + +``` +infrastructure/ +├── bootstrap/ # One-time setup for Terraform state backend +├── environments/ +│ └── production/ # Production environment configuration +└── modules/ + ├── network/ # VPC, subnets, security groups + ├── rds/ # PostgreSQL database + ├── elasticbeanstalk/ # Backend application hosting + ├── amplify-frontend/ # Frontend hosting and CI/CD + ├── iam/ # Roles and policies + ├── secrets/ # Secrets Manager integration + ├── monitoring/ # CloudWatch dashboards and alarms + ├── dns/ # Route53 and ACM certificates + └── ecr/ # Docker image registry +``` + +**Why this structure:** +- **Modules enable reusability:** Same modules can be used for staging/production environments +- **Separation of concerns:** Each module has a single responsibility +- **Environment-specific overrides:** Production can have different variables than staging +- **Bootstrap separation:** One-time setup isolated from regular infrastructure + +## Cost Optimization + +Current cost-saving measures: +- ARM-based RDS instances (t4g.medium) for better price/performance +- Single-AZ RDS deployment (Multi-AZ doubles cost) +- On-demand DynamoDB billing (pay only for state lock operations) +- CloudWatch Logs 30-day retention (not indefinite) +- Elastic Beanstalk auto-scaling with minimum 1 instance + +Future optimization opportunities: +- Consider t3.micro EB instances if memory usage remains under 40% +- Enable Amplify PR preview only when needed (generates build minutes) +- Review CloudWatch log retention policies + +## Disaster Recovery + +**Backup Strategy:** +- RDS automated daily backups with 7-day retention +- RDS point-in-time recovery (5-minute RPO) +- Terraform state versioning in S3 +- Docker images stored in ECR with lifecycle policies + +**Recovery Procedures:** +- Database restore from automated backup or snapshot +- Terraform state recovery from S3 versions +- Application redeploy from ECR images or GitHub source + +**RPO/RTO Targets:** +- Database Recovery Point Objective: 5 minutes (via PITR) +- Database Recovery Time Objective: ~30 minutes (restore time) +- Application Recovery Time Objective: ~15 minutes (redeploy) + +## Monitoring Strategy + +### Key Performance Indicators + +1. **Application Health:** + - ALB 5xx error rate (should be <1%) + - ALB request count and latency + - EB instance health checks + +2. **Database Performance:** + - RDS CPU utilization (should stay <60% sustained) + - Database connections (monitor for connection leaks) + - Query latency (via Performance Insights) + +3. **Resource Utilization:** + - EC2 CPU utilization (target 30-50% average for cost efficiency) + - EC2 memory utilization (should stay <70%) + - RDS freeable memory (should stay >1GB) + +### Alarm Response + +All CloudWatch alarms send notifications to SNS topic, which can be configured to: +- Send email notifications to team +- Trigger automated remediation (Lambda functions) +- Integrate with PagerDuty or other on-call systems +- Post to Slack channels + +## Future Enhancements + +Potential infrastructure improvements: +1. **Multi-environment setup:** Add staging environment using same modules +2. **Blue/green deployments:** Zero-downtime database migrations +3. **CloudFront for backend:** Cache static API responses at edge +4. **ElastiCache:** Redis for session storage and caching +5. **S3 for file storage:** Replace Google Drive integration with S3 +6. **WAF integration:** Web Application Firewall for ALB +7. **Scheduled RDS snapshots:** Additional backup layer before major changes +8. **Multi-AZ RDS:** Enable for true high availability (adds cost) +9. **Infrastructure testing:** Terratest for automated infrastructure tests +10. **Secret rotation:** Automate periodic password and token rotation diff --git a/infrastructure/GUIDE.md b/infrastructure/GUIDE.md new file mode 100644 index 0000000000..aa9e8cce8c --- /dev/null +++ b/infrastructure/GUIDE.md @@ -0,0 +1,984 @@ +# FinishLine Infrastructure User Guide + +This guide covers how to work with the FinishLine AWS infrastructure, from initial setup through day-to-day operations. + +## Table of Contents + +1. [Prerequisites](#prerequisites) +2. [Initial Setup](#initial-setup) +3. [Infrastructure Deployment](#infrastructure-deployment) +4. [Database Access via SSH Tunnel](#database-access-via-ssh-tunnel) +5. [Monitoring and Logs](#monitoring-and-logs) +6. [Deployment Process](#deployment-process) +7. [Common Tasks](#common-tasks) +8. [Troubleshooting](#troubleshooting) + +## Prerequisites + +### Required Tools + +Install the following tools before proceeding: + +```bash +# Terraform (Infrastructure as Code) +brew install terraform + +# AWS CLI (AWS command-line interface) +brew install awscli + +# PostgreSQL client (for database access) +brew install postgresql@16 + +# jq (JSON parsing for scripts) +brew install jq +``` + +### AWS Account Setup + +1. **Create/Access AWS Account:** + - Production uses the NER AWS account + - Request access from the software lead + +2. **Configure AWS CLI:** + ```bash + aws configure + ``` + Enter your: + - AWS Access Key ID + - AWS Secret Access Key + - Default region: `us-east-1` + - Default output format: `json` + +3. **Verify AWS Access:** + ```bash + aws sts get-caller-identity + ``` + Should display your AWS account and user information. + +### SSH Key Setup for EB Instances + +Generate an SSH key pair for accessing Elastic Beanstalk instances: + +```bash +# Generate SSH key +ssh-keygen -t rsa -b 4096 -f ~/.ssh/aws-eb -C "your-email@example.com" + +# Set proper permissions +chmod 400 ~/.ssh/aws-eb + +# Import public key to AWS +aws ec2 import-key-pair \ + --key-name aws-eb \ + --public-key-material fileb://~/.ssh/aws-eb.pub +``` + +### Environment Variables for Secrets + +Create a file `~/.finishline-secrets.env` with the following content: + +```bash +# Database +export TF_VAR_db_master_password="your-secure-password" + +# Application +export TF_VAR_session_secret=$(openssl rand -base64 32) +export TF_VAR_encryption_key=$(openssl rand -base64 32) + +# Google OAuth and APIs +export TF_VAR_google_client_id="xxxx.apps.googleusercontent.com" +export TF_VAR_google_client_secret="GOCSPX-xxxxx" +export TF_VAR_drive_refresh_token="1//xxxxx" +export TF_VAR_calendar_refresh_token="1//xxxxx" +export TF_VAR_google_drive_folder_id="xxxx" + +# Slack Integration +export TF_VAR_slack_id="xxxxx" +export TF_VAR_slack_bot_token="xoxb-xxxxx" +export TF_VAR_slack_token_secret="xxxxx" +export TF_VAR_slack_signing_secret="xxxxx" + +# Notification Endpoint +export TF_VAR_notification_endpoint_secret=$(openssl rand -base64 32) + +# GitHub (for Amplify) +export TF_VAR_github_access_token="ghp_xxxxx" + +# User Configuration +export TF_VAR_user_email="your-email@example.com" +export TF_VAR_admin_user_id="your-admin-id" +export TF_VAR_clarity_project_id="xxxxx" +``` + +Load secrets before running Terraform: +```bash +source ~/.finishline-secrets.env +``` + +## Initial Setup + +### Step 1: Bootstrap Terraform State Backend + +The bootstrap creates the S3 bucket and DynamoDB table for storing Terraform state. **This only needs to be done once per AWS account.** + +```bash +cd infrastructure/bootstrap + +# Initialize Terraform +terraform init + +# Review what will be created +terraform plan + +# Create the state backend resources +terraform apply + +# Outputs will show bucket and table names +# Example: +# state_bucket_name = "finishline-terraform-state" +# locks_table_name = "finishline-terraform-locks" +``` + +**Important:** After running bootstrap, **do not delete** the state file (`terraform.tfstate`) in the bootstrap directory. This is stored locally and is needed to manage the state backend infrastructure. + +### Step 2: Configure Production Environment + +```bash +cd infrastructure/environments/production + +# Copy the example variables file +cp terraform.tfvars.example terraform.tfvars + +# Edit terraform.tfvars with your configuration +# Most values have sensible defaults; only change if needed +``` + +Key variables to review in `terraform.tfvars`: +- `eb_instance_type`: Default `t3.small` (can downsize to `t3.micro` if memory usage is consistently low) +- `rds_multi_az`: Default `false` (set to `true` for high availability, doubles cost) +- `use_custom_domain`: Set to `true` if using custom domain +- `hosted_zone_name`: Your Route53 domain (if using custom domain) + +### Step 3: Deploy Infrastructure + +```bash +cd infrastructure/environments/production + +# Load secrets +source ~/.finishline-secrets.env + +# Initialize Terraform (downloads providers and modules) +terraform init + +# Review the execution plan +terraform plan + +# Apply the infrastructure +# This will take 10-15 minutes on first run +terraform apply + +# Type 'yes' when prompted +``` + +**What gets created:** +- VPC with public/private subnets +- RDS PostgreSQL database +- Elastic Beanstalk environment +- Application Load Balancer +- Security groups +- IAM roles and instance profiles +- CloudWatch dashboards and alarms +- Secrets in AWS Secrets Manager +- AWS Amplify app for frontend +- ECR repository for Docker images + +**Important Outputs:** +After apply completes, Terraform outputs important values: +``` +eb_environment_name = "finishline-production-env" +rds_endpoint = "finishline-production-db.xxx.us-east-1.rds.amazonaws.com" +alb_dns_name = "finishline-production-xxx.us-east-1.elb.amazonaws.com" +amplify_app_url = "https://main.xxxxx.amplifyapp.com" +``` + +Save these values for later use. + +## Database Access via SSH Tunnel + +The RDS database is in a private subnet and not publicly accessible. To connect, create an SSH tunnel through an Elastic Beanstalk instance. + +### Using the Tunnel Script + +A convenience script is provided for easy tunneling: + +```bash +cd infrastructure/scripts + +# Make script executable (first time only) +chmod +x tunnel-to-rds.sh + +# Create tunnel (runs in foreground) +./tunnel-to-rds.sh +``` + +**What the script does:** +1. Finds the RDS endpoint from AWS +2. Identifies a running EB instance +3. Gets the instance's public IP +4. Creates SSH tunnel: `localhost:5434` → `RDS:5432` + +**Output example:** +``` +✅ RDS Endpoint: finishline-production-db.xxx.us-east-1.rds.amazonaws.com +✅ Found instance: i-0123456789abcdef0 +✅ Instance IP: 3.123.45.67 + +🚇 Creating SSH tunnel... +Tunnel Details: + Local Port: localhost:5434 + RDS Endpoint: finishline-production-db.xxx.us-east-1.rds.amazonaws.com:5432 + Via Instance: 3.123.45.67 +``` + +### Connecting to Database + +Once the tunnel is running (in one terminal), open a **new terminal** and connect: + +```bash +# Using psql +psql -h localhost -p 5434 -U postgres -d finishline + +# Or using connection string +psql postgresql://postgres:YOUR_PASSWORD@localhost:5434/finishline + +# Or using environment variable +export PGPASSWORD="your-db-password" +psql -h localhost -p 5434 -U postgres -d finishline +``` + +**Note:** Replace `YOUR_PASSWORD` with the value from `TF_VAR_db_master_password`. + +### Common Database Operations + +```sql +-- List all tables +\dt + +-- Describe a table +\d table_name + +-- Run queries +SELECT * FROM "User" LIMIT 10; + +-- Check database size +SELECT pg_size_pretty(pg_database_size('finishline')); + +-- Exit psql +\q +``` + +### Closing the Tunnel + +In the terminal running the tunnel script, press `Ctrl+C` to close the connection. + +### Manual Tunnel (without script) + +If the script doesn't work, create tunnel manually: + +```bash +# Get RDS endpoint +RDS_ENDPOINT=$(aws rds describe-db-instances \ + --db-instance-identifier finishline-production-db \ + --query 'DBInstances[0].Endpoint.Address' \ + --output text) + +# Get EB instance ID +INSTANCE_ID=$(aws elasticbeanstalk describe-environment-resources \ + --environment-name finishline-production-env \ + --query 'EnvironmentResources.Instances[0].Id' \ + --output text) + +# Get instance IP +INSTANCE_IP=$(aws ec2 describe-instances \ + --instance-ids $INSTANCE_ID \ + --query 'Reservations[0].Instances[0].PublicIpAddress' \ + --output text) + +# Create tunnel +ssh -i ~/.ssh/aws-eb \ + -o StrictHostKeyChecking=no \ + -L 5434:$RDS_ENDPOINT:5432 \ + ec2-user@$INSTANCE_IP +``` + +## Monitoring and Logs + +### CloudWatch Dashboard + +View real-time metrics and historical data: + +1. Navigate to CloudWatch in AWS Console +2. Click "Dashboards" in left sidebar +3. Select `finishline-production-dashboard` + +**Dashboard includes:** +- EC2 CPU and memory utilization +- ALB request count and error rates +- RDS performance metrics (CPU, IOPS, connections, latency) +- Disk utilization + +### CloudWatch Alarms + +View active alarms and alarm history: + +1. Navigate to CloudWatch → Alarms +2. Filter by tag: `Project=finishline` + +**Configured alarms:** +- EB CPU high (>80%) +- EB memory high (>75%) +- ALB HTTP 5xx errors (>10 in 5 minutes) +- RDS CPU high (>75%) +- RDS memory low (<500MB) +- RDS read latency high (>10ms) + +Alarms send notifications to the SNS topic: `finishline-production-alerts` + +### Application Logs + +#### Via AWS Console + +1. Navigate to CloudWatch → Log groups +2. Select log group: + - `/aws/elasticbeanstalk/finishline-production/var/log/eb-docker/containers/eb-current-app/` - Application logs + - `/aws/elasticbeanstalk/finishline-production-env` - Platform logs + +3. Select a log stream (one per EB instance) +4. View logs in real-time + +#### Via AWS CLI + +```bash +# Tail application logs +aws logs tail /aws/elasticbeanstalk/finishline-production/var/log/eb-docker/containers/eb-current-app/stdouterr.log --follow + +# Get last 100 log lines +aws logs tail /aws/elasticbeanstalk/finishline-production/var/log/eb-docker/containers/eb-current-app/stdouterr.log --since 1h + +# Search logs for errors +aws logs filter-log-events \ + --log-group-name /aws/elasticbeanstalk/finishline-production/var/log/eb-docker/containers/eb-current-app/stdouterr.log \ + --filter-pattern "ERROR" +``` + +### CloudWatch Insights Queries + +Advanced log analysis with SQL-like queries: + +1. Navigate to CloudWatch → Logs Insights +2. Select log group +3. Run queries: + +**Query: Request performance analysis** +``` +fields @timestamp, message +| filter message like /endpoint_performance/ +| parse message /duration=(?\d+)ms/ +| stats avg(duration), max(duration), min(duration) by bin(5m) +``` + +**Query: Error rate analysis** +``` +fields @timestamp, @message +| filter @message like /ERROR/ +| stats count() by bin(5m) +``` + +**Query: Database query performance** +``` +fields @timestamp, message +| filter message like /db_query_time/ +| parse message /time=(?