From a646f110df04c4b989916cab120aed7e9f7175df Mon Sep 17 00:00:00 2001 From: harish Date: Wed, 24 Sep 2025 16:04:07 -0400 Subject: [PATCH 01/46] progress --- .../ProjectViewContainer/ProjectDetails.tsx | 2 + .../ProjectPage/ProjectSpendingHistory.tsx | 127 ++++++++++++++++++ 2 files changed, 129 insertions(+) create mode 100644 src/frontend/src/pages/ProjectPage/ProjectSpendingHistory.tsx diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectDetails.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectDetails.tsx index fd58452942..08f05e9b33 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectDetails.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectDetails.tsx @@ -20,6 +20,7 @@ import LoadingIndicator from '../../../components/LoadingIndicator'; import PieChart from '../../FinancePage/FinanceComponents/PieChart'; import WarningBanner from '../../../components/WarningBanner'; import { Box } from '@mui/system'; +import ProjectSpendingHistory from '../../ProjectPage/ProjectSpendingHistory'; export const getProjectTeamsName = (project: ProjectPreview): string => { return project.teams.map((team) => team.teamName).join(', '); @@ -136,6 +137,7 @@ const ProjectDetails: React.FC = ({ project }) => { reimbursed={rrData.reimbursed} available={rrData.available} /> + )} diff --git a/src/frontend/src/pages/ProjectPage/ProjectSpendingHistory.tsx b/src/frontend/src/pages/ProjectPage/ProjectSpendingHistory.tsx new file mode 100644 index 0000000000..b16fa1f6c3 --- /dev/null +++ b/src/frontend/src/pages/ProjectPage/ProjectSpendingHistory.tsx @@ -0,0 +1,127 @@ +import React from 'react'; +import { + Box, + Typography, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Paper, + Chip, + Collapse, + IconButton +} from '@mui/material'; +import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; +import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp'; +import { useGetMaterialsForWbsElement } from '../../hooks/bom.hooks'; +import { Material, WbsNumber } from 'shared'; + +interface ProjectSpendingHistoryProps { + wbsNum: WbsNumber; +} + +const ProjectSpendingHistory: React.FC = ({ wbsNum }) => { + const { data: materials, isLoading, isError } = useGetMaterialsForWbsElement(wbsNum); + const [openRows, setOpenRows] = React.useState>({}); + + // Group materials by reimbursementRequestId + const grouped = React.useMemo(() => { + if (!materials) return []; + const map: Record = {}; + materials.forEach((mat) => { + const rr = mat.reimbursementRequest; + if (rr) { + if (!map[rr.reimbursementRequestId]) { + map[rr.reimbursementRequestId] = { request: rr, materials: [] }; + } + map[rr.reimbursementRequestId].materials.push(mat); + } + }); + return Object.values(map); + }, [materials]); + + if (isLoading) return Loading spending history...; + if (isError) return Failed to load spending history.; + if (!grouped.length) return No spending history for this project.; + + const handleToggleRow = (id: string) => { + setOpenRows((prev) => ({ ...prev, [id]: !prev[id] })); + }; + + return ( + + + Spending History + + + + + + + Submitter + Date + Status + Total Amount + + + + {grouped.map(({ request, materials }) => ( + + + + handleToggleRow(request.reimbursementRequestId)}> + {openRows[request.reimbursementRequestId] ? : } + + + {request.recipient?.name || request.recipient?.email || 'N/A'} + {new Date(request.dateCreated).toLocaleDateString()} + + + + ${request.totalCost?.toFixed(2) || '0.00'} + + + + + + + Line Items + +
+ + + Name + Notes + Amount + + + + {materials.map((mat) => ( + + {mat.name} + {mat.notes || '-'} + ${mat.price?.toFixed(2) || '0.00'} + + ))} + +
+
+ + + + + ))} + + + + + ); +}; + +export default ProjectSpendingHistory; From 60954c7f8d105f05c6dc2f04ebc201aec6cedabf Mon Sep 17 00:00:00 2001 From: harish Date: Thu, 25 Sep 2025 17:35:40 -0400 Subject: [PATCH 02/46] #3604 new tab --- .../ProjectViewContainer/ProjectDetails.tsx | 1 - .../ProjectViewContainer/ProjectViewContainer.tsx | 8 ++++++-- .../src/pages/ProjectPage/ProjectSpendingHistory.tsx | 1 - 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectDetails.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectDetails.tsx index 08f05e9b33..f4fa0ad92e 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectDetails.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectDetails.tsx @@ -137,7 +137,6 @@ const ProjectDetails: React.FC = ({ project }) => { reimbursed={rrData.reimbursed} available={rrData.available} /> -
)} diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectViewContainer.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectViewContainer.tsx index 83882f0969..c72d3993ce 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectViewContainer.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectViewContainer.tsx @@ -34,6 +34,7 @@ import { useGetMaterialsForWbsElement } from '../../../hooks/bom.hooks'; import ChangeRequestTab from '../../../components/ChangeRequestTab'; import PartsReviewPage from './PartReview/PartsReviewPage'; import ActionsMenu from '../../../components/ActionsMenu'; +import ProjectSpendingHistory from '../../ProjectPage/ProjectSpendingHistory'; interface ProjectViewContainerProps { project: Project; @@ -179,7 +180,8 @@ const ProjectViewContainer: React.FC = ({ project, en { tabUrlValue: 'changes', tabName: 'Changes' }, { tabUrlValue: 'gantt', tabName: 'Gantt' }, { tabUrlValue: 'change-requests', tabName: 'Change Requests' }, - { tabUrlValue: 'parts-review', tabName: 'Parts Review' } + { tabUrlValue: 'parts-review', tabName: 'Parts Review' }, + { tabUrlValue: 'spending', tabName: 'Spending History' } ]} baseUrl={`${routes.PROJECTS}/${wbsNum}`} defaultTab="overview" @@ -202,8 +204,10 @@ const ProjectViewContainer: React.FC = ({ project, en ) : tab === 6 ? ( - ) : ( + ) : tab === 7 ? ( + ) : ( + )} {deleteModalShow && ( diff --git a/src/frontend/src/pages/ProjectPage/ProjectSpendingHistory.tsx b/src/frontend/src/pages/ProjectPage/ProjectSpendingHistory.tsx index b16fa1f6c3..82aab37180 100644 --- a/src/frontend/src/pages/ProjectPage/ProjectSpendingHistory.tsx +++ b/src/frontend/src/pages/ProjectPage/ProjectSpendingHistory.tsx @@ -26,7 +26,6 @@ const ProjectSpendingHistory: React.FC = ({ wbsNum const { data: materials, isLoading, isError } = useGetMaterialsForWbsElement(wbsNum); const [openRows, setOpenRows] = React.useState>({}); - // Group materials by reimbursementRequestId const grouped = React.useMemo(() => { if (!materials) return []; const map: Record = {}; From a6d160df260b191f170368cd167b18952a931846 Mon Sep 17 00:00:00 2001 From: harish Date: Mon, 6 Oct 2025 17:02:59 -0400 Subject: [PATCH 03/46] filtering --- .../FinanceComponents/PieChart.tsx | 16 +- .../ProjectViewContainer.tsx | 2 +- .../ProjectPage/ProjectSpendingHistory.tsx | 438 ++++++++++++++---- 3 files changed, 361 insertions(+), 95 deletions(-) diff --git a/src/frontend/src/pages/FinancePage/FinanceComponents/PieChart.tsx b/src/frontend/src/pages/FinancePage/FinanceComponents/PieChart.tsx index f80e336eaa..2020650785 100644 --- a/src/frontend/src/pages/FinancePage/FinanceComponents/PieChart.tsx +++ b/src/frontend/src/pages/FinancePage/FinanceComponents/PieChart.tsx @@ -24,10 +24,12 @@ const FinancePieChart: React.FC = ({ available }) => { const [isLegendOpen, setIsLegendOpen] = useState(true); + + // Combine pending categories into one + const pendingReimbursement = pendingLeadership + pendingFinance + submittedToSABO; + const [sectionStates, setSectionStates] = useState([ - { title: 'Pending Leadership', color: '#562016', expanded: false }, - { title: 'Pending Finance', color: '#8e3c2d', expanded: false }, - { title: 'Submitted to SABO', color: '#dd514c', expanded: false }, + { title: 'Pending Reimbursement', color: '#8e3c2d', expanded: false }, { title: 'Reimbursed', color: '#797a7a', expanded: false }, { title: 'Available', color: '#afafaf', expanded: false } ]); @@ -35,9 +37,7 @@ const FinancePieChart: React.FC = ({ const MIN_PERCENTAGE = 0.05; const data = [ - { name: 'Pending Leadership', value: pendingLeadership }, - { name: 'Pending Finance', value: pendingFinance }, - { name: 'Submitted to SABO', value: submittedToSABO }, + { name: 'Pending Reimbursement', value: pendingReimbursement }, { name: 'Reimbursed', value: reimbursed }, { name: 'Available', value: available } ]; @@ -73,9 +73,7 @@ const FinancePieChart: React.FC = ({ } const sectionColorMap = new Map([ - ['Pending Leadership', '#562016'], - ['Pending Finance', '#8e3c2d'], - ['Submitted to SABO', '#dd514c'], + ['Pending Reimbursement', '#8e3c2d'], ['Reimbursed', '#797a7a'], ['Available', '#afafaf'] ]); diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectViewContainer.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectViewContainer.tsx index c72d3993ce..819915ca0e 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectViewContainer.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectViewContainer.tsx @@ -181,7 +181,7 @@ const ProjectViewContainer: React.FC = ({ project, en { tabUrlValue: 'gantt', tabName: 'Gantt' }, { tabUrlValue: 'change-requests', tabName: 'Change Requests' }, { tabUrlValue: 'parts-review', tabName: 'Parts Review' }, - { tabUrlValue: 'spending', tabName: 'Spending History' } + { tabUrlValue: 'spending', tabName: 'Budget' } ]} baseUrl={`${routes.PROJECTS}/${wbsNum}`} defaultTab="overview" diff --git a/src/frontend/src/pages/ProjectPage/ProjectSpendingHistory.tsx b/src/frontend/src/pages/ProjectPage/ProjectSpendingHistory.tsx index 82aab37180..3f4b478c07 100644 --- a/src/frontend/src/pages/ProjectPage/ProjectSpendingHistory.tsx +++ b/src/frontend/src/pages/ProjectPage/ProjectSpendingHistory.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useState, useMemo } from 'react'; import { Box, Typography, @@ -11,35 +11,174 @@ import { Paper, Chip, Collapse, - IconButton + IconButton, + TextField, + Grid, + MenuItem, + FormControl, + InputLabel, + Select, + Button, + Link } from '@mui/material'; import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp'; +import FilterListIcon from '@mui/icons-material/FilterList'; +import ClearIcon from '@mui/icons-material/Clear'; import { useGetMaterialsForWbsElement } from '../../hooks/bom.hooks'; -import { Material, WbsNumber } from 'shared'; +import { useAllReimbursementRequests } from '../../hooks/finance.hooks'; +import { useSingleProject } from '../../hooks/projects.hooks'; +import { Material, WbsNumber, ReimbursementRequest, WBSElementData, OtherProductReason, equalsWbsNumber } from 'shared'; interface ProjectSpendingHistoryProps { wbsNum: WbsNumber; } const ProjectSpendingHistory: React.FC = ({ wbsNum }) => { - const { data: materials, isLoading, isError } = useGetMaterialsForWbsElement(wbsNum); - const [openRows, setOpenRows] = React.useState>({}); - - const grouped = React.useMemo(() => { - if (!materials) return []; - const map: Record = {}; - materials.forEach((mat) => { - const rr = mat.reimbursementRequest; - if (rr) { - if (!map[rr.reimbursementRequestId]) { - map[rr.reimbursementRequestId] = { request: rr, materials: [] }; + const { data: materials, isLoading: materialsLoading, isError: materialsError } = useGetMaterialsForWbsElement(wbsNum); + const { data: allReimbursementRequests, isLoading: rrLoading, isError: rrError } = useAllReimbursementRequests(); + const { data: project, isLoading: projectLoading } = useSingleProject(wbsNum); + const [openRows, setOpenRows] = useState>({}); + const [showFilters, setShowFilters] = useState(false); + + // Filter states + const [submitterFilter, setSubmitterFilter] = useState(''); + const [statusFilter, setStatusFilter] = useState(''); + const [dateFromFilter, setDateFromFilter] = useState(''); + const [dateToFilter, setDateToFilter] = useState(''); + const [amountMinFilter, setAmountMinFilter] = useState(''); + const [amountMaxFilter, setAmountMaxFilter] = useState(''); + + const grouped = useMemo(() => { + if (!allReimbursementRequests || !project) return []; + + // Create a map of reimbursement requests that include both: + // 1. Requests with BOM materials for this project + // 2. Requests with products linked directly to this project + const requestMap = new Map(); + + // First, add reimbursement requests from BOM materials + if (materials) { + materials.forEach((mat) => { + const rr = mat.reimbursementRequest; + if (rr) { + if (!requestMap.has(rr.reimbursementRequestId)) { + requestMap.set(rr.reimbursementRequestId, { request: rr, materials: [] }); + } + requestMap.get(rr.reimbursementRequestId)!.materials.push(mat); } - map[rr.reimbursementRequestId].materials.push(mat); + }); + } + + // Then, add standalone reimbursement requests linked to this project + allReimbursementRequests.forEach((rr) => { + const hasProjectProduct = rr.reimbursementProducts.some((product) => { + const reason = product.reimbursementProductReason; + // Check if it's a WBS element and matches our project + if ((reason as WBSElementData).wbsNum) { + return equalsWbsNumber( + { ...(reason as WBSElementData).wbsNum, workPackageNumber: 0 }, // Convert to project WBS + wbsNum + ); + } + return false; + }); + + if (hasProjectProduct && !requestMap.has(rr.reimbursementRequestId)) { + requestMap.set(rr.reimbursementRequestId, { request: rr, materials: [] }); } }); - return Object.values(map); - }, [materials]); + + return Array.from(requestMap.values()); + }, [materials, allReimbursementRequests, project, wbsNum]); + + // Filter the grouped data + const filteredData = useMemo(() => { + return grouped.filter(({ request }) => { + // Submitter filter + if (submitterFilter) { + const submitterName = `${request.recipient?.firstName} ${request.recipient?.lastName}` || request.recipient?.email || ''; + if (!submitterName.toLowerCase().includes(submitterFilter.toLowerCase())) { + return false; + } + } + + // Status filter + if (statusFilter) { + const currentStatus = request.reimbursementStatuses?.[0]?.type || ''; + if (currentStatus !== statusFilter) { + return false; + } + } + + // Date range filter + const requestDate = new Date(request.dateCreated); + if (dateFromFilter) { + const fromDate = new Date(dateFromFilter); + if (requestDate < fromDate) { + return false; + } + } + if (dateToFilter) { + const toDate = new Date(dateToFilter); + toDate.setHours(23, 59, 59, 999); // End of day + if (requestDate > toDate) { + return false; + } + } + + // Amount range filter + const amount = (request.totalCost || 0) / 100; + if (amountMinFilter) { + const minAmount = parseFloat(amountMinFilter); + if (!isNaN(minAmount) && amount < minAmount) { + return false; + } + } + if (amountMaxFilter) { + const maxAmount = parseFloat(amountMaxFilter); + if (!isNaN(maxAmount) && amount > maxAmount) { + return false; + } + } + + return true; + }); + }, [grouped, submitterFilter, statusFilter, dateFromFilter, dateToFilter, amountMinFilter, amountMaxFilter]); + + // Get unique submitters and statuses for filter dropdowns + const uniqueSubmitters = useMemo(() => { + const submitters = new Set(); + grouped.forEach(({ request }) => { + const name = `${request.recipient?.firstName} ${request.recipient?.lastName}` || request.recipient?.email; + if (name) submitters.add(name); + }); + return Array.from(submitters).sort(); + }, [grouped]); + + const uniqueStatuses = useMemo(() => { + const statuses = new Set(); + grouped.forEach(({ request }) => { + const status = request.reimbursementStatuses?.[0]?.type; + if (status) statuses.add(status); + }); + return Array.from(statuses).sort(); + }, [grouped]); + + const clearFilters = () => { + setSubmitterFilter(''); + setStatusFilter(''); + setDateFromFilter(''); + setDateToFilter(''); + setAmountMinFilter(''); + setAmountMaxFilter(''); + }; + + const hasActiveFilters = + submitterFilter || statusFilter || dateFromFilter || dateToFilter || amountMinFilter || amountMaxFilter; + + const isLoading = materialsLoading || rrLoading || projectLoading; + const isError = materialsError || rrError; if (isLoading) return Loading spending history...; if (isError) return Failed to load spending history.; @@ -51,74 +190,203 @@ const ProjectSpendingHistory: React.FC = ({ wbsNum return ( - - Spending History - - - - - - - Submitter - Date - Status - Total Amount - - - - {grouped.map(({ request, materials }) => ( - - - - handleToggleRow(request.reimbursementRequestId)}> - {openRows[request.reimbursementRequestId] ? : } - - - {request.recipient?.name || request.recipient?.email || 'N/A'} - {new Date(request.dateCreated).toLocaleDateString()} - - - - ${request.totalCost?.toFixed(2) || '0.00'} - - - - - - - Line Items - -
- - - Name - Notes - Amount - - - - {materials.map((mat) => ( - - {mat.name} - {mat.notes || '-'} - ${mat.price?.toFixed(2) || '0.00'} - - ))} - -
-
- - - - - ))} - - - + + + Spending History + + + {hasActiveFilters && ( + + {filteredData.length} of {grouped.length} results + + )} + + {hasActiveFilters && ( + + )} + + + + {showFilters && ( + + + Filter Options + + + + + Submitter + + + + + + Status + + + + + setDateFromFilter(e.target.value)} + InputLabelProps={{ shrink: true }} + /> + + + setDateToFilter(e.target.value)} + InputLabelProps={{ shrink: true }} + /> + + + setAmountMinFilter(e.target.value)} + inputProps={{ step: '0.01', min: '0' }} + /> + + + setAmountMaxFilter(e.target.value)} + inputProps={{ step: '0.01', min: '0' }} + /> + + + + )} + + {filteredData.length === 0 && grouped.length > 0 ? ( + + No spending history matches the current filters. + + + ) : ( + + + + + + Submitter / RR Link + Date + Status + Total Amount + + + + {filteredData.map(({ request, materials }) => { + return ( + + + + handleToggleRow(request.reimbursementRequestId)}> + {openRows[request.reimbursementRequestId] ? : } + + + + + {`${request.recipient?.firstName} ${request.recipient?.lastName}` || request.recipient?.email || 'N/A'} + + + {new Date(request.dateCreated).toLocaleDateString()} + + + + ${(request.totalCost / 100)?.toFixed(2) || '0.00'} + + + + + + + Line Items + + {materials.length > 0 ? ( +
+ + + Name + Notes + Amount + + + + {materials.map((mat) => ( + + {mat.name} + {mat.notes || '-'} + ${(mat.subtotal / 100)?.toFixed(2) || '0.00'} + + ))} + +
+ ) : ( + + This reimbursement request has no associated BOM line items. + It may have been created independently or with non-BOM products. + + )} + + + + + + ); + })} + + +
+ )} ); }; From 0e5d66f87d70eeb70de846ae29a70cabc5295a63 Mon Sep 17 00:00:00 2001 From: harish Date: Mon, 6 Oct 2025 18:17:10 -0400 Subject: [PATCH 04/46] only considering reimbursement requests --- .../ProjectPage/ProjectSpendingHistory.tsx | 79 +++++++++++-------- 1 file changed, 48 insertions(+), 31 deletions(-) diff --git a/src/frontend/src/pages/ProjectPage/ProjectSpendingHistory.tsx b/src/frontend/src/pages/ProjectPage/ProjectSpendingHistory.tsx index 3f4b478c07..afe599b553 100644 --- a/src/frontend/src/pages/ProjectPage/ProjectSpendingHistory.tsx +++ b/src/frontend/src/pages/ProjectPage/ProjectSpendingHistory.tsx @@ -28,7 +28,7 @@ import ClearIcon from '@mui/icons-material/Clear'; import { useGetMaterialsForWbsElement } from '../../hooks/bom.hooks'; import { useAllReimbursementRequests } from '../../hooks/finance.hooks'; import { useSingleProject } from '../../hooks/projects.hooks'; -import { Material, WbsNumber, ReimbursementRequest, WBSElementData, OtherProductReason, equalsWbsNumber } from 'shared'; +import { Material, WbsNumber, ReimbursementRequest, WBSElementData, equalsWbsNumber } from 'shared'; interface ProjectSpendingHistoryProps { wbsNum: WbsNumber; @@ -36,7 +36,13 @@ interface ProjectSpendingHistoryProps { const ProjectSpendingHistory: React.FC = ({ wbsNum }) => { const { data: materials, isLoading: materialsLoading, isError: materialsError } = useGetMaterialsForWbsElement(wbsNum); - const { data: allReimbursementRequests, isLoading: rrLoading, isError: rrError } = useAllReimbursementRequests(); + const { + data: allReimbursementRequests, + isLoading: rrLoading, + isError: rrError, + error: rrErrorDetails + } = useAllReimbursementRequests(); + const { data: project, isLoading: projectLoading } = useSingleProject(wbsNum); const [openRows, setOpenRows] = useState>({}); const [showFilters, setShowFilters] = useState(false); @@ -50,27 +56,13 @@ const ProjectSpendingHistory: React.FC = ({ wbsNum const [amountMaxFilter, setAmountMaxFilter] = useState(''); const grouped = useMemo(() => { + // Return empty array if any required data is missing if (!allReimbursementRequests || !project) return []; - - // Create a map of reimbursement requests that include both: - // 1. Requests with BOM materials for this project - // 2. Requests with products linked directly to this project + + // Create a map of reimbursement requests that are linked to this project const requestMap = new Map(); - - // First, add reimbursement requests from BOM materials - if (materials) { - materials.forEach((mat) => { - const rr = mat.reimbursementRequest; - if (rr) { - if (!requestMap.has(rr.reimbursementRequestId)) { - requestMap.set(rr.reimbursementRequestId, { request: rr, materials: [] }); - } - requestMap.get(rr.reimbursementRequestId)!.materials.push(mat); - } - }); - } - - // Then, add standalone reimbursement requests linked to this project + + // First, find all reimbursement requests that are directly linked to this project allReimbursementRequests.forEach((rr) => { const hasProjectProduct = rr.reimbursementProducts.some((product) => { const reason = product.reimbursementProductReason; @@ -83,12 +75,23 @@ const ProjectSpendingHistory: React.FC = ({ wbsNum } return false; }); - - if (hasProjectProduct && !requestMap.has(rr.reimbursementRequestId)) { + + if (hasProjectProduct) { requestMap.set(rr.reimbursementRequestId, { request: rr, materials: [] }); } }); - + + // Then, add BOM materials ONLY for reimbursement requests that are already linked to this project + if (materials && materials.length > 0) { + materials.forEach((mat) => { + const rr = mat.reimbursementRequest; + if (rr && requestMap.has(rr.reimbursementRequestId)) { + // Only add the material if the RR is already linked to this project + requestMap.get(rr.reimbursementRequestId)!.materials.push(mat); + } + }); + } + return Array.from(requestMap.values()); }, [materials, allReimbursementRequests, project, wbsNum]); @@ -97,7 +100,8 @@ const ProjectSpendingHistory: React.FC = ({ wbsNum return grouped.filter(({ request }) => { // Submitter filter if (submitterFilter) { - const submitterName = `${request.recipient?.firstName} ${request.recipient?.lastName}` || request.recipient?.email || ''; + const submitterName = + `${request.recipient?.firstName} ${request.recipient?.lastName}` || request.recipient?.email || ''; if (!submitterName.toLowerCase().includes(submitterFilter.toLowerCase())) { return false; } @@ -178,10 +182,21 @@ const ProjectSpendingHistory: React.FC = ({ wbsNum submitterFilter || statusFilter || dateFromFilter || dateToFilter || amountMinFilter || amountMaxFilter; const isLoading = materialsLoading || rrLoading || projectLoading; - const isError = materialsError || rrError; if (isLoading) return Loading spending history...; - if (isError) return Failed to load spending history.; + + // Handle specific errors + if (rrError) { + console.error('Failed to load reimbursement requests:', rrErrorDetails); + return Failed to load spending history.; + } + + if (materialsError) { + console.error('Failed to load materials for project'); + return Failed to load spending history.; + } + + // If we have no data but no errors, show "no spending history" if (!grouped.length) return No spending history for this project.; const handleToggleRow = (id: string) => { @@ -326,12 +341,14 @@ const ProjectSpendingHistory: React.FC = ({ wbsNum - - {`${request.recipient?.firstName} ${request.recipient?.lastName}` || request.recipient?.email || 'N/A'} + {`${request.recipient?.firstName} ${request.recipient?.lastName}` || + request.recipient?.email || + 'N/A'} {new Date(request.dateCreated).toLocaleDateString()} @@ -372,8 +389,8 @@ const ProjectSpendingHistory: React.FC = ({ wbsNum ) : ( - This reimbursement request has no associated BOM line items. - It may have been created independently or with non-BOM products. + This reimbursement request has no associated BOM line items. It may have been created + independently or with non-BOM products. )} From 309ee762071facdc4e3cbc87c6c9fc875801dbed Mon Sep 17 00:00:00 2001 From: harish Date: Mon, 6 Oct 2025 18:28:51 -0400 Subject: [PATCH 05/46] linting --- .../FinanceComponents/PieChart.tsx | 1 - .../ProjectViewContainer/ProjectDetails.tsx | 1 - .../ProjectPage/ProjectSpendingHistory.tsx | 23 ++----------------- 3 files changed, 2 insertions(+), 23 deletions(-) diff --git a/src/frontend/src/pages/FinancePage/FinanceComponents/PieChart.tsx b/src/frontend/src/pages/FinancePage/FinanceComponents/PieChart.tsx index 2020650785..5376dacc44 100644 --- a/src/frontend/src/pages/FinancePage/FinanceComponents/PieChart.tsx +++ b/src/frontend/src/pages/FinancePage/FinanceComponents/PieChart.tsx @@ -25,7 +25,6 @@ const FinancePieChart: React.FC = ({ }) => { const [isLegendOpen, setIsLegendOpen] = useState(true); - // Combine pending categories into one const pendingReimbursement = pendingLeadership + pendingFinance + submittedToSABO; const [sectionStates, setSectionStates] = useState([ diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectDetails.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectDetails.tsx index 70102f142d..5cef1c4416 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectDetails.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectDetails.tsx @@ -20,7 +20,6 @@ import LoadingIndicator from '../../../components/LoadingIndicator'; import PieChart from '../../FinancePage/FinanceComponents/PieChart'; import WarningBanner from '../../../components/WarningBanner'; import { Box } from '@mui/system'; -import ProjectSpendingHistory from '../../ProjectPage/ProjectSpendingHistory'; export const getProjectTeamsName = (project: { teams: { teamName: string }[] }): string => { return project.teams.map((team) => team.teamName).join(', '); diff --git a/src/frontend/src/pages/ProjectPage/ProjectSpendingHistory.tsx b/src/frontend/src/pages/ProjectPage/ProjectSpendingHistory.tsx index afe599b553..9674f60a32 100644 --- a/src/frontend/src/pages/ProjectPage/ProjectSpendingHistory.tsx +++ b/src/frontend/src/pages/ProjectPage/ProjectSpendingHistory.tsx @@ -47,7 +47,6 @@ const ProjectSpendingHistory: React.FC = ({ wbsNum const [openRows, setOpenRows] = useState>({}); const [showFilters, setShowFilters] = useState(false); - // Filter states const [submitterFilter, setSubmitterFilter] = useState(''); const [statusFilter, setStatusFilter] = useState(''); const [dateFromFilter, setDateFromFilter] = useState(''); @@ -56,22 +55,15 @@ const ProjectSpendingHistory: React.FC = ({ wbsNum const [amountMaxFilter, setAmountMaxFilter] = useState(''); const grouped = useMemo(() => { - // Return empty array if any required data is missing if (!allReimbursementRequests || !project) return []; - // Create a map of reimbursement requests that are linked to this project const requestMap = new Map(); - // First, find all reimbursement requests that are directly linked to this project allReimbursementRequests.forEach((rr) => { const hasProjectProduct = rr.reimbursementProducts.some((product) => { const reason = product.reimbursementProductReason; - // Check if it's a WBS element and matches our project if ((reason as WBSElementData).wbsNum) { - return equalsWbsNumber( - { ...(reason as WBSElementData).wbsNum, workPackageNumber: 0 }, // Convert to project WBS - wbsNum - ); + return equalsWbsNumber((reason as WBSElementData).wbsNum, { ...wbsNum, workPackageNumber: 0 }); } return false; }); @@ -81,12 +73,10 @@ const ProjectSpendingHistory: React.FC = ({ wbsNum } }); - // Then, add BOM materials ONLY for reimbursement requests that are already linked to this project if (materials && materials.length > 0) { materials.forEach((mat) => { const rr = mat.reimbursementRequest; if (rr && requestMap.has(rr.reimbursementRequestId)) { - // Only add the material if the RR is already linked to this project requestMap.get(rr.reimbursementRequestId)!.materials.push(mat); } }); @@ -95,10 +85,8 @@ const ProjectSpendingHistory: React.FC = ({ wbsNum return Array.from(requestMap.values()); }, [materials, allReimbursementRequests, project, wbsNum]); - // Filter the grouped data const filteredData = useMemo(() => { return grouped.filter(({ request }) => { - // Submitter filter if (submitterFilter) { const submitterName = `${request.recipient?.firstName} ${request.recipient?.lastName}` || request.recipient?.email || ''; @@ -107,15 +95,12 @@ const ProjectSpendingHistory: React.FC = ({ wbsNum } } - // Status filter if (statusFilter) { const currentStatus = request.reimbursementStatuses?.[0]?.type || ''; if (currentStatus !== statusFilter) { return false; } } - - // Date range filter const requestDate = new Date(request.dateCreated); if (dateFromFilter) { const fromDate = new Date(dateFromFilter); @@ -125,13 +110,12 @@ const ProjectSpendingHistory: React.FC = ({ wbsNum } if (dateToFilter) { const toDate = new Date(dateToFilter); - toDate.setHours(23, 59, 59, 999); // End of day + toDate.setHours(23, 59, 59, 999); if (requestDate > toDate) { return false; } } - // Amount range filter const amount = (request.totalCost || 0) / 100; if (amountMinFilter) { const minAmount = parseFloat(amountMinFilter); @@ -150,7 +134,6 @@ const ProjectSpendingHistory: React.FC = ({ wbsNum }); }, [grouped, submitterFilter, statusFilter, dateFromFilter, dateToFilter, amountMinFilter, amountMaxFilter]); - // Get unique submitters and statuses for filter dropdowns const uniqueSubmitters = useMemo(() => { const submitters = new Set(); grouped.forEach(({ request }) => { @@ -185,7 +168,6 @@ const ProjectSpendingHistory: React.FC = ({ wbsNum if (isLoading) return Loading spending history...; - // Handle specific errors if (rrError) { console.error('Failed to load reimbursement requests:', rrErrorDetails); return Failed to load spending history.; @@ -196,7 +178,6 @@ const ProjectSpendingHistory: React.FC = ({ wbsNum return Failed to load spending history.; } - // If we have no data but no errors, show "no spending history" if (!grouped.length) return No spending history for this project.; const handleToggleRow = (id: string) => { From 2b974326f910c4ca6dbfd7d711bdb1ee70bbe3e0 Mon Sep 17 00:00:00 2001 From: harish Date: Wed, 29 Oct 2025 18:18:17 -0400 Subject: [PATCH 06/46] progress --- .../ProjectPage/ProjectSpendingHistory.tsx | 153 ++++++++++++++---- 1 file changed, 126 insertions(+), 27 deletions(-) diff --git a/src/frontend/src/pages/ProjectPage/ProjectSpendingHistory.tsx b/src/frontend/src/pages/ProjectPage/ProjectSpendingHistory.tsx index 9674f60a32..52da4537fb 100644 --- a/src/frontend/src/pages/ProjectPage/ProjectSpendingHistory.tsx +++ b/src/frontend/src/pages/ProjectPage/ProjectSpendingHistory.tsx @@ -19,7 +19,10 @@ import { InputLabel, Select, Button, - Link + Link, + LinearProgress, + Card, + CardContent } from '@mui/material'; import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp'; @@ -101,14 +104,18 @@ const ProjectSpendingHistory: React.FC = ({ wbsNum return false; } } - const requestDate = new Date(request.dateCreated); - if (dateFromFilter) { + const requestDate = + request.reimbursementStatuses && request.reimbursementStatuses.length > 0 + ? new Date(Math.min(...request.reimbursementStatuses.map((status) => new Date(status.dateCreated).getTime()))) + : null; + + if (dateFromFilter && requestDate) { const fromDate = new Date(dateFromFilter); if (requestDate < fromDate) { return false; } } - if (dateToFilter) { + if (dateToFilter && requestDate) { const toDate = new Date(dateToFilter); toDate.setHours(23, 59, 59, 999); if (requestDate > toDate) { @@ -161,6 +168,22 @@ const ProjectSpendingHistory: React.FC = ({ wbsNum setAmountMaxFilter(''); }; + const budgetInfo = useMemo(() => { + if (!project || !grouped.length) return null; + + const totalBudget = project.budget; // Budget is in cents + const totalSpent = grouped.reduce((sum, { request }) => sum + (request.totalCost || 0), 0); // Total cost is in cents + const budgetRemaining = totalBudget - totalSpent; + const budgetUsedPercentage = totalBudget > 0 ? (totalSpent / totalBudget) * 100 : 0; + + return { + totalBudget: totalBudget / 100, // Convert to dollars + totalSpent: totalSpent / 100, // Convert to dollars + budgetRemaining: budgetRemaining / 100, // Convert to dollars + budgetUsedPercentage: Math.min(budgetUsedPercentage, 100) // Cap at 100% + }; + }, [project, grouped]); + const hasActiveFilters = submitterFilter || statusFilter || dateFromFilter || dateToFilter || amountMinFilter || amountMaxFilter; @@ -212,6 +235,67 @@ const ProjectSpendingHistory: React.FC = ({ wbsNum + {budgetInfo && ( + + + + + + Budget Overview + + + + + Spent: ${budgetInfo.totalSpent.toFixed(2)} + + + Total Budget: ${budgetInfo.totalBudget.toFixed(2)} + + + 90 + ? '#f44336' + : budgetInfo.budgetUsedPercentage > 75 + ? '#ff9800' + : '#4caf50' + } + }} + /> + + + + + + Budget Remaining + + = 0 ? '#4caf50' : '#f44336', + fontWeight: 'bold' + }} + > + ${budgetInfo.budgetRemaining.toFixed(2)} + + + ({budgetInfo.budgetUsedPercentage.toFixed(1)}% used) + + + + + + + )} + {showFilters && ( @@ -306,20 +390,24 @@ const ProjectSpendingHistory: React.FC = ({ wbsNum Submitter / RR Link - Date + Description + Date Submitted Status Total Amount {filteredData.map(({ request, materials }) => { + const hasMaterials = materials.length > 0; return ( - handleToggleRow(request.reimbursementRequestId)}> - {openRows[request.reimbursementRequestId] ? : } - + {hasMaterials ? ( + handleToggleRow(request.reimbursementRequestId)}> + {openRows[request.reimbursementRequestId] ? : } + + ) : null} = ({ wbsNum 'N/A'} - {new Date(request.dateCreated).toLocaleDateString()} + + + {request.accountCode?.name || + request.reimbursementProducts?.map((p) => p.name).join(', ') || + request.vendor?.name || + 'No description available'} + + + + {request.reimbursementStatuses && request.reimbursementStatuses.length > 0 + ? new Date( + Math.min( + ...request.reimbursementStatuses.map((status) => new Date(status.dateCreated).getTime()) + ) + ).toLocaleDateString() + : ''} + = ({ wbsNum ${(request.totalCost / 100)?.toFixed(2) || '0.00'} - - - - - - Line Items - - {materials.length > 0 ? ( + {hasMaterials && ( + + + + + + Line Items + @@ -368,16 +472,11 @@ const ProjectSpendingHistory: React.FC = ({ wbsNum ))}
- ) : ( - - This reimbursement request has no associated BOM line items. It may have been created - independently or with non-BOM products. - - )} -
-
-
-
+
+
+
+
+ )}
); })} From fbbc94dcafd3d1fe2448f0ab074c2ef49031b83e Mon Sep 17 00:00:00 2001 From: Chris Pyle Date: Sat, 1 Nov 2025 10:00:46 -0400 Subject: [PATCH 07/46] 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 daffec2d07b3e818c66bb8258fa457ce6ac654ee Mon Sep 17 00:00:00 2001 From: Raphael Bessin Date: Tue, 9 Dec 2025 14:14:37 -0500 Subject: [PATCH 08/46] #3661 added seed data for blocked items --- src/backend/src/prisma/seed.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/backend/src/prisma/seed.ts b/src/backend/src/prisma/seed.ts index 95f880bf30..804b6946c1 100644 --- a/src/backend/src/prisma/seed.ts +++ b/src/backend/src/prisma/seed.ts @@ -1379,7 +1379,7 @@ const performSeed: () => Promise = async () => { await ChangeRequestsService.reviewChangeRequest(joeShmoe, project3WP1ActivationCrId, 'Approved!', true, ner, null); /** Work Package 2 */ - await seedWorkPackage( + const { workPackage: project3WP2 } = await seedWorkPackage( lexLuther, 'Laser Canon Research', changeRequestProject7Id, @@ -1404,7 +1404,7 @@ const performSeed: () => Promise = async () => { WorkPackageStage.Testing, weeksFromNow(3).toISOString().split('T')[0], 4, - [], + [project3WP1.wbsNum, project3WP2.wbsNum], [], zatanna, WbsElementStatus.Active, From f2a8a58eb88229644f772de7f5ab05503a6a6f59 Mon Sep 17 00:00:00 2001 From: Raphael Bessin Date: Tue, 9 Dec 2025 21:25:08 -0500 Subject: [PATCH 09/46] #3661 fixed duplication issues --- .../GanttTaskBar/BlockedTaskBarView.tsx | 70 ------------------ .../GanttTaskBar/GanttTaskBar.tsx | 71 +++++++----------- .../GanttTaskBar/GanttTaskBarView.tsx | 18 ----- .../GanttChart/GanttChartSection.tsx | 72 ++++++++++--------- src/frontend/src/utils/gantt.utils.tsx | 10 ++- 5 files changed, 68 insertions(+), 173 deletions(-) delete mode 100644 src/frontend/src/pages/GanttPage/GanttChart/GanttChartComponents/GanttTaskBar/BlockedTaskBarView.tsx diff --git a/src/frontend/src/pages/GanttPage/GanttChart/GanttChartComponents/GanttTaskBar/BlockedTaskBarView.tsx b/src/frontend/src/pages/GanttPage/GanttChart/GanttChartComponents/GanttTaskBar/BlockedTaskBarView.tsx deleted file mode 100644 index b2401a8ee4..0000000000 --- a/src/frontend/src/pages/GanttPage/GanttChart/GanttChartComponents/GanttTaskBar/BlockedTaskBarView.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import { - GanttTask, - HighlightTaskComparator, - OnMouseOverOptions, - RequestEventChange -} from '../../../../../utils/gantt.utils'; -import GanttTaskBarDisplay from './GanttTaskBarDisplay'; - -interface BlockedGanttTaskViewProps { - task: GanttTask; - days: Date[]; - getStartCol: (start: Date) => number; - getEndCol: (end: Date) => number; - handleOnMouseOver: (e: React.MouseEvent, task: OnMouseOverOptions) => void; - handleOnMouseLeave: () => void; - onShowChildrenToggle: () => void; - highlightedChange?: RequestEventChange; - highlightTaskComparator: HighlightTaskComparator; - highlightSubtaskComparator: HighlightTaskComparator; -} - -const BlockedGanttTaskView = ({ - task, - days, - getStartCol, - getEndCol, - handleOnMouseOver, - handleOnMouseLeave, - onShowChildrenToggle, - highlightedChange, - highlightSubtaskComparator, - highlightTaskComparator -}: BlockedGanttTaskViewProps) => { - return ( - <> - - {task.blocking.map((child) => { - return ( - - ); - })} - - ); -}; - -export default BlockedGanttTaskView; diff --git a/src/frontend/src/pages/GanttPage/GanttChart/GanttChartComponents/GanttTaskBar/GanttTaskBar.tsx b/src/frontend/src/pages/GanttPage/GanttChart/GanttChartComponents/GanttTaskBar/GanttTaskBar.tsx index 4b4533f2d1..0999d23962 100644 --- a/src/frontend/src/pages/GanttPage/GanttChart/GanttChartComponents/GanttTaskBar/GanttTaskBar.tsx +++ b/src/frontend/src/pages/GanttPage/GanttChart/GanttChartComponents/GanttTaskBar/GanttTaskBar.tsx @@ -5,9 +5,6 @@ import GanttTaskBarEdit from './GanttTaskBarEdit'; import GanttTaskBarView from './GanttTaskBarView'; -import { ArcherContainer } from 'react-archer'; -import { useRef } from 'react'; -import { ArcherContainerHandle } from 'react-archer/lib/ArcherContainer/ArcherContainer.types'; import { GanttChange, GanttTask, @@ -46,14 +43,11 @@ const GanttTaskBar = ({ highlightSubtaskComparator, highlightTaskComparator }: GanttTaskBarProps) => { - const archerRef = useRef(null); - const getStartCol = (start: Date) => { const startCol = days.findIndex((day) => dateToString(day) === dateToString(getMonday(start))) + 1; return startCol; }; - // if the end date doesn't exist within the timeframe, have it span to the end const getEndCol = (end: Date) => { const endCol = days.findIndex((day) => dateToString(day) === dateToString(getMonday(end))) === -1 @@ -62,45 +56,34 @@ const GanttTaskBar = ({ return endCol; }; - const handleChange = (change: GanttChange) => { - createChange(change); - setTimeout(() => { - if (archerRef.current) { - archerRef.current.refreshScreen(); - } - }, 100); // wait for the change to be added to the state and the DOM to update - }; - return ( - -
- {isEditMode ? ( - - ) : ( - - )} -
-
+
+ {isEditMode ? ( + + ) : ( + + )} +
); }; diff --git a/src/frontend/src/pages/GanttPage/GanttChart/GanttChartComponents/GanttTaskBar/GanttTaskBarView.tsx b/src/frontend/src/pages/GanttPage/GanttChart/GanttChartComponents/GanttTaskBar/GanttTaskBarView.tsx index 6e17b69cce..ef6551fbfe 100644 --- a/src/frontend/src/pages/GanttPage/GanttChart/GanttChartComponents/GanttTaskBar/GanttTaskBarView.tsx +++ b/src/frontend/src/pages/GanttPage/GanttChart/GanttChartComponents/GanttTaskBar/GanttTaskBarView.tsx @@ -6,7 +6,6 @@ import { } from '../../../../../utils/gantt.utils'; import { Collapse } from '@mui/material'; import GanttTaskBar from './GanttTaskBar'; -import BlockedGanttTaskView from './BlockedTaskBarView'; import GanttTaskBarDisplay from './GanttTaskBarDisplay'; interface GanttTaskBarViewProps { @@ -74,23 +73,6 @@ const GanttTaskBarView = ({ ); })} - {task.blocking.map((blocking) => { - return ( - - ); - })} ); }; diff --git a/src/frontend/src/pages/GanttPage/GanttChart/GanttChartSection.tsx b/src/frontend/src/pages/GanttPage/GanttChart/GanttChartSection.tsx index a42ea71867..b415d3cf9c 100644 --- a/src/frontend/src/pages/GanttPage/GanttChart/GanttChartSection.tsx +++ b/src/frontend/src/pages/GanttPage/GanttChart/GanttChartSection.tsx @@ -15,6 +15,7 @@ import { Box, Typography } from '@mui/material'; import { useState } from 'react'; import GanttTaskBar from './GanttChartComponents/GanttTaskBar/GanttTaskBar'; import GanttToolTip from './GanttChartComponents/GanttToolTip'; +import { ArcherContainer } from 'react-archer'; interface GanttChartSectionProps { start: Date; @@ -64,42 +65,43 @@ const GanttChartSection = ({ }; return tasks.length > 0 ? ( - - - {tasks.map((task) => { - return ( - - onShowChildrenToggle(task)} - onAddTaskPressed={onAddTaskPressed} - showChildren={shouldShowChildren(task)} - highlightedChange={highlightedChange} - highlightSubtaskComparator={highlightSubtaskComparator} - highlightTaskComparator={highlightTaskComparator} - /> - - ); - })} + + + + {tasks.map((task) => { + return ( + + onShowChildrenToggle(task)} + onAddTaskPressed={onAddTaskPressed} + showChildren={shouldShowChildren(task)} + highlightedChange={highlightedChange} + highlightSubtaskComparator={highlightSubtaskComparator} + highlightTaskComparator={highlightTaskComparator} + /> + + ); + })} + + {currentTooltipOptions && ( + + )} - {currentTooltipOptions && ( - - )} - + ) : ( No Projects to Display ); diff --git a/src/frontend/src/utils/gantt.utils.tsx b/src/frontend/src/utils/gantt.utils.tsx index 2a8c06c499..bec4d16409 100644 --- a/src/frontend/src/utils/gantt.utils.tsx +++ b/src/frontend/src/utils/gantt.utils.tsx @@ -411,7 +411,7 @@ const getBlockingGanttTasks = ( export const transformTaskToGanttTask = (task: T, end: Date): GanttTask => { return { - id: uuidv4(), + id: task.taskId, element: task, name: task.title, @@ -441,7 +441,7 @@ export const transformWorkPackageToGanttTask = ( allWorkPackages: T[] ): GanttTask => { return { - id: uuidv4(), + id: workPackage.id, element: workPackage, name: workPackage.name, @@ -477,7 +477,7 @@ export const transformProjectToGanttTask = ( const taskList = hideTasks ? [] : project.tasks; return { - id: uuidv4(), + id: project.id, element: project, name: project.name, @@ -485,9 +485,7 @@ export const transformProjectToGanttTask = ( end: endDate, blocking: [], children: [ - ...project.workPackages - .filter((workPackage) => workPackage.blockedBy.length === 0) - .map((workPackage) => transformWorkPackageToGanttTask(workPackage, project.workPackages)), + ...project.workPackages.map((workPackage) => transformWorkPackageToGanttTask(workPackage, project.workPackages)), ...taskList.map((task) => transformTaskToGanttTask(task, endDate)) ], overlays: [ From cf2047d9f59e78aa77179970d50861f641d1b768 Mon Sep 17 00:00:00 2001 From: harish Date: Fri, 19 Dec 2025 10:28:02 -0500 Subject: [PATCH 10/46] #3604 finishing touches --- .../ProjectPage/ProjectSpendingHistory.tsx | 514 +++++------------- 1 file changed, 129 insertions(+), 385 deletions(-) diff --git a/src/frontend/src/pages/ProjectPage/ProjectSpendingHistory.tsx b/src/frontend/src/pages/ProjectPage/ProjectSpendingHistory.tsx index 52da4537fb..9a8b3d219b 100644 --- a/src/frontend/src/pages/ProjectPage/ProjectSpendingHistory.tsx +++ b/src/frontend/src/pages/ProjectPage/ProjectSpendingHistory.tsx @@ -1,68 +1,23 @@ -import React, { useState, useMemo } from 'react'; -import { - Box, - Typography, - Table, - TableBody, - TableCell, - TableContainer, - TableHead, - TableRow, - Paper, - Chip, - Collapse, - IconButton, - TextField, - Grid, - MenuItem, - FormControl, - InputLabel, - Select, - Button, - Link, - LinearProgress, - Card, - CardContent -} from '@mui/material'; -import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; -import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp'; -import FilterListIcon from '@mui/icons-material/FilterList'; -import ClearIcon from '@mui/icons-material/Clear'; -import { useGetMaterialsForWbsElement } from '../../hooks/bom.hooks'; +import React, { useMemo } from 'react'; +import { Box, Typography, Link, LinearProgress, Card, CardContent, Grid, Chip } from '@mui/material'; +import { DataGrid, GridColDef, GridRenderCellParams } from '@mui/x-data-grid'; import { useAllReimbursementRequests } from '../../hooks/finance.hooks'; import { useSingleProject } from '../../hooks/projects.hooks'; -import { Material, WbsNumber, ReimbursementRequest, WBSElementData, equalsWbsNumber } from 'shared'; +import { WbsNumber, ReimbursementRequest, WBSElementData, equalsWbsNumber } from 'shared'; +import LoadingIndicator from '../../components/LoadingIndicator'; interface ProjectSpendingHistoryProps { wbsNum: WbsNumber; } const ProjectSpendingHistory: React.FC = ({ wbsNum }) => { - const { data: materials, isLoading: materialsLoading, isError: materialsError } = useGetMaterialsForWbsElement(wbsNum); - const { - data: allReimbursementRequests, - isLoading: rrLoading, - isError: rrError, - error: rrErrorDetails - } = useAllReimbursementRequests(); - + const { data: allReimbursementRequests, isLoading: rrLoading, isError: rrError } = useAllReimbursementRequests(); const { data: project, isLoading: projectLoading } = useSingleProject(wbsNum); - const [openRows, setOpenRows] = useState>({}); - const [showFilters, setShowFilters] = useState(false); - - const [submitterFilter, setSubmitterFilter] = useState(''); - const [statusFilter, setStatusFilter] = useState(''); - const [dateFromFilter, setDateFromFilter] = useState(''); - const [dateToFilter, setDateToFilter] = useState(''); - const [amountMinFilter, setAmountMinFilter] = useState(''); - const [amountMaxFilter, setAmountMaxFilter] = useState(''); - const grouped = useMemo(() => { + const reimbursementRequests = useMemo(() => { if (!allReimbursementRequests || !project) return []; - const requestMap = new Map(); - - allReimbursementRequests.forEach((rr) => { + return allReimbursementRequests.filter((rr) => { const hasProjectProduct = rr.reimbursementProducts.some((product) => { const reason = product.reimbursementProductReason; if ((reason as WBSElementData).wbsNum) { @@ -70,170 +25,120 @@ const ProjectSpendingHistory: React.FC = ({ wbsNum } return false; }); - - if (hasProjectProduct) { - requestMap.set(rr.reimbursementRequestId, { request: rr, materials: [] }); - } + return hasProjectProduct; }); + }, [allReimbursementRequests, project, wbsNum]); - if (materials && materials.length > 0) { - materials.forEach((mat) => { - const rr = mat.reimbursementRequest; - if (rr && requestMap.has(rr.reimbursementRequestId)) { - requestMap.get(rr.reimbursementRequestId)!.materials.push(mat); - } - }); + const getSubmittedDate = (rr: ReimbursementRequest): Date | null => { + const pendingFinanceStatus = rr.reimbursementStatuses?.find((status) => status.type === 'PENDING_FINANCE'); + if (pendingFinanceStatus) { + return new Date(pendingFinanceStatus.dateCreated); } - - return Array.from(requestMap.values()); - }, [materials, allReimbursementRequests, project, wbsNum]); - - const filteredData = useMemo(() => { - return grouped.filter(({ request }) => { - if (submitterFilter) { - const submitterName = - `${request.recipient?.firstName} ${request.recipient?.lastName}` || request.recipient?.email || ''; - if (!submitterName.toLowerCase().includes(submitterFilter.toLowerCase())) { - return false; - } - } - - if (statusFilter) { - const currentStatus = request.reimbursementStatuses?.[0]?.type || ''; - if (currentStatus !== statusFilter) { - return false; - } - } - const requestDate = - request.reimbursementStatuses && request.reimbursementStatuses.length > 0 - ? new Date(Math.min(...request.reimbursementStatuses.map((status) => new Date(status.dateCreated).getTime()))) - : null; - - if (dateFromFilter && requestDate) { - const fromDate = new Date(dateFromFilter); - if (requestDate < fromDate) { - return false; - } - } - if (dateToFilter && requestDate) { - const toDate = new Date(dateToFilter); - toDate.setHours(23, 59, 59, 999); - if (requestDate > toDate) { - return false; - } - } - - const amount = (request.totalCost || 0) / 100; - if (amountMinFilter) { - const minAmount = parseFloat(amountMinFilter); - if (!isNaN(minAmount) && amount < minAmount) { - return false; - } - } - if (amountMaxFilter) { - const maxAmount = parseFloat(amountMaxFilter); - if (!isNaN(maxAmount) && amount > maxAmount) { - return false; - } - } - - return true; - }); - }, [grouped, submitterFilter, statusFilter, dateFromFilter, dateToFilter, amountMinFilter, amountMaxFilter]); - - const uniqueSubmitters = useMemo(() => { - const submitters = new Set(); - grouped.forEach(({ request }) => { - const name = `${request.recipient?.firstName} ${request.recipient?.lastName}` || request.recipient?.email; - if (name) submitters.add(name); - }); - return Array.from(submitters).sort(); - }, [grouped]); - - const uniqueStatuses = useMemo(() => { - const statuses = new Set(); - grouped.forEach(({ request }) => { - const status = request.reimbursementStatuses?.[0]?.type; - if (status) statuses.add(status); - }); - return Array.from(statuses).sort(); - }, [grouped]); - - const clearFilters = () => { - setSubmitterFilter(''); - setStatusFilter(''); - setDateFromFilter(''); - setDateToFilter(''); - setAmountMinFilter(''); - setAmountMaxFilter(''); + return null; }; + const rows = useMemo(() => { + return reimbursementRequests.map((rr) => ({ + id: rr.reimbursementRequestId, + submitter: `${rr.recipient?.firstName} ${rr.recipient?.lastName}` || rr.recipient?.email || 'N/A', + description: + rr.reimbursementProducts?.map((p) => p.name).join(', ') || + rr.accountCode?.name || + rr.vendor?.name || + 'No description', + dateSubmitted: getSubmittedDate(rr), + status: rr.reimbursementStatuses?.[0]?.type || 'UNKNOWN', + totalAmount: (rr.totalCost || 0) / 100, + reimbursementRequestId: rr.reimbursementRequestId + })); + }, [reimbursementRequests]); + const budgetInfo = useMemo(() => { - if (!project || !grouped.length) return null; + if (!project) return null; - const totalBudget = project.budget; // Budget is in cents - const totalSpent = grouped.reduce((sum, { request }) => sum + (request.totalCost || 0), 0); // Total cost is in cents + const totalBudget = project.budget; + const totalSpent = reimbursementRequests.reduce((sum, rr) => sum + (rr.totalCost || 0), 0); const budgetRemaining = totalBudget - totalSpent; const budgetUsedPercentage = totalBudget > 0 ? (totalSpent / totalBudget) * 100 : 0; return { - totalBudget: totalBudget / 100, // Convert to dollars - totalSpent: totalSpent / 100, // Convert to dollars - budgetRemaining: budgetRemaining / 100, // Convert to dollars - budgetUsedPercentage: Math.min(budgetUsedPercentage, 100) // Cap at 100% + totalBudget: totalBudget / 100, + totalSpent: totalSpent / 100, + budgetRemaining: budgetRemaining / 100, + budgetUsedPercentage: Math.min(budgetUsedPercentage, 100) }; - }, [project, grouped]); - - const hasActiveFilters = - submitterFilter || statusFilter || dateFromFilter || dateToFilter || amountMinFilter || amountMaxFilter; + }, [project, reimbursementRequests]); + + const columns: GridColDef[] = [ + { + field: 'submitter', + headerName: 'Submitter', + flex: 1, + minWidth: 150, + renderCell: (params: GridRenderCellParams) => ( + + {params.value} + + ) + }, + { + field: 'description', + headerName: 'Description', + flex: 2, + minWidth: 250 + }, + { + field: 'dateSubmitted', + headerName: 'Date Submitted', + flex: 1, + minWidth: 130, + type: 'date', + valueFormatter: (params) => { + return params.value ? new Date(params.value).toLocaleDateString() : '-'; + } + }, + { + field: 'status', + headerName: 'Status', + flex: 1, + minWidth: 150, + renderCell: (params: GridRenderCellParams) => ( + + ) + }, + { + field: 'totalAmount', + headerName: 'Total Amount', + flex: 0.7, + minWidth: 120, + type: 'number', + valueFormatter: (params) => `$${params.value.toFixed(2)}` + } + ]; - const isLoading = materialsLoading || rrLoading || projectLoading; + const isLoading = rrLoading || projectLoading; - if (isLoading) return Loading spending history...; + if (isLoading) return ; if (rrError) { - console.error('Failed to load reimbursement requests:', rrErrorDetails); - return Failed to load spending history.; - } - - if (materialsError) { - console.error('Failed to load materials for project'); return Failed to load spending history.; } - if (!grouped.length) return No spending history for this project.; - - const handleToggleRow = (id: string) => { - setOpenRows((prev) => ({ ...prev, [id]: !prev[id] })); - }; + if (!reimbursementRequests.length) return No spending history for this project.; return ( - - - Spending History - - - {hasActiveFilters && ( - - {filteredData.length} of {grouped.length} results - - )} - - {hasActiveFilters && ( - - )} - - + + Spending History + {budgetInfo && ( @@ -256,7 +161,7 @@ const ProjectSpendingHistory: React.FC = ({ wbsNum variant="determinate" value={budgetInfo.budgetUsedPercentage} sx={{ - height: 8, + height: 10, borderRadius: 5, backgroundColor: '#444', '& .MuiLinearProgress-bar': { @@ -278,7 +183,7 @@ const ProjectSpendingHistory: React.FC = ({ wbsNum Budget Remaining
= 0 ? '#4caf50' : '#f44336', fontWeight: 'bold' @@ -296,194 +201,33 @@ const ProjectSpendingHistory: React.FC = ({ wbsNum )} - {showFilters && ( - - - Filter Options - - - - - Submitter - - - - - - Status - - - - - setDateFromFilter(e.target.value)} - InputLabelProps={{ shrink: true }} - /> - - - setDateToFilter(e.target.value)} - InputLabelProps={{ shrink: true }} - /> - - - setAmountMinFilter(e.target.value)} - inputProps={{ step: '0.01', min: '0' }} - /> - - - setAmountMaxFilter(e.target.value)} - inputProps={{ step: '0.01', min: '0' }} - /> - - - - )} - - {filteredData.length === 0 && grouped.length > 0 ? ( - - No spending history matches the current filters. - - - ) : ( - - - - - - Submitter / RR Link - Description - Date Submitted - Status - Total Amount - - - - {filteredData.map(({ request, materials }) => { - const hasMaterials = materials.length > 0; - return ( - - - - {hasMaterials ? ( - handleToggleRow(request.reimbursementRequestId)}> - {openRows[request.reimbursementRequestId] ? : } - - ) : null} - - - - {`${request.recipient?.firstName} ${request.recipient?.lastName}` || - request.recipient?.email || - 'N/A'} - - - - - {request.accountCode?.name || - request.reimbursementProducts?.map((p) => p.name).join(', ') || - request.vendor?.name || - 'No description available'} - - - - {request.reimbursementStatuses && request.reimbursementStatuses.length > 0 - ? new Date( - Math.min( - ...request.reimbursementStatuses.map((status) => new Date(status.dateCreated).getTime()) - ) - ).toLocaleDateString() - : ''} - - - - - ${(request.totalCost / 100)?.toFixed(2) || '0.00'} - - {hasMaterials && ( - - - - - - Line Items - -
- - - Name - Notes - Amount - - - - {materials.map((mat) => ( - - {mat.name} - {mat.notes || '-'} - ${(mat.subtotal / 100)?.toFixed(2) || '0.00'} - - ))} - -
-
- - - - )} - - ); - })} - - - - )} + + + ); }; From c8aa259d59e5bfcb4d54368721701da62c79ff11 Mon Sep 17 00:00:00 2001 From: harish Date: Fri, 19 Dec 2025 10:29:55 -0500 Subject: [PATCH 11/46] #3604 prettier --- .../src/pages/ProjectPage/ProjectSpendingHistory.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/frontend/src/pages/ProjectPage/ProjectSpendingHistory.tsx b/src/frontend/src/pages/ProjectPage/ProjectSpendingHistory.tsx index 9a8b3d219b..641253f9f2 100644 --- a/src/frontend/src/pages/ProjectPage/ProjectSpendingHistory.tsx +++ b/src/frontend/src/pages/ProjectPage/ProjectSpendingHistory.tsx @@ -56,16 +56,16 @@ const ProjectSpendingHistory: React.FC = ({ wbsNum const budgetInfo = useMemo(() => { if (!project) return null; - const totalBudget = project.budget; - const totalSpent = reimbursementRequests.reduce((sum, rr) => sum + (rr.totalCost || 0), 0); + const totalBudget = project.budget; + const totalSpent = reimbursementRequests.reduce((sum, rr) => sum + (rr.totalCost || 0), 0); const budgetRemaining = totalBudget - totalSpent; const budgetUsedPercentage = totalBudget > 0 ? (totalSpent / totalBudget) * 100 : 0; return { - totalBudget: totalBudget / 100, - totalSpent: totalSpent / 100, - budgetRemaining: budgetRemaining / 100, - budgetUsedPercentage: Math.min(budgetUsedPercentage, 100) + totalBudget: totalBudget / 100, + totalSpent: totalSpent / 100, + budgetRemaining: budgetRemaining / 100, + budgetUsedPercentage: Math.min(budgetUsedPercentage, 100) }; }, [project, reimbursementRequests]); From 3dc5ef3c58351555da1cc506bbbc520b6ea98c7c Mon Sep 17 00:00:00 2001 From: Chris Pyle Date: Sun, 21 Dec 2025 19:34:20 -0500 Subject: [PATCH 12/46] 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 13/46] 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 14/46] 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 15/46] 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=(?