diff --git a/.circleci/config.yml b/.circleci/config.yml index c70035de5..610988559 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -228,7 +228,7 @@ workflows: branches: only: - dev - - flexi-talent + - copilot_reviewer tags: only: /^dev-.*/ diff --git a/README.md b/README.md index be4a28c20..34fc8776e 100644 --- a/README.md +++ b/README.md @@ -556,6 +556,7 @@ The following summarizes the various [apps](#adding-a-new-platform-ui-applicatio - [Gamification Admin](#gamification-admin) - [Learn](#learn) - [Self Service](#self-service) +- [Status](#status) ## Platform App @@ -599,3 +600,13 @@ Application that allows customers to submit/start challenges self-service. [Work README TBD](./src/apps/self-service/README.md) [Work Routes](./src/apps/self-service/src/self-service.routes.tsx) + +## Status + +Administrator-only operational visibility for ECS services and tasks, API +traffic and failures, SendGrid acceptance/activity, and the Topcoder services +RDS instance. The app is read-only and loads only the active tab or expanded +diagnostic section. + +[Status README](./src/apps/status/README.md) +[Status Routes](./src/apps/status/src/status-app.routes.tsx) diff --git a/src/.eslintrc.js b/src/.eslintrc.js index d90237e75..38ad394ba 100644 --- a/src/.eslintrc.js +++ b/src/.eslintrc.js @@ -97,10 +97,6 @@ module.exports = { 'error', 'as-needed', ], - complexity: [ - 'error', - 14, - ], 'import/extensions': 'off', 'import/no-named-default': 'off', 'import/prefer-default-export': 'off', diff --git a/src/apps/admin/src/lib/components/ChallengeList/ChallengeList.spec.ts b/src/apps/admin/src/lib/components/ChallengeList/ChallengeList.spec.ts index 884b4bb7e..7268fdd18 100644 --- a/src/apps/admin/src/lib/components/ChallengeList/ChallengeList.spec.ts +++ b/src/apps/admin/src/lib/components/ChallengeList/ChallengeList.spec.ts @@ -2,6 +2,24 @@ import { canOpenReviewUi, getReviewUiChallengeUrl, } from './reviewUiLink' +import { getChallengeSubTrackSuffix } from './challengeTypeTrack' + +describe('getChallengeSubTrackSuffix', () => { + it('returns an empty suffix when legacy data is missing', () => { + expect(getChallengeSubTrackSuffix(undefined)) + .toBe('') + }) + + it('returns an empty suffix when the legacy sub-track is missing', () => { + expect(getChallengeSubTrackSuffix({})) + .toBe('') + }) + + it('formats a legacy sub-track', () => { + expect(getChallengeSubTrackSuffix({ subTrack: 'DEVELOP' })) + .toBe(' / DEVELOP') + }) +}) describe('ChallengeList review UI helpers', () => { describe('canOpenReviewUi', () => { diff --git a/src/apps/admin/src/lib/components/ChallengeList/ChallengeList.tsx b/src/apps/admin/src/lib/components/ChallengeList/ChallengeList.tsx index 988a7f01d..c53fac7ca 100644 --- a/src/apps/admin/src/lib/components/ChallengeList/ChallengeList.tsx +++ b/src/apps/admin/src/lib/components/ChallengeList/ChallengeList.tsx @@ -28,6 +28,7 @@ import { canOpenReviewUi, getReviewUiChallengeUrl, } from './reviewUiLink' +import { getChallengeSubTrackSuffix } from './challengeTypeTrack' import styles from './ChallengeList.module.scss' export interface ChallengeListProps { @@ -323,10 +324,7 @@ const ChallengeList: FC = props => { {typeName}
{trackName} - {' '} - {challenge.legacy.subTrack - ? ` / ${challenge.legacy.subTrack}` - : ''} + {getChallengeSubTrackSuffix(challenge.legacy)} ) }, diff --git a/src/apps/admin/src/lib/components/ChallengeList/challengeTypeTrack.ts b/src/apps/admin/src/lib/components/ChallengeList/challengeTypeTrack.ts new file mode 100644 index 000000000..63e23060d --- /dev/null +++ b/src/apps/admin/src/lib/components/ChallengeList/challengeTypeTrack.ts @@ -0,0 +1,5 @@ +import { Challenge } from '../../models' + +export const getChallengeSubTrackSuffix = ( + legacy: Challenge['legacy'], +): string => (legacy?.subTrack ? ` / ${legacy.subTrack}` : '') diff --git a/src/apps/admin/src/lib/models/challenge-management/Challenge.ts b/src/apps/admin/src/lib/models/challenge-management/Challenge.ts index 4f5c913aa..6f082fe3e 100644 --- a/src/apps/admin/src/lib/models/challenge-management/Challenge.ts +++ b/src/apps/admin/src/lib/models/challenge-management/Challenge.ts @@ -60,8 +60,8 @@ export interface Challenge { typeId: string /** Challenge track. */ track: ChallengeTrack - legacy: { - subTrack: string + legacy?: { + subTrack?: string } /** Challenge status. */ status: ChallengeStatus diff --git a/src/apps/customer-portal/src/pages/flexi-talent/FlexiTalentPage/FlexiTalentPage.module.scss b/src/apps/customer-portal/src/pages/flexi-talent/FlexiTalentPage/FlexiTalentPage.module.scss index 8a7701ce1..e3d4e3a94 100644 --- a/src/apps/customer-portal/src/pages/flexi-talent/FlexiTalentPage/FlexiTalentPage.module.scss +++ b/src/apps/customer-portal/src/pages/flexi-talent/FlexiTalentPage/FlexiTalentPage.module.scss @@ -416,6 +416,13 @@ color: $black-80; } +.detailCapacity { + box-sizing: border-box; + max-width: 100%; + overflow-wrap: anywhere; + white-space: normal; +} + .memberTimePill { display: inline-flex; align-items: center; diff --git a/src/apps/customer-portal/src/pages/flexi-talent/components/MembersView/MembersView.spec.tsx b/src/apps/customer-portal/src/pages/flexi-talent/components/MembersView/MembersView.spec.tsx index 0ad3d2bda..ee31348c0 100644 --- a/src/apps/customer-portal/src/pages/flexi-talent/components/MembersView/MembersView.spec.tsx +++ b/src/apps/customer-portal/src/pages/flexi-talent/components/MembersView/MembersView.spec.tsx @@ -1,6 +1,7 @@ /* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports, sort-keys */ import '@testing-library/jest-dom' +import { readFileSync } from 'fs' import React from 'react' import { fireEvent, @@ -20,6 +21,18 @@ import { MembersView } from './MembersView' const mockGetFlexiMemberSummary = getFlexiMemberSummary as jest.Mock const mockGetFlexiMemberList = getFlexiMemberList as jest.Mock const mockGetFlexiMemberDetail = getFlexiMemberDetail as jest.Mock +const flexiTalentPageStyles = readFileSync( + `${__dirname}/../../FlexiTalentPage/FlexiTalentPage.module.scss`, + 'utf8', +) +const detailCapacityWrappingPattern = [ + '[.]detailCapacity \\{', + '[^}]*box-sizing: border-box;', + '[^}]*max-width: 100%;', + '[^}]*overflow-wrap: anywhere;', + '[^}]*white-space: normal;', + '[^}]*\\}', +].join('') jest.mock('~/apps/admin/src/lib/components/common/Pagination', () => ({ Pagination: () =>
pagination
, @@ -53,6 +66,13 @@ jest.mock('../MemberHistoryModal', () => ({ MemberHistoryModal: () => undefined, })) +describe('MembersView styles', () => { + it('allows long assignment labels to wrap within the member detail card', () => { + expect(flexiTalentPageStyles) + .toMatch(new RegExp(detailCapacityWrappingPattern)) + }) +}) + describe('MembersView', () => { beforeEach(() => { jest.clearAllMocks() diff --git a/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePostPage/ShowcasePostMedia/ShowcasePostMediaGallery.module.scss b/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePostPage/ShowcasePostMedia/ShowcasePostMediaGallery.module.scss index 2a06d1a82..69b1dbc67 100644 --- a/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePostPage/ShowcasePostMedia/ShowcasePostMediaGallery.module.scss +++ b/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePostPage/ShowcasePostMedia/ShowcasePostMediaGallery.module.scss @@ -9,13 +9,16 @@ background: rgba(0, 0, 0, 0.9); } -.mainFrame { +.mainFrame, +.zoomFrame { position: relative; width: min(100%, 1000px); + height: min(100%, 90vh); max-height: min(100%, 90vh); display: flex; flex-direction: column; padding: 120px 0 0; + box-sizing: border-box; } .close { @@ -28,7 +31,78 @@ cursor: pointer; } -.mainContent { +.zoomOpen { + position: absolute; + bottom: calc(100% + 8px); + right: 0; + z-index: 2; + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + border: 0; + border-radius: 50%; + background: rgba($tc-white, 0.12); + color: $tc-white; + cursor: pointer; + transition: background 0.15s ease; + + &:hover { + background: rgba($tc-white, 0.22); + } + + &:active { + background: rgba($tc-white, 0.18); + } +} + +.zoomToolbar { + position: absolute; + bottom: calc(100% + 8px); + right: 0; + z-index: 2; + display: flex; + align-items: center; + gap: 6px; +} + +.zoomControl { + display: flex; + align-items: center; + justify-content: center; + min-width: 36px; + height: 36px; + padding: 0 8px; + border: 0; + border-radius: 8px; + background: rgba($tc-white, 0.12); + color: $tc-white; + font-size: 12px; + font-weight: 600; + cursor: pointer; + transition: background 0.15s ease; + + &:hover:not(:disabled) { + background: rgba($tc-white, 0.22); + } + + &:disabled { + opacity: 0.4; + cursor: default; + } +} + +.zoomLevel { + min-width: 48px; + color: $tc-white; + font-size: 12px; + font-weight: 600; + text-align: center; +} + +.mainContent, +.zoomContent { display: flex; flex-direction: column; gap: 24px; @@ -37,27 +111,49 @@ position: relative; } -.galleryMedia { +.galleryMedia, +.zoomMedia { display: flex; align-items: center; justify-content: center; - min-height: 340px; + flex: 1; + min-height: 0; padding: 1rem; border-radius: 1rem; background-color: var(--neutral-100); overflow: hidden; } +.zoomMedia { + display: block; + overflow: auto; + -webkit-overflow-scrolling: touch; + align-items: unset; + justify-content: unset; + + @include scrollbar; +} + .galleryMedia img, .galleryMedia video { max-width: 100%; - max-height: 74vh; + max-height: 100%; + width: auto; + height: auto; object-fit: contain; } .galleryImage { width: auto; height: auto; + display: block; +} + +.zoomImage { + display: block; + max-width: none; + height: auto; + margin: 0 auto; } .galleryPlaceholder { @@ -118,10 +214,10 @@ } } - .galleryThumbnails { display: flex; align-items: flex-start; + flex-shrink: 0; gap: $sp-4; height: 160px; width: 100%; diff --git a/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePostPage/ShowcasePostMedia/ShowcasePostMediaGallery.tsx b/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePostPage/ShowcasePostMedia/ShowcasePostMediaGallery.tsx index 571859afd..f8de4068c 100644 --- a/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePostPage/ShowcasePostMedia/ShowcasePostMediaGallery.tsx +++ b/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePostPage/ShowcasePostMedia/ShowcasePostMediaGallery.tsx @@ -1,4 +1,11 @@ -import { FC, MouseEvent, useCallback, useEffect, useRef, useState } from 'react' +import { + FC, + MouseEvent as ReactMouseEvent, + useCallback, + useEffect, + useRef, + useState, +} from 'react' import classNames from 'classnames' import { IconFile } from '~/apps/customer-portal/src/lib/assets' @@ -54,6 +61,10 @@ const IMAGE_EXTENSIONS = new Set(['.bmp', '.gif', '.jpg', '.jpeg', '.png']) const VIDEO_EXTENSIONS = new Set(['.webm', '.mp4', '.mov', '.avi']) const PDF_EXTENSIONS = new Set(['.pdf']) +const MIN_ZOOM = 1 +const MAX_ZOOM = 8 +const ZOOM_STEP = 0.25 + export function getAssetExtension(asset: ProjectShowcasePostMedia): string { return getFileExtension(asset.type) || getFileExtension(asset.url) || '' } @@ -93,15 +104,35 @@ export function getMediaAlt(asset: ProjectShowcasePostMedia): string { return `Project showcase attachment (${getPlaceholderLabel(extension, asset)})` } +function clampZoom(value: number): number { + return Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, value)) +} + const ShowcasePostMediaGallery: FC = props => { const thumbsContainerRef = useRef(null) const [currentIndex, setCurrentIndex] = useState(props.startingIndex) + const [isZoomOverlayOpen, setIsZoomOverlayOpen] = useState(false) + const [zoom, setZoom] = useState(MIN_ZOOM) const mediaAsset = props.assets[currentIndex] + const isCurrentImage = mediaAsset ? isImageAsset(getAssetExtension(mediaAsset)) : false + + const resetZoom = useCallback(() => { + setZoom(MIN_ZOOM) + }, []) + + const closeZoomOverlay = useCallback(() => { + setIsZoomOverlayOpen(false) + resetZoom() + }, [resetZoom]) useEffect(() => { setCurrentIndex(props.startingIndex) }, [props.startingIndex]) + useEffect(() => { + resetZoom() + }, [currentIndex, resetZoom]) + const handlePrevious = useCallback(() => { setCurrentIndex(prevIndex => (prevIndex + props.assets.length - 1) % props.assets.length) }, [props.assets.length]) @@ -109,19 +140,46 @@ const ShowcasePostMediaGallery: FC = props => { const handleNext = useCallback(() => { setCurrentIndex(prevIndex => (prevIndex + 1) % props.assets.length) }, [props.assets.length]) + + const handleOpenZoomOverlay = useCallback(() => { + setIsZoomOverlayOpen(true) + resetZoom() + }, [resetZoom]) + + const handleZoomIn = useCallback(() => { + setZoom(prev => clampZoom(prev + ZOOM_STEP)) + }, []) + + const handleZoomOut = useCallback(() => { + setZoom(prev => clampZoom(prev - ZOOM_STEP)) + }, []) + useEffect(() => { const handleKeyDown = (event: KeyboardEvent): void => { if (event.key === 'Escape') { + if (isZoomOverlayOpen) { + closeZoomOverlay() + return + } + props.onClose() } - if (event.key === 'ArrowLeft') { + if (event.key === 'ArrowLeft' && !isZoomOverlayOpen) { handlePrevious() } - if (event.key === 'ArrowRight') { + if (event.key === 'ArrowRight' && !isZoomOverlayOpen) { handleNext() } + + if (isZoomOverlayOpen && (event.key === '+' || event.key === '=')) { + handleZoomIn() + } + + if (isZoomOverlayOpen && event.key === '-') { + handleZoomOut() + } } const originalDocumentOverflow = document.documentElement.style.overflow @@ -137,15 +195,24 @@ const ShowcasePostMediaGallery: FC = props => { document.documentElement.style.overflow = originalDocumentOverflow document.body.style.overflow = originalBodyOverflow } - }, [handleNext, handlePrevious, props.onClose]) + }, [ + closeZoomOverlay, + handleNext, + handlePrevious, + handleZoomIn, + handleZoomOut, + isZoomOverlayOpen, + props.onClose, + props, + ]) useEffect(() => { - if (!thumbsContainerRef.current) { + if (!thumbsContainerRef.current || isZoomOverlayOpen) { return } thumbsContainerRef.current.children[currentIndex].scrollIntoView() - }, [currentIndex]) + }, [currentIndex, isZoomOverlayOpen]) if (!mediaAsset) { return <> @@ -156,107 +223,189 @@ const ShowcasePostMediaGallery: FC = props => { className={styles.overlay} role='dialog' aria-modal='true' - onClick={props.onClose} + onClick={isZoomOverlayOpen ? closeZoomOverlay : props.onClose} > -
) { + {isZoomOverlayOpen && isCurrentImage ? ( +
) { event.stopPropagation() - } - } - > -
- - -
- {isImageAsset(getAssetExtension(mediaAsset)) ? ( + }} + > +
+ + +
+ + + {Math.round(zoom * 100)} + % + + + +
+ +
{getMediaAlt(mediaAsset)} - ) : ( -
- - - {getPlaceholderLabel(getAssetExtension(mediaAsset), mediaAsset)} - - - Open file - -
- )} +
+
+ ) : ( +
) { + event.stopPropagation() + } + } + > +
+ + + {isCurrentImage && ( + + )} -
-
+ +
    + {props.assets.map((thumbAsset, index) => { + const extension = getAssetExtension(thumbAsset) + const isImage = isImageAsset(extension) + + return ( +
  • + {isImage && ( + {getMediaAlt(thumbAsset)} + )} + {!isImage && ( +
    + + + {getPlaceholderLabel(extension, thumbAsset)} + +
    + )} +
  • + ) + })} +
- -
    - {props.assets.map((thumbAsset, index) => { - const extension = getAssetExtension(thumbAsset) - const isImage = isImageAsset(extension) - - return ( -
  • - {isImage && ( - {getMediaAlt(thumbAsset)} - )} - {!isImage && ( -
    - - - {getPlaceholderLabel(extension, thumbAsset)} - -
    - )} -
  • - ) - })} -
-
+ )}
) } diff --git a/src/apps/platform/src/platform.routes.tsx b/src/apps/platform/src/platform.routes.tsx index 48ec1ed29..53f28e93a 100644 --- a/src/apps/platform/src/platform.routes.tsx +++ b/src/apps/platform/src/platform.routes.tsx @@ -16,6 +16,7 @@ import { calendarRoutes } from '~/apps/calendar' import { engagementsRoutes } from '~/apps/engagements' import { customerPortalRoutes } from '~/apps/customer-portal' import { procurementRoutes } from '~/apps/procurement' +import { statusRoutes } from '~/apps/status' const Home: LazyLoadedComponent = lazyLoad( () => import('./routes/home'), @@ -47,6 +48,7 @@ export const platformRoutes: Array = [ ...calendarRoutes, ...engagementsRoutes, ...procurementRoutes, + ...statusRoutes, ...homeRoutes, ...adminRoutes, ...reportsRoutes, diff --git a/src/apps/profiles/src/components/tc-achievements/TcSpecialRolesBanner/TcSpecialRolesBanner.module.scss b/src/apps/profiles/src/components/tc-achievements/TcSpecialRolesBanner/TcSpecialRolesBanner.module.scss index ee88a4b6b..6de800d35 100644 --- a/src/apps/profiles/src/components/tc-achievements/TcSpecialRolesBanner/TcSpecialRolesBanner.module.scss +++ b/src/apps/profiles/src/components/tc-achievements/TcSpecialRolesBanner/TcSpecialRolesBanner.module.scss @@ -1,35 +1,150 @@ @import '@libs/ui/styles/includes'; .rolesSection { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 320px), 1fr)); + gap: $sp-4; + margin-top: $sp-4; + container-type: inline-size; +} + +.roleCard { + position: relative; display: flex; - justify-content: space-between; align-items: center; - width: 100%; - padding: $sp-4; - background-image: linear-gradient(to right, #652385, #8C384C); + justify-content: space-between; + gap: $sp-4; + min-height: 66px; + padding: $sp-3 $sp-4; + border-radius: 10px; color: $tc-white; - border-radius: 8px; - margin: $sp-4 0; + overflow: visible; + + &.reviewer { + background: linear-gradient(155deg, #065D6E 0%, #3E3B91 100%); + } - @include ltesm { - margin-bottom: 0; - flex-direction: column; - align-items: flex-start; + &.copilot { + background: linear-gradient(90deg, #652385 0%, #8C384C 100%); } } -.rolesWrap { +.cardLink { + position: absolute; + z-index: 1; + inset: 0; + border-radius: inherit; + + &:focus-visible { + outline: 2px solid $tc-white; + outline-offset: -4px; + } + + &:hover { + background: rgba($tc-white, 0.08); + } +} + +.roleHeading { display: flex; align-items: center; + min-width: 0; + gap: $sp-2; + font-family: $font-roboto; + font-size: 16px; + line-height: 24px; + white-space: nowrap; + + > span { + font-weight: $font-weight-medium; + } + + > strong { + font-weight: $font-weight-normal; + } +} + +.infoButton { + position: relative; + z-index: 2; + display: flex; + flex: 0 0 auto; + width: 18px; + height: 18px; + padding: 1px; + color: rgba($tc-white, 0.8); + border-radius: 50%; + + &:focus-visible { + outline: 2px solid $tc-white; + outline-offset: 1px; + } } -.link { - font-size: 14px; - line-height: 14px; - font-weight: $font-weight-medium; +.roleCount { + display: flex; + align-items: center; + flex: 0 0 auto; + gap: $sp-2; font-family: $font-roboto; - @include ltesm { - margin-top: $sp-2; + > strong { + font-family: $font-barlow-condensed; + font-size: 26px; + font-weight: $font-weight-medium; + line-height: 28px; + } + + > span { + color: rgba($tc-white, 0.88); + font-size: 12px; + font-weight: $font-weight-medium; + line-height: 10px; + } +} + +.chevron { + width: 18px; + height: 18px; + margin-left: $sp-2; + color: rgba($tc-white, 0.9); +} + +.roleTooltip { + --rt-opacity: 1; + + max-width: 250px !important; + padding: $sp-3 !important; + border-radius: 4px !important; + background: $black-100 !important; + color: $tc-white !important; + font-size: 14px !important; + line-height: 20px !important; + opacity: 1 !important; + + :global(.react-tooltip-arrow) { + background-color: $black-100 !important; + } +} + +@container (max-width: 420px) { + .roleCard { + min-height: 58px; + gap: $sp-2; + padding: $sp-3; + } + + .roleHeading { + gap: $sp-1; + font-size: 14px; + line-height: 20px; + } + + .roleCount { + gap: $sp-1; + } + + .chevron { + margin-left: 0; } } diff --git a/src/apps/profiles/src/components/tc-achievements/TcSpecialRolesBanner/TcSpecialRolesBanner.spec.tsx b/src/apps/profiles/src/components/tc-achievements/TcSpecialRolesBanner/TcSpecialRolesBanner.spec.tsx new file mode 100644 index 000000000..a092710db --- /dev/null +++ b/src/apps/profiles/src/components/tc-achievements/TcSpecialRolesBanner/TcSpecialRolesBanner.spec.tsx @@ -0,0 +1,127 @@ +/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */ +import '@testing-library/jest-dom' +import { readFileSync } from 'fs' +import type { PropsWithChildren, ReactNode } from 'react' +import { render, screen } from '@testing-library/react' +import { MemoryRouter } from 'react-router-dom' + +import type { MemberRoleStats, UserProfile } from '~/libs/core' + +import TcSpecialRolesBanner from './TcSpecialRolesBanner' + +jest.mock('~/libs/ui', () => ({ + IconOutline: { + ChevronRightIcon: (): JSX.Element => , + InformationCircleIcon: (): JSX.Element => , + }, + Tooltip: (props: PropsWithChildren<{ content?: ReactNode }>): JSX.Element => ( +
+ {props.children} + {props.content} +
+ ), +}), { + virtual: true, +}) + +jest.mock('../../../profiles.routes', () => ({ + getUserProfileRoleRoute: (handle: string, role: string): string => ( + `/${handle}/stats/roles/${role}` + ), +})) + +jest.mock('../../../lib', () => ({ + formatPlural: (count: number, label: string): string => `${label}${count === 1 ? '' : 's'}`, +})) + +const profile = { handle: 'Tester' } as UserProfile +const tcSpecialRolesBannerStyles = readFileSync(`${__dirname}/TcSpecialRolesBanner.module.scss`, 'utf8') + +describe('TcSpecialRolesBanner styles', () => { + it('uses the approved role challenge count size at every breakpoint', () => { + expect(tcSpecialRolesBannerStyles) + .toMatch(/\.roleCount \{[\s\S]*?> strong \{[\s\S]*?font-size: 26px;/) + expect(tcSpecialRolesBannerStyles).not + .toMatch(/font-size: 22px;/) + }) +}) + +/** + * Renders role summary cards inside the router required by their detail links. + * + * This test helper does not intentionally throw; render failures are reported by Testing Library. + * + * @param {MemberRoleStats | undefined} roleStats - Summary response under test. + * @returns {void} + */ +function renderRoles(roleStats?: MemberRoleStats): void { + render( + + + , + ) +} + +describe('TcSpecialRolesBanner', () => { + it('renders reviewer first and copilot second when the member has both roles', () => { + renderRoles({ + copilot: { challengeCount: 86 }, + reviewer: { challengeCount: 9 }, + }) + + const links = screen.getAllByRole('link') + + expect(links) + .toHaveLength(2) + expect(links[0]) + .toHaveAttribute('href', '/Tester/stats/roles/reviewer') + expect(links[1]) + .toHaveAttribute('href', '/Tester/stats/roles/copilot') + expect(screen.getByText('9')) + .toBeInTheDocument() + expect(screen.getByText('86')) + .toBeInTheDocument() + }) + + it.each([ + ['reviewer', { reviewer: { challengeCount: 1 } }, '/Tester/stats/roles/reviewer'], + ['copilot', { copilot: { challengeCount: 12 } }, '/Tester/stats/roles/copilot'], + ] as const)('renders a single full-row %s card', (_role, roleStats, expectedRoute) => { + renderRoles(roleStats) + + expect(screen.getAllByRole('link')) + .toHaveLength(1) + expect(screen.getByRole('link')) + .toHaveAttribute('href', expectedRoute) + }) + + it('renders no role cards for a missing or zero-count summary', () => { + renderRoles({ + copilot: { challengeCount: 0 }, + reviewer: { challengeCount: 0 }, + }) + + expect(screen.queryByRole('link')).not + .toBeInTheDocument() + expect(screen.queryByTestId('role-tooltip')).not + .toBeInTheDocument() + }) + + it('provides the exact Figma tooltip copy for both roles', () => { + renderRoles({ + copilot: { challengeCount: 86 }, + reviewer: { challengeCount: 9 }, + }) + + expect(screen.getByText( + 'A Topcoder reviewer is an expert community member who evaluates submissions,' + + ' scores them against requirements, and provides actionable feedback.', + )) + .toBeInTheDocument() + expect(screen.getByText( + 'A Topcoder Copilot is an elite expert who turns client’s requirements into challenges' + + ' and guides the community to deliver quality solutions.', + )) + .toBeInTheDocument() + }) +}) diff --git a/src/apps/profiles/src/components/tc-achievements/TcSpecialRolesBanner/TcSpecialRolesBanner.tsx b/src/apps/profiles/src/components/tc-achievements/TcSpecialRolesBanner/TcSpecialRolesBanner.tsx index cecc885e8..d7f19fb79 100644 --- a/src/apps/profiles/src/components/tc-achievements/TcSpecialRolesBanner/TcSpecialRolesBanner.tsx +++ b/src/apps/profiles/src/components/tc-achievements/TcSpecialRolesBanner/TcSpecialRolesBanner.tsx @@ -1,41 +1,136 @@ -import { FC, useMemo, useState } from 'react' +import { FC, ReactNode } from 'react' +import { Link } from 'react-router-dom' +import classNames from 'classnames' -import { UserStats } from '~/libs/core' +import { + MemberRoleStats, + MemberSpecialRole, + UserProfile, +} from '~/libs/core' +import { IconOutline, Tooltip } from '~/libs/ui' + +import { getUserProfileRoleRoute } from '../../../profiles.routes' +import { formatPlural } from '../../../lib' -import { MemberRolesInfoModal } from './MemberRolesInfoModal' import styles from './TcSpecialRolesBanner.module.scss' interface TcSpecialRolesBannerProps { - memberStats: UserStats | undefined + profile: UserProfile + roleStats?: MemberRoleStats } -const TcSpecialRolesBanner: FC = props => { - const isCopilot: boolean - = useMemo(() => !!props.memberStats?.COPILOT, [props.memberStats]) +interface SpecialRoleCardConfig { + challengeCount: number + role: MemberSpecialRole + tooltip: ReactNode +} + +const roleTooltips: Record = { + copilot: 'A Topcoder Copilot is an elite expert who turns client’s requirements into challenges' + + ' and guides the community to deliver quality solutions.', + reviewer: 'A Topcoder reviewer is an expert community member who evaluates submissions,' + + ' scores them against requirements, and provides actionable feedback.', +} + +/** + * Converts a member role identifier to the title casing used in the profile cards. + * + * This function does not throw. + * + * @param {MemberSpecialRole} role - Copilot or reviewer role identifier. + * @returns {string} Human-readable role title. + */ +const getRoleTitle = (role: MemberSpecialRole): string => ( + role === 'copilot' ? 'Copilot' : 'Reviewer' +) + +/** + * Renders the linked Figma summary card for one profile special role. + * + * This component does not throw. + * + * @param {SpecialRoleCardConfig & { profile: UserProfile }} props - Role count, tooltip, and profile route data. + * @returns {JSX.Element} One linked special-role card. + */ +const SpecialRoleCard: FC = props => { + const roleTitle = getRoleTitle(props.role) + + return ( +
+ +
+ Special Role: + {roleTitle} + + + +
+
+ {props.challengeCount.toLocaleString('en-US')} + {formatPlural(props.challengeCount, 'Challenge')} + +
+
+ ) +} - const [isInfoModalOpen, setIsInfoModalOpen] = useState(false) +/** + * Shows reviewer and copilot summary cards above Member Stats. + * + * A single role spans the available width, while two roles share the row and + * collapse to a vertical stack on narrow profile layouts. + * + * This component does not throw. + * + * @param {TcSpecialRolesBannerProps} props - Profile and API-backed role totals. + * @returns {JSX.Element} Special-role cards, or an empty fragment when no roles exist. + */ +const TcSpecialRolesBanner: FC = props => { + const roles: SpecialRoleCardConfig[] = [] - function handleInfoModalClose(): void { - setIsInfoModalOpen(false) + if (props.roleStats?.reviewer?.challengeCount) { + roles.push({ + challengeCount: props.roleStats.reviewer.challengeCount, + role: 'reviewer', + tooltip: roleTooltips.reviewer, + }) } - function handleInfoModalOpen(): void { - setIsInfoModalOpen(true) + if (props.roleStats?.copilot?.challengeCount) { + roles.push({ + challengeCount: props.roleStats.copilot.challengeCount, + role: 'copilot', + tooltip: roleTooltips.copilot, + }) } - return !isCopilot ? <> : ( + return roles.length === 0 ? <> : (
-
-

Topcoder Special Roles: 

-

Copilot

-
- - - {isInfoModalOpen && ( - - )} + {roles.map(role => ( + + ))}
) } diff --git a/src/apps/profiles/src/member-profile/tc-achievements/MemberTCAchievements.spec.tsx b/src/apps/profiles/src/member-profile/tc-achievements/MemberTCAchievements.spec.tsx index 761db3236..66f163f62 100644 --- a/src/apps/profiles/src/member-profile/tc-achievements/MemberTCAchievements.spec.tsx +++ b/src/apps/profiles/src/member-profile/tc-achievements/MemberTCAchievements.spec.tsx @@ -1,4 +1,45 @@ +/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */ +import '@testing-library/jest-dom' import { readFileSync } from 'fs' +import { render, screen } from '@testing-library/react' +import { MemoryRouter, Route, Routes } from 'react-router-dom' + +import type { UserProfile } from '~/libs/core' +import { + useMemberBadges, + useMemberRoleStats, + useMemberStats, +} from '~/libs/core' + +import MemberTCAchievements from './MemberTCAchievements' + +jest.mock('~/libs/core', () => ({ + useMemberBadges: jest.fn(), + useMemberRoleStats: jest.fn(), + useMemberStats: jest.fn(), +}), { + virtual: true, +}) + +jest.mock('./default-achievements-view', () => ({ + DefaultAchievementsView: (): JSX.Element =>
Default achievements
, +})) + +jest.mock('./member-role-details-view', () => ({ + MemberRoleDetailsView: (): JSX.Element =>
Role details
, +})) + +jest.mock('./sub-track-view', () => ({ + SubTrackView: (): JSX.Element =>
Subtrack
, +})) + +jest.mock('./track-view', () => ({ + TrackView: (): JSX.Element =>
Track
, +})) + +const mockedUseMemberBadges = useMemberBadges as jest.MockedFunction +const mockedUseMemberRoleStats = useMemberRoleStats as jest.MockedFunction +const mockedUseMemberStats = useMemberStats as jest.MockedFunction const memberTCAchievementsStyles = readFileSync(`${__dirname}/MemberTCAchievements.module.scss`, 'utf8') @@ -8,3 +49,34 @@ describe('MemberTCAchievements styles', () => { .toMatch(/\.container \{[\s\S]*?padding: \$sp-8;/) }) }) + +describe('MemberTCAchievements role routing', () => { + beforeEach(() => { + mockedUseMemberBadges.mockReturnValue(undefined) + mockedUseMemberRoleStats.mockReturnValue({ + data: undefined, + error: new Error('Summary unavailable'), + isValidating: false, + mutate: jest.fn(), + }) + mockedUseMemberStats.mockReturnValue(undefined) + }) + + it('renders a direct role details route even when summary and ordinary stats are unavailable', () => { + render( + + + + )} + /> + + , + ) + + expect(screen.getByText('Role details')) + .toBeInTheDocument() + }) +}) diff --git a/src/apps/profiles/src/member-profile/tc-achievements/MemberTCAchievements.tsx b/src/apps/profiles/src/member-profile/tc-achievements/MemberTCAchievements.tsx index 1523bbf53..877248b5e 100644 --- a/src/apps/profiles/src/member-profile/tc-achievements/MemberTCAchievements.tsx +++ b/src/apps/profiles/src/member-profile/tc-achievements/MemberTCAchievements.tsx @@ -1,8 +1,10 @@ import { FC, useCallback, useMemo } from 'react' -import { Outlet, Route, Routes } from 'react-router-dom' +import { Location, Outlet, Route, Routes, useLocation } from 'react-router-dom' import { + MemberRoleStats, useMemberBadges, + useMemberRoleStats, useMemberStats, UserBadge, UserBadgesResponse, @@ -11,8 +13,9 @@ import { } from '~/libs/core' import { DefaultAchievementsView } from './default-achievements-view' -import { TrackView } from './track-view' +import { MemberRoleDetailsView } from './member-role-details-view' import { SubTrackView } from './sub-track-view' +import { TrackView } from './track-view' import styles from './MemberTCAchievements.module.scss' interface MemberTCAchievementsProps { @@ -20,7 +23,9 @@ interface MemberTCAchievementsProps { } const MemberTCAchievements: FC = (props: MemberTCAchievementsProps) => { + const location: Location = useLocation() const memberStats: UserStats | undefined = useMemberStats(props.profile?.handle) + const { data: roleStats }: { data?: MemberRoleStats } = useMemberRoleStats(props.profile?.handle) const memberBadges: UserBadgesResponse | undefined = useMemberBadges(props.profile?.userId as number, { limit: 500 }) @@ -45,8 +50,12 @@ const MemberTCAchievements: FC = (props: MemberTCAchi tcoQualifications={tcoQualifications} tcoTrips={tcoTrips} memberStats={memberStats} + roleStats={roleStats} /> - ), [memberStats, props.profile, tcoQualifications, tcoTrips, tcoWins]) + ), [memberStats, props.profile, roleStats, tcoQualifications, tcoTrips, tcoWins]) + + const hasSpecialRole = !!roleStats?.copilot?.challengeCount || !!roleStats?.reviewer?.challengeCount + const isRoleDetailsRoute = /\/stats\/roles\/[^/]+\/?$/i.test(location.pathname) if ( !memberStats?.challenges @@ -54,6 +63,8 @@ const MemberTCAchievements: FC = (props: MemberTCAchi && !tcoWins && !tcoQualifications && !tcoTrips + && !hasSpecialRole + && !isRoleDetailsRoute ) { return <> } @@ -66,6 +77,10 @@ const MemberTCAchievements: FC = (props: MemberTCAchi path='' element={renderDefaultRoute()} /> + } + /> = props => { + + {(hasTcoBanner || hasMemberStats) && (
{hasTcoBanner && ( @@ -41,7 +44,6 @@ const DefaultAchievementsView: FC = props => {
)} - ) } diff --git a/src/apps/profiles/src/member-profile/tc-achievements/member-role-details-view/MemberRoleDetailsView.module.scss b/src/apps/profiles/src/member-profile/tc-achievements/member-role-details-view/MemberRoleDetailsView.module.scss new file mode 100644 index 000000000..3b27c881b --- /dev/null +++ b/src/apps/profiles/src/member-profile/tc-achievements/member-role-details-view/MemberRoleDetailsView.module.scss @@ -0,0 +1,208 @@ +@import '@libs/ui/styles/includes'; + +.wrap { + container-type: inline-size; + + > h2 { + margin-top: $sp-4; + color: $black-80; + font-family: $font-roboto; + font-size: 20px; + font-weight: $font-weight-medium; + line-height: 24px; + text-transform: none; + } + + > hr { + margin: $sp-4 0 $sp-6; + border: 0; + border-top: 1px solid $black-10; + } +} + +.navigation { + display: flex; + align-items: center; + justify-content: space-between; +} + +.backLink, +.closeLink { + display: inline-flex; + align-items: center; + color: $link-blue-dark; + font-family: $font-roboto; + font-size: 14px; + font-weight: $font-weight-medium; + line-height: 20px; + + &:hover { + text-decoration: underline; + } +} + +.backLink > svg { + width: 18px; + height: 18px; +} + +.backLink { + line-height: 22px; +} + +.closeLink { + color: $turq-160; + + > svg { + width: 28px; + height: 28px; + } +} + +.statsSection { + min-height: 102px; + margin-bottom: $sp-6; + padding: $sp-4; + border-radius: 12px; + background: $black-5; + + > h3 { + margin-bottom: $sp-3; + color: $black-80; + font-family: $font-roboto; + font-size: 16px; + font-weight: $font-weight-medium; + line-height: 24px; + text-transform: none; + } +} + +.metrics { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: $sp-3 $sp-12; +} + +.metric { + display: flex; + align-items: baseline; + gap: $sp-2; + color: $black-80; + font-family: $font-roboto; + + > strong { + color: $turq-160; + font-family: $font-barlow-condensed; + font-size: 32px; + font-weight: $font-weight-medium; + line-height: 34px; + } + + > span { + font-size: 14px; + line-height: 20px; + } +} + +.challengeList { + max-height: 258px; + padding-right: $sp-3; + overflow-y: auto; + scrollbar-color: $black-20 transparent; + scrollbar-width: thin; +} + +.challengeGrid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: $sp-4; +} + +.challengeCard { + display: grid; + grid-template-columns: minmax(0, 1fr) 18px; + align-items: center; + gap: $sp-2; + min-height: 70px; + padding: $sp-3 $sp-2 $sp-3 $sp-4; + border: 1px solid $black-20; + border-radius: 8px; + color: $black-100; + font-family: $font-roboto; + font-size: 14px; + font-weight: $font-weight-medium; + line-height: 20px; + + &:hover { + border-color: $turq-120; + box-shadow: 0 1px 4px rgba($tc-black, 0.12); + } + + > span { + display: -webkit-box; + min-width: 0; + overflow: hidden; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + } + + > svg { + width: 18px; + height: 18px; + color: $black-60; + } +} + +.loadingState { + height: 120px; + overflow: hidden; + border-radius: $sp-2; +} + +.message { + display: flex; + min-height: 120px; + align-items: center; + justify-content: center; + flex-direction: column; + gap: $sp-3; + color: $black-60; + text-align: center; + + > button { + color: $link-blue-dark; + font-weight: $font-weight-bold; + + &:hover { + text-decoration: underline; + } + } +} + +@container (max-width: 700px) { + .challengeGrid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@container (max-width: 480px) { + .metrics { + align-items: flex-start; + flex-direction: column; + gap: $sp-2; + } + + .challengeList { + max-height: 360px; + } + + .challengeGrid { + grid-template-columns: 1fr; + gap: $sp-2; + } + + .challengeCard { + min-height: 58px; + } +} diff --git a/src/apps/profiles/src/member-profile/tc-achievements/member-role-details-view/MemberRoleDetailsView.spec.tsx b/src/apps/profiles/src/member-profile/tc-achievements/member-role-details-view/MemberRoleDetailsView.spec.tsx new file mode 100644 index 000000000..d2f4d9bd9 --- /dev/null +++ b/src/apps/profiles/src/member-profile/tc-achievements/member-role-details-view/MemberRoleDetailsView.spec.tsx @@ -0,0 +1,222 @@ +/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */ +import '@testing-library/jest-dom' +import { readFileSync } from 'fs' +import { render, RenderResult, screen } from '@testing-library/react' +import { MemoryRouter, Route, Routes } from 'react-router-dom' + +import type { + MemberRoleChallenges, + MemberSpecialRole, + UserProfile, +} from '~/libs/core' +import { useMemberRoleChallenges } from '~/libs/core' + +import MemberRoleDetailsView from './MemberRoleDetailsView' + +jest.mock('~/config', () => ({ + EnvironmentConfig: { + URLS: { + CHALLENGES_PAGE: 'https://challenges.example.com', + }, + }, +}), { + virtual: true, +}) + +jest.mock('~/libs/core', () => ({ + useMemberRoleChallenges: jest.fn(), +}), { + virtual: true, +}) + +jest.mock('~/libs/ui', () => ({ + IconOutline: { + ChevronLeftIcon: (): JSX.Element => , + ChevronRightIcon: (): JSX.Element => , + XIcon: (): JSX.Element => , + }, + LoadingSpinner: (): JSX.Element =>
Loading
, +}), { + virtual: true, +}) + +jest.mock('../../../profiles.routes', () => ({ + getUserProfileRoute: (handle: string): string => `/${handle}`, +})) + +const mockedUseMemberRoleChallenges = useMemberRoleChallenges as jest.MockedFunction< + typeof useMemberRoleChallenges +> +const profile = { handle: 'tester' } as UserProfile +const memberRoleDetailsStyles = readFileSync(`${__dirname}/MemberRoleDetailsView.module.scss`, 'utf8') + +describe('MemberRoleDetailsView styles', () => { + it('uses the approved role details typography and casing', () => { + const statsHeadingStyles = memberRoleDetailsStyles.match(/> h3 \{[\s\S]*?\n\s{4}\}/)?.[0] + + expect(memberRoleDetailsStyles) + .toMatch(/> h2 \{[\s\S]*?text-transform: none;/) + expect(memberRoleDetailsStyles) + .toMatch(/\.backLink \{[\s\S]*?line-height: 22px;/) + expect(statsHeadingStyles) + .toContain('font-size: 16px;') + expect(statsHeadingStyles) + .toContain('font-weight: $font-weight-medium;') + expect(statsHeadingStyles) + .toContain('line-height: 24px;') + expect(statsHeadingStyles) + .toContain('text-transform: none;') + expect(memberRoleDetailsStyles) + .toMatch(/> strong \{[\s\S]*?font-size: 32px;[\s\S]*?line-height: 34px;/) + }) + + it('bounds the loading state within the achievements card', () => { + expect(memberRoleDetailsStyles) + .toMatch(/\.loadingState \{[\s\S]*?height: 120px;[\s\S]*?overflow: hidden;/) + }) + + it('keeps long challenge lists in a bounded scrollable viewport', () => { + expect(memberRoleDetailsStyles) + .toMatch(/\.challengeList \{[\s\S]*?max-height: 258px;[\s\S]*?overflow-y: auto;/) + expect(memberRoleDetailsStyles) + .not.toMatch(/\.pagination \{/) + }) +}) + +/** + * Creates a complete SWR-shaped response for a role details page. + * + * This test helper does not throw. + * + * @param {MemberRoleChallenges} data - Loaded member role challenges. + * @returns {ReturnType} Hook response consumed by the component. + */ +function createHookResponse(data: MemberRoleChallenges): ReturnType { + return { + data, + error: undefined, + isValidating: false, + mutate: jest.fn(), + } +} + +/** + * Renders the details view at a nested profile role route. + * + * This test helper does not intentionally throw; render failures are reported by Testing Library. + * + * @param {MemberSpecialRole} role - Copilot or reviewer route to render. + * @param {UserProfile} memberProfile - Profile supplied to the role details view. + * @returns {RenderResult} Testing Library controls for the rendered role route. + */ +function renderRole(role: MemberSpecialRole, memberProfile: UserProfile = profile): RenderResult { + return render( + + + } + /> + + , + ) +} + +describe('MemberRoleDetailsView', () => { + beforeEach(() => { + mockedUseMemberRoleChallenges.mockReset() + }) + + it('shows nonzero copilot tracks, fulfillment, and linked challenge cards', () => { + mockedUseMemberRoleChallenges.mockReturnValue(createHookResponse({ + challenges: [ + { id: 'challenge-1', name: 'Newest public challenge' }, + { id: 'challenge-2', name: 'Earlier public challenge' }, + ], + fulfillment: { + cancelled: 11, + completed: 89, + rate: 88.95, + total: 100, + }, + role: 'copilot', + total: 90, + trackCounts: { + DATA_SCIENCE: 0, + DESIGN: 0, + DEVELOPMENT: 86, + QUALITY_ASSURANCE: 4, + }, + })) + + renderRole('copilot') + + expect(screen.getByText('Development Challenges')) + .toBeInTheDocument() + expect(screen.getByText('QA Challenges')) + .toBeInTheDocument() + expect(screen.queryByText('Design Challenges')).not.toBeInTheDocument() + expect(screen.queryByText('Data Science Challenges')).not.toBeInTheDocument() + expect(screen.getByText('88.95%')) + .toBeInTheDocument() + expect(screen.getByRole('link', { name: /newest public challenge/i })) + .toHaveAttribute('href', 'https://challenges.example.com/challenge-1') + }) + + it('shows the deduplicated reviewer challenge count', () => { + mockedUseMemberRoleChallenges.mockReturnValue(createHookResponse({ + challenges: [{ id: 'review-1', name: 'Reviewed challenge' }], + role: 'reviewer', + total: 9, + })) + + renderRole('reviewer') + + expect(screen.getByText('Reviewer Stats')) + .toBeInTheDocument() + expect(screen.getByText('9')) + .toBeInTheDocument() + expect(screen.getByText('Challenges')) + .toBeInTheDocument() + }) + + it('contains the loading spinner in the bounded loading state', () => { + mockedUseMemberRoleChallenges.mockReturnValue({ + data: undefined, + error: undefined, + isValidating: true, + mutate: jest.fn(), + }) + + renderRole('reviewer') + + expect(screen.getByText('Loading').parentElement) + .toHaveClass('loadingState') + }) + + it.each(['copilot', 'reviewer'])( + 'shows the complete %s challenge list without pagination controls', + role => { + mockedUseMemberRoleChallenges.mockReturnValue(createHookResponse({ + challenges: [ + { id: 'challenge-1', name: 'Newest challenge' }, + { id: 'challenge-101', name: 'Challenge beyond the old limit' }, + ], + role, + total: 101, + })) + + renderRole(role) + + expect(mockedUseMemberRoleChallenges) + .toHaveBeenCalledWith('tester', role) + expect(screen.getByText('Newest challenge')) + .toBeInTheDocument() + expect(screen.getByText('Challenge beyond the old limit')) + .toBeInTheDocument() + expect(screen.queryByRole('button')) + .not.toBeInTheDocument() + }, + ) + +}) diff --git a/src/apps/profiles/src/member-profile/tc-achievements/member-role-details-view/MemberRoleDetailsView.tsx b/src/apps/profiles/src/member-profile/tc-achievements/member-role-details-view/MemberRoleDetailsView.tsx new file mode 100644 index 000000000..d1275a96f --- /dev/null +++ b/src/apps/profiles/src/member-profile/tc-achievements/member-role-details-view/MemberRoleDetailsView.tsx @@ -0,0 +1,263 @@ +import { + FC, + useMemo, +} from 'react' +import { Link, Navigate, Params, useParams } from 'react-router-dom' +import { SWRResponse } from 'swr' + +import { EnvironmentConfig } from '~/config' +import { + MemberRoleChallenge, + MemberRoleChallenges, + MemberRoleTrack, + MemberSpecialRole, + useMemberRoleChallenges, + UserProfile, +} from '~/libs/core' +import { IconOutline, LoadingSpinner } from '~/libs/ui' + +import { getUserProfileRoute } from '../../../profiles.routes' + +import styles from './MemberRoleDetailsView.module.scss' + +interface MemberRoleDetailsViewProps { + profile: UserProfile +} + +interface RoleMetric { + label: string + value: string +} + +const roleTrackLabels: Record = { + DATA_SCIENCE: 'Data Science Challenges', + DESIGN: 'Design Challenges', + DEVELOPMENT: 'Development Challenges', + QUALITY_ASSURANCE: 'QA Challenges', +} + +const roleTrackOrder: MemberRoleTrack[] = [ + 'DEVELOPMENT', + 'DESIGN', + 'QUALITY_ASSURANCE', + 'DATA_SCIENCE', +] + +const fulfillmentRateFormatter = new Intl.NumberFormat('en-US', { + maximumFractionDigits: 2, +}) + +/** + * Narrows an arbitrary route segment to a supported profile special role. + * + * This function does not throw. + * + * @param {string | undefined} role - Role route segment. + * @returns {MemberSpecialRole | undefined} Valid role identifier when supported. + */ +const parseRole = (role?: string): MemberSpecialRole | undefined => ( + role === 'copilot' || role === 'reviewer' ? role : undefined +) + +/** + * Converts a role identifier to the heading used in the Figma detail panel. + * + * This function does not throw. + * + * @param {MemberSpecialRole} role - Copilot or reviewer role identifier. + * @returns {string} Human-readable role heading. + */ +const getRoleTitle = (role: MemberSpecialRole): string => ( + role === 'copilot' ? 'Copilot' : 'Reviewer' +) + +/** + * Formats a fulfillment percentage with at most two fractional digits. + * + * This function does not throw. + * + * @param {number} rate - API-provided percentage on a zero-to-100 scale. + * @returns {string} Localized percentage value. + */ +const formatFulfillmentRate = (rate: number): string => `${fulfillmentRateFormatter + .format(rate)}%` + +/** + * Builds the Figma summary-strip metrics for a role challenge list. + * + * Copilot tracks with zero challenges are intentionally omitted. Reviewer views + * show only the deduplicated challenge total. + * + * This function does not throw. + * + * @param {MemberSpecialRole} role - Role displayed by the detail page. + * @param {MemberRoleChallenges | undefined} data - Loaded challenges and aggregate statistics. + * @returns {RoleMetric[]} Ordered display metrics. + */ +const getRoleMetrics = ( + role: MemberSpecialRole, + data?: MemberRoleChallenges, +): RoleMetric[] => { + if (!data) { + return [] + } + + if (role === 'reviewer') { + return [{ + label: data.total === 1 ? 'Challenge' : 'Challenges', + value: data.total.toLocaleString('en-US'), + }] + } + + const trackMetrics: RoleMetric[] = roleTrackOrder + .filter(track => (data.trackCounts?.[track] ?? 0) > 0) + .map(track => ({ + label: roleTrackLabels[track], + value: (data.trackCounts?.[track] ?? 0).toLocaleString('en-US'), + })) + + return data.fulfillment + ? [ + ...trackMetrics, + { + label: 'Fulfillment Rate', + value: formatFulfillmentRate(data.fulfillment.rate), + }, + ] + : trackMetrics +} + +/** + * Renders one linked challenge card in the role history grid. + * + * This component does not throw. + * + * @param {{ challenge: MemberRoleChallenge }} props - Challenge returned by member-api-v6. + * @returns {JSX.Element} External link to the Topcoder challenge page. + */ +const RoleChallengeCard: FC<{ challenge: MemberRoleChallenge }> = props => ( + + {props.challenge.name || `Challenge ${props.challenge.id}`} + + +) + +/** + * Displays a newest-first copilot or reviewer challenge history in a scrollable viewport. + * + * Request failures are rendered in the panel instead of thrown by this component. + * + * @param {MemberRoleDetailsViewProps} props - Profile whose role details are displayed. + * @returns {JSX.Element} Figma-matched role detail panel or a redirect for invalid roles. + */ +const MemberRoleDetailsView: FC = props => { + const routeParams: Readonly> = useParams() + const role = parseRole(routeParams.roleType) + const { + data, + error, + isValidating, + mutate, + }: SWRResponse = useMemberRoleChallenges( + props.profile.handle, + role, + ) + const roleTitle = role ? getRoleTitle(role) : '' + const metrics = useMemo(() => ( + role ? getRoleMetrics(role, data) : [] + ), [data, role]) + + /** + * Revalidates the role challenge list after a request failure. + * + * @returns {void} This click handler starts SWR revalidation and does not return a value. + * Request failures remain in SWR's error state and are not thrown synchronously. + */ + function handleRetry(): void { + mutate() + .catch(() => undefined) + } + + if (!role) { + return + } + + return ( +
+
+ + + Back to Member Stats + + + + +
+ +

{roleTitle}

+
+ +
+

+ {roleTitle} + {' '} + Stats +

+
+ {metrics.map(metric => ( +
+ {metric.value} + {metric.label} +
+ ))} +
+
+ + {!data && !error && ( +
+ +
+ )} + + {error && !data && ( +
+

{`We couldn't load ${roleTitle.toLowerCase()} challenges.`}

+ +
+ )} + + {data && data.challenges.length === 0 && ( +

No role challenges were found.

+ )} + + {data && data.challenges.length > 0 && ( +
+
+ {data.challenges.map(challenge => ( + + ))} +
+
+ )} +
+ ) +} + +export default MemberRoleDetailsView diff --git a/src/apps/profiles/src/member-profile/tc-achievements/member-role-details-view/index.ts b/src/apps/profiles/src/member-profile/tc-achievements/member-role-details-view/index.ts new file mode 100644 index 000000000..0e2343d70 --- /dev/null +++ b/src/apps/profiles/src/member-profile/tc-achievements/member-role-details-view/index.ts @@ -0,0 +1 @@ +export { default as MemberRoleDetailsView } from './MemberRoleDetailsView' diff --git a/src/apps/profiles/src/profiles.routes.tsx b/src/apps/profiles/src/profiles.routes.tsx index 404abc623..590091a86 100644 --- a/src/apps/profiles/src/profiles.routes.tsx +++ b/src/apps/profiles/src/profiles.routes.tsx @@ -1,4 +1,4 @@ -import { lazyLoad, LazyLoadedComponent, PlatformRoute } from '~/libs/core' +import { lazyLoad, LazyLoadedComponent, MemberSpecialRole, PlatformRoute } from '~/libs/core' import { AppSubdomain, EnvironmentConfig, ToolTitle } from '~/config' const ProfilesApp: LazyLoadedComponent = lazyLoad(() => import('./ProfilesApp')) @@ -26,6 +26,19 @@ export const getUserProfileStatsRoute = ( `${getUserProfileRoute(userHandle)}${track ? `/stats/${track}` : ''}${!(track && subTrack) ? '' : `/${subTrack}`}` ) +/** + * Builds the profile route for a member's copilot or reviewer challenge details. + * + * This function does not throw. + * + * @param {string} userHandle - Member handle shown on the profile. + * @param {MemberSpecialRole} role - Special role whose challenge history should be displayed. + * @returns {string} Profile-relative route for the requested special role. + */ +export const getUserProfileRoleRoute = (userHandle: string, role: MemberSpecialRole): string => ( + `${getUserProfileRoute(userHandle)}/stats/roles/${role}` +) + export const profilesRoutes: ReadonlyArray = [ { children: [ diff --git a/src/apps/review/README.md b/src/apps/review/README.md index de43e93f1..2258c46c6 100644 --- a/src/apps/review/README.md +++ b/src/apps/review/README.md @@ -21,3 +21,15 @@ sudo yarn start ### Mock data: - Mock data files are under src/apps/review/src/mock-datas + +### Winners result identity: + +- The Winners tab loads every page from the Review API `projectResult` endpoint. +- Each final-placement winner is matched by normalized member ID and placement. The endpoint's + `submissionId` is authoritative for display and download; another submission from the same + member is never substituted based on score or recency. +- Local submission and review data may enrich the submitted date and reviews only when the local + submission ID exactly matches the canonical ID. Missing or malformed canonical results are + omitted safely. +- Canonical `PLACEMENT` winner types are shown. Untyped and contest-submission winner types remain + supported for legacy challenge records, while checkpoint winner types are excluded. diff --git a/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentReview.spec.tsx b/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentReview.spec.tsx new file mode 100644 index 000000000..665c3ec19 --- /dev/null +++ b/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentReview.spec.tsx @@ -0,0 +1,211 @@ +/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */ +import { render } from '@testing-library/react' + +import { + ChallengeDetailContext, +} from '../../contexts' +import type { + ChallengeDetailContextModel, + ChallengeInfo, + SubmissionInfo, +} from '../../models' + +import { TabContentReview } from './TabContentReview' + +const mockUseRole = jest.fn() +const mockTableAppealsForSubmitter = jest.fn() +const mockTableAppealsResponse = jest.fn() +const mockTableReviewForSubmitter = jest.fn() + +jest.mock('~/config', () => ({ + EnvironmentConfig: { + REVIEW: { + PROFILE_PAGE_URL: 'https://profiles.test', + }, + }, +}), { virtual: true }) + +jest.mock('~/libs/core', () => ({ + getRatingColor: () => '#2a2a2a', +}), { virtual: true }) + +jest.mock('../../contexts', () => { + const React: typeof import('react') = jest.requireActual('react') + + return { + ChallengeDetailContext: React.createContext({}), + } +}) + +jest.mock('../../hooks', () => ({ + useRole: () => mockUseRole(), +})) + +jest.mock('~/apps/admin/src/lib', () => ({ + TableLoading: () =>
Loading
, +}), { virtual: true }) + +jest.mock('../TableAppeals', () => ({ + TableAppeals: () =>
Reviewer appeals
, +})) + +jest.mock('../TableAppealsForSubmitter', () => ({ + TableAppealsForSubmitter: (props: { datas: SubmissionInfo[] }) => { + mockTableAppealsForSubmitter(props) + return ( +
+ {props.datas.map(submission => submission.id) + .join(',')} +
+ ) + }, +})) + +jest.mock('../TableAppealsResponse', () => ({ + TableAppealsResponse: (props: { datas: SubmissionInfo[] }) => { + mockTableAppealsResponse(props) + return ( +
+ {props.datas.map(submission => submission.id) + .join(',')} +
+ ) + }, +})) + +jest.mock('../TableNoRecord', () => ({ + TableNoRecord: (props: { message: string }) =>
{props.message}
, +})) + +jest.mock('../TableReview', () => ({ + TableReview: () =>
Reviewer reviews
, +})) + +jest.mock('../TableReviewForSubmitter', () => ({ + TableReviewForSubmitter: (props: { datas: SubmissionInfo[] }) => { + mockTableReviewForSubmitter(props) + return ( +
+ {props.datas.map(submission => submission.id) + .join(',')} +
+ ) + }, +})) + +const ownSubmission = { + id: 'own-submission', + isLatest: true, + memberId: 'member-current', +} as SubmissionInfo +const foreignSubmission = { + id: 'foreign-submission', + isLatest: true, + memberId: 'member-other', +} as SubmissionInfo +const challengeInfo = { + metadata: [], + phases: [], + status: 'Completed', + submissions: [ + ownSubmission, + foreignSubmission, + ], + track: { + name: 'Development', + }, + type: { + name: 'Challenge', + }, +} as unknown as ChallengeInfo +const challengeContext = { + challengeInfo, + challengeSubmissions: [], + myResources: [ + { + memberId: 'member-current', + roleName: 'Submitter', + }, + ], + myRoles: ['Submitter'], + resourceMemberIdMapping: {}, + resources: [], + reviewers: [], +} as unknown as ChallengeDetailContextModel +const commonProps = { + downloadSubmission: jest.fn(), + isActiveChallenge: false, + isDownloading: {}, + isLoadingReview: false, + mappingReviewAppeal: {}, + reviewMinimumPassingScore: undefined, + reviews: [], + screeningOutcome: { + failingSubmissionIds: new Set(), + passingSubmissionIds: new Set(), + }, + submitterReviews: [ + ownSubmission, + foreignSubmission, + ], +} + +describe('TabContentReview submitter Appeals ownership', () => { + beforeEach(() => { + jest.clearAllMocks() + mockUseRole.mockReturnValue({ + actionChallengeRole: 'Submitter', + hasApproverRole: false, + isPrivilegedRole: false, + }) + }) + + it('passes only owned rows to Appeals and Appeals Response', () => { + const rendered = render( + + + , + ) + + expect(mockTableAppealsForSubmitter) + .toHaveBeenLastCalledWith(expect.objectContaining({ + datas: [ownSubmission], + })) + + rendered.rerender( + + + , + ) + + expect(mockTableAppealsResponse) + .toHaveBeenLastCalledWith(expect.objectContaining({ + datas: [ownSubmission], + })) + }) + + it('leaves the completed Review tab data set unchanged', () => { + render( + + + , + ) + + expect(mockTableReviewForSubmitter) + .toHaveBeenLastCalledWith(expect.objectContaining({ + datas: [ + ownSubmission, + foreignSubmission, + ], + })) + }) +}) diff --git a/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentReview.tsx b/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentReview.tsx index fd108a356..b97df3859 100644 --- a/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentReview.tsx +++ b/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentReview.tsx @@ -24,7 +24,11 @@ import { ReviewResult, SubmissionInfo, } from '../../models' -import { hasIsLatestFlag, isMarathonMatchChallenge } from '../../utils' +import { + filterSubmissionRowsByOwnership, + hasIsLatestFlag, + isMarathonMatchChallenge, +} from '../../utils' import { TableAppeals } from '../TableAppeals' import { TableAppealsForSubmitter } from '../TableAppealsForSubmitter' import { TableAppealsResponse } from '../TableAppealsResponse' @@ -748,6 +752,21 @@ export const TabContentReview: FC = (props: Props) => { }, [resolvedSubmitterReviews], ) + const submitterAppealRows = useMemo( + () => (isSubmitterView + ? filterSubmissionRowsByOwnership( + filteredSubmitterReviews, + myOwnedMemberIds, + myOwnedSubmissionIds, + ) + : filteredSubmitterReviews), + [ + filteredSubmitterReviews, + isSubmitterView, + myOwnedMemberIds, + myOwnedSubmissionIds, + ], + ) const reviewerRowsForReviewTab = useMemo( () => (shouldSortReviewTabByScore ? sortSubmissionsByReviewScoreDesc(filteredReviews, useAggregateReviewScore) @@ -774,7 +793,7 @@ export const TabContentReview: FC = (props: Props) => { if (selectedTab === 'Appeals Response') { const appealsResponseDatas = isSubmitterView - ? filteredSubmitterReviews + ? submitterAppealRows : resolvedReviewsWithSubmitter return ( = (props: Props) => { if (selectedTab === 'Appeals') { return isSubmitterView ? ( ({ + EnvironmentConfig: { + REVIEW: { + PROFILE_PAGE_URL: 'https://profiles.test', + }, + }, +}), { virtual: true }) + +jest.mock('../../contexts', () => { + const React: typeof import('react') = jest.requireActual('react') + + return { + ChallengeDetailContext: React.createContext({}), + } +}) + +jest.mock('~/libs/core', () => ({ + getRatingColor: () => '#2a2a2a', +}), { virtual: true }) + +jest.mock('~/libs/shared', () => ({ + useWindowSize: () => ({ + height: 800, + width: 1200, + }), +}), { virtual: true }) + +jest.mock('~/apps/admin/src/lib/components/common/TableMobile', () => ({ + TableMobile: () =>
Mobile table
, +}), { virtual: true }) + +jest.mock('~/libs/ui', () => ({ + Table: (props: { + columns: Array<{ + renderer?: ( + row: SubmissionReviewerRow, + rows: SubmissionReviewerRow[], + ) => JSX.Element + }> + data: SubmissionReviewerRow[] + }) => ( +
+ {props.data.map(row => ( +
+ {props.columns[0].renderer?.(row, props.data)} +
+ ))} +
+ ), +}), { virtual: true }) + +jest.mock('../../hooks', () => ({ + useRolePermissions: () => ({ + ownedMemberIds: new Set(['member-current']), + }), + useScoreVisibility: () => ({ + canDisplayScores: () => true, + isChallengeCompleted: true, + isPastChallengeStatus: true, + }), + useSubmissionDownloadAccess: () => ({ + getRestrictionMessageForMember: () => undefined, + isSubmissionDownloadRestricted: false, + isSubmissionDownloadRestrictedForMember: () => false, + restrictionMessage: undefined, + shouldRestrictSubmitterToOwnSubmission: false, + }), +})) + +jest.mock('../common/TableColumnRenderers', () => { + const React = jest.requireActual('react') + /** + * Renders an inert cell for columns outside this ownership-boundary test. + * + * @returns An empty span element. + * @throws This test helper does not throw. + */ + const emptyCell = (): JSX.Element => React.createElement('span') + + return { + renderAppealsCell: emptyCell, + renderReviewDateCell: emptyCell, + renderReviewerCell: emptyCell, + renderReviewScoreCell: emptyCell, + renderScoreCell: emptyCell, + renderSubmissionIdCell: ( + row: SubmissionReviewerRow, + config: DownloadButtonConfig, + ) => { + mockRenderSubmissionIdCell(row, config) + return React.createElement('span', undefined, row.id) + }, + renderSubmitterHandleCell: emptyCell, + } +}) + +jest.mock('../CollapsibleAiReviewsRow', () => ({ + CollapsibleAiReviewsRow: () =>
AI reviews
, +})) + +jest.mock('../TableWrapper', () => ({ + TableWrapper: (props: { children: JSX.Element }) =>
{props.children}
, +})) + +const ownSubmission = { + id: 'own-submission', + isLatest: true, + memberId: 'member-current', + type: 'CONTEST_SUBMISSION', +} as SubmissionInfo +const foreignSubmission = { + id: 'foreign-submission', + isLatest: true, + memberId: 'member-other', + type: 'CONTEST_SUBMISSION', +} as SubmissionInfo +const challengeInfo = { + metadata: [], + phases: [ + { + isOpen: false, + name: 'Appeals', + }, + ], + status: 'Completed', + submissions: [ + ownSubmission, + foreignSubmission, + ], + track: { + name: 'Development', + }, + type: { + name: 'Challenge', + }, +} as unknown as ChallengeInfo +const challengeContext = { + challengeInfo, + myResources: [ + { + memberId: 'member-current', + roleName: 'Submitter', + }, + ], + reviewers: [], +} as unknown as ChallengeDetailContextModel + +describe('TableAppealsForSubmitter ownership boundary', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('drops foreign rows and always enables the table-level ownership guard', () => { + render( + + + , + ) + + expect(mockRenderSubmissionIdCell) + .toHaveBeenCalledTimes(1) + expect(mockRenderSubmissionIdCell) + .toHaveBeenCalledWith( + expect.objectContaining({ + id: 'own-submission', + memberId: 'member-current', + }), + expect.objectContaining({ + shouldRestrictSubmitterToOwnSubmission: true, + }), + ) + expect(mockRenderSubmissionIdCell) + .not + .toHaveBeenCalledWith( + expect.objectContaining({ + id: 'foreign-submission', + }), + expect.anything(), + ) + }) +}) diff --git a/src/apps/review/src/lib/components/TableAppealsForSubmitter/TableAppealsForSubmitter.tsx b/src/apps/review/src/lib/components/TableAppealsForSubmitter/TableAppealsForSubmitter.tsx index 0123f81a1..90bcd2e3e 100644 --- a/src/apps/review/src/lib/components/TableAppealsForSubmitter/TableAppealsForSubmitter.tsx +++ b/src/apps/review/src/lib/components/TableAppealsForSubmitter/TableAppealsForSubmitter.tsx @@ -39,6 +39,7 @@ import type { import { aggregateSubmissionReviews, challengeHasSubmissionLimit, + filterSubmissionRowsByOwnership, isAppealsPhase, isAppealsResponsePhase, partitionSubmissionHistory, @@ -92,7 +93,6 @@ export const TableAppealsForSubmitter: FC = (prop isSubmissionDownloadRestricted, isSubmissionDownloadRestrictedForMember, restrictionMessage, - shouldRestrictSubmitterToOwnSubmission, }: UseSubmissionDownloadAccessResult = downloadAccess const { @@ -118,6 +118,38 @@ export const TableAppealsForSubmitter: FC = (prop const challengeType: ChallengeInfo['type'] | undefined = challengeInfo?.type const challengeTrack: ChallengeInfo['track'] | undefined = challengeInfo?.track + const ownedSubmissionIds = useMemo>( + () => { + const ids = new Set() + const submissions = [ + ...(challengeInfo?.submissions ?? []), + ...datas, + ] + + submissions.forEach(submission => { + const memberId = `${submission.memberId ?? ''}`.trim() + const submissionId = `${submission.id ?? ''}`.trim() + if ( + memberId.length + && submissionId.length + && ownedMemberIds.has(memberId) + ) { + ids.add(submissionId) + } + }) + + return ids + }, + [challengeInfo?.submissions, datas, ownedMemberIds], + ) + const ownedDatas = useMemo( + () => filterSubmissionRowsByOwnership( + datas, + ownedMemberIds, + ownedSubmissionIds, + ), + [datas, ownedMemberIds, ownedSubmissionIds], + ) const hasAppealsPhase = useMemo(() => { const phases = challengeInfo?.phases ?? [] @@ -155,21 +187,25 @@ export const TableAppealsForSubmitter: FC = (prop const submissionTypes = useMemo>( () => new Set( - datas + ownedDatas .map(submission => submission.type) .filter((type): type is string => Boolean(type)), ), - [datas], + [ownedDatas], ) const filteredAll = useMemo( () => { - const allSubmissions = challengeInfo?.submissions ?? [] + const allSubmissions = filterSubmissionRowsByOwnership( + challengeInfo?.submissions ?? [], + ownedMemberIds, + ownedSubmissionIds, + ) const typedFiltered = allSubmissions.filter( submission => submission.type && submissionTypes.has(submission.type), ) const fallbackIds = new Set( - datas + ownedDatas .map(submission => submission.id) .filter((id): id is string => Boolean(id)), ) @@ -179,12 +215,18 @@ export const TableAppealsForSubmitter: FC = (prop return submissionTypes.size ? typedFiltered : filtered }, - [challengeInfo?.submissions, datas, submissionTypes], + [ + challengeInfo?.submissions, + ownedDatas, + ownedMemberIds, + ownedSubmissionIds, + submissionTypes, + ], ) const submissionHistory = useMemo( - () => partitionSubmissionHistory(datas, filteredAll), - [datas, filteredAll], + () => partitionSubmissionHistory(ownedDatas, filteredAll), + [filteredAll, ownedDatas], ) const restrictToLatest = useMemo( @@ -196,7 +238,7 @@ export const TableAppealsForSubmitter: FC = (prop () => { const sourceSubmissions = restrictToLatest ? submissionHistory.latestSubmissions - : datas + : ownedDatas if (!filteredAll.length) { return sourceSubmissions @@ -226,7 +268,7 @@ export const TableAppealsForSubmitter: FC = (prop } }) }, - [datas, filteredAll, restrictToLatest, submissionHistory], + [filteredAll, ownedDatas, restrictToLatest, submissionHistory], ) const aggregatedRows = useMemo( @@ -270,7 +312,7 @@ export const TableAppealsForSubmitter: FC = (prop isSubmissionDownloadRestrictedForMember, ownedMemberIds, restrictionMessage, - shouldRestrictSubmitterToOwnSubmission, + shouldRestrictSubmitterToOwnSubmission: true, }), [ downloadSubmission, getRestrictionMessageForMember, @@ -279,16 +321,20 @@ export const TableAppealsForSubmitter: FC = (prop isSubmissionDownloadRestrictedForMember, ownedMemberIds, restrictionMessage, - shouldRestrictSubmitterToOwnSubmission, ]) const isOwned = useCallback<( submission: SubmissionRow) => boolean >( - (submission: SubmissionRow) => ( - submission.memberId ? ownedMemberIds.has(submission.memberId) : false - ), - [ownedMemberIds], + (submission: SubmissionRow) => { + const memberId = `${submission.memberId ?? ''}`.trim() + if (memberId.length) { + return ownedMemberIds.has(memberId) + } + + return ownedSubmissionIds.has(`${submission.id ?? ''}`.trim()) + }, + [ownedMemberIds, ownedSubmissionIds], ) const columns = useMemo[]>(() => { @@ -332,7 +378,7 @@ export const TableAppealsForSubmitter: FC = (prop const isOwnedSubmission = isOwned(submission) const scoreConfig: ScoreVisibilityConfig = { canDisplayScores, - canViewScorecard: isChallengeCompleted || isOwnedSubmission, + canViewScorecard: isOwnedSubmission, isAppealsTab: true, } @@ -362,7 +408,7 @@ export const TableAppealsForSubmitter: FC = (prop const isOwnedSubmission = isOwned(submission) const scoreConfig: ScoreVisibilityConfig = { canDisplayScores, - canViewScorecard: isChallengeCompleted || isOwnedSubmission, + canViewScorecard: isOwnedSubmission, isAppealsTab: true, } @@ -380,7 +426,7 @@ export const TableAppealsForSubmitter: FC = (prop const isOwnedSubmission = isOwned(submission) const scoreConfig: ScoreVisibilityConfig = { canDisplayScores, - canViewScorecard: isChallengeCompleted || isOwnedSubmission, + canViewScorecard: isOwnedSubmission, isAppealsTab: true, } diff --git a/src/apps/review/src/lib/components/TableAppealsResponse/TableAppealsResponse.tsx b/src/apps/review/src/lib/components/TableAppealsResponse/TableAppealsResponse.tsx index 20e7941be..4b44efcfa 100644 --- a/src/apps/review/src/lib/components/TableAppealsResponse/TableAppealsResponse.tsx +++ b/src/apps/review/src/lib/components/TableAppealsResponse/TableAppealsResponse.tsx @@ -28,7 +28,10 @@ import { aggregateSubmissionReviews, } from '../../utils/aggregateSubmissionReviews' import { challengeHasSubmissionLimit } from '../../utils/challenge' -import { hasIsLatestFlag } from '../../utils' +import { + filterSubmissionRowsByOwnership, + hasIsLatestFlag, +} from '../../utils' import { getReviewRoute } from '../../utils/routes' import { TableWrapper } from '../TableWrapper' import { @@ -142,18 +145,6 @@ export const TableAppealsResponse: FC = (props: Table ], ) - const normalizedChallengeStatus = useMemo( - () => (challengeInfo?.status ?? '') - .trim() - .toUpperCase(), - [challengeInfo?.status], - ) - - const submitterCanViewAllRows = useMemo( - () => normalizedChallengeStatus.startsWith('COMPLETED'), - [normalizedChallengeStatus], - ) - const submissionTypes = useMemo>( () => new Set( datas @@ -231,6 +222,30 @@ export const TableAppealsResponse: FC = (props: Table aggregatedResults, filteredChallengeSubmissions, ]) + const ownedSubmissionIds = useMemo>( + () => { + const ids = new Set() + const submissions = [ + ...(challengeInfo?.submissions ?? []), + ...datas, + ] + + submissions.forEach(submission => { + const memberId = `${submission.memberId ?? ''}`.trim() + const submissionId = `${submission.id ?? ''}`.trim() + if ( + memberId.length + && submissionId.length + && ownedMemberIds.has(memberId) + ) { + ids.add(submissionId) + } + }) + + return ids + }, + [challengeInfo?.submissions, datas, ownedMemberIds], + ) const visibleRows = useMemo(() => { if (!canRender) { @@ -241,17 +256,18 @@ export const TableAppealsResponse: FC = (props: Table return aggregatedRows } - if (canViewAsSubmitter && submitterCanViewAllRows) { - return aggregatedRows - } + const ownedSubmitterRows = new Set( + canViewAsSubmitter + ? filterSubmissionRowsByOwnership( + aggregatedRows, + ownedMemberIds, + ownedSubmissionIds, + ) + : [], + ) const matchesSubmitter = (row: SubmissionRow): boolean => { - if (!canViewAsSubmitter) { - return false - } - - const memberId = row.memberId - if (!memberId || !ownedMemberIds.has(memberId)) { + if (!canViewAsSubmitter || !ownedSubmitterRows.has(row)) { return false } @@ -281,7 +297,7 @@ export const TableAppealsResponse: FC = (props: Table canViewAsSubmitter, myReviewerResourceIds, ownedMemberIds, - submitterCanViewAllRows, + ownedSubmissionIds, ]) const reviewerRows = useMemo( @@ -298,7 +314,11 @@ export const TableAppealsResponse: FC = (props: Table isSubmissionDownloadRestrictedForMember, ownedMemberIds, restrictionMessage, - shouldRestrictSubmitterToOwnSubmission, + shouldRestrictSubmitterToOwnSubmission: canViewAsSubmitter + && !canViewAllAppeals + && !canViewAsReviewer + ? true + : shouldRestrictSubmitterToOwnSubmission, }), [ downloadSubmission, @@ -309,6 +329,9 @@ export const TableAppealsResponse: FC = (props: Table ownedMemberIds, restrictionMessage, shouldRestrictSubmitterToOwnSubmission, + canViewAllAppeals, + canViewAsReviewer, + canViewAsSubmitter, ], ) diff --git a/src/apps/review/src/lib/hooks/useFetchChallengeResults.integration.spec.tsx b/src/apps/review/src/lib/hooks/useFetchChallengeResults.integration.spec.tsx new file mode 100644 index 000000000..1c1ed4e31 --- /dev/null +++ b/src/apps/review/src/lib/hooks/useFetchChallengeResults.integration.spec.tsx @@ -0,0 +1,269 @@ +/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */ +import { act } from 'react' +import type { FC, PropsWithChildren } from 'react' + +import { renderHook, waitFor } from '@testing-library/react' +import type { RenderHookResult } from '@testing-library/react' +import { SWRConfig } from 'swr' + +import { handleError } from '~/libs/shared' + +import type { + ChallengeDetailContextModel, + ChallengeInfo, + ProjectResult, + SubmissionInfo, +} from '../models' +import { + fetchAllChallengeReviews, + fetchAllProjectResults, + fetchAllSubmissions, +} from '../services' +import { ChallengeDetailContext } from '../contexts/ChallengeDetailContext' + +import { + useFetchChallengeResults, + useFetchChallengeResultsProps, +} from './useFetchChallengeResults' + +jest.mock('~/libs/core', () => ({ + getRatingColor: jest.fn() + .mockReturnValue('#000000'), +}), { virtual: true }) + +jest.mock('~/config', () => ({ + EnvironmentConfig: {}, +}), { virtual: true }) + +jest.mock('~/libs/shared', () => ({ + handleError: jest.fn(), +}), { virtual: true }) + +jest.mock('../services', () => ({ + fetchAllChallengeReviews: jest.fn(), + fetchAllProjectResults: jest.fn(), + fetchAllSubmissions: jest.fn(), +})) + +const mockedFetchAllChallengeReviews = fetchAllChallengeReviews as jest.MockedFunction< + typeof fetchAllChallengeReviews +> +const mockedFetchAllProjectResults = fetchAllProjectResults as jest.MockedFunction< + typeof fetchAllProjectResults +> +const mockedFetchAllSubmissions = fetchAllSubmissions as jest.MockedFunction< + typeof fetchAllSubmissions +> +const mockedHandleError = handleError as jest.MockedFunction + +/** + * Creates a minimal submission row for canonical winner hook tests. + * + * @param overrides fields that differ from the default winning submission. + * @returns A challenge submission suitable for local display enrichment. + */ +const buildSubmission = (overrides: Partial = {}): SubmissionInfo => ({ + id: 'canonical-submission', + memberId: '1001', + placement: 1, + reviews: [], + submittedDate: '2026-01-03T00:00:00.000Z', + submitterHandle: 'winner-handle', + type: 'CONTEST_SUBMISSION', + ...overrides, +}) + +/** + * Creates the Review API's canonical final-placement result for hook tests. + * + * @param overrides fields that differ from the default project-result row. + * @returns A canonical project result carrying the winning submission id. + */ +const buildProjectResult = (overrides: Partial = {}): ProjectResult => ({ + challengeId: 'challenge-id', + createdAt: '2026-01-05T00:00:00.000Z', + finalScore: 81, + initialScore: 75, + placement: 1, + reviews: [], + submissionId: 'canonical-submission', + userId: '1001', + ...overrides, +}) + +/** + * Creates the challenge detail context required by the result hook. + * + * @param submissions local challenge submissions shown by the detail page. + * @returns A completed challenge context with one final placement winner. + */ +const buildContextValue = ( + submissions: SubmissionInfo[], +): ChallengeDetailContextModel => ({ + aiReviewDecisionsBySubmissionId: {}, + challengeId: 'challenge-id', + challengeInfo: { + id: 'challenge-id', + status: 'COMPLETED', + submissions, + winners: [{ + handle: 'winner-handle', + placement: 1, + type: 'PLACEMENT', + userId: 1001, + }], + } as ChallengeInfo, + challengeInfoError: undefined, + challengeResourcesError: undefined, + challengeScopedFetchError: undefined, + challengeSubmissions: [], + challengeSubmissionsError: undefined, + hasChallengeScopedFetchError: false, + isLoadingAiReviewConfig: false, + isLoadingAiReviewDecisions: false, + isLoadingChallengeInfo: false, + isLoadingChallengeResources: false, + isLoadingChallengeSubmissions: false, + myResources: [], + myRoles: [], + registrants: [], + resourceMemberIdMapping: {}, + resources: [], + retryChallengeScopedFetches: () => undefined, + reviewers: [], +}) + +/** + * Builds an isolated SWR and challenge-context wrapper for each hook test. + * + * @param contextValue challenge data supplied to the hook. + * @returns A React wrapper with a fresh cache and retries disabled. + */ +function createWrapper(contextValue: ChallengeDetailContextModel): FC { + return function Wrapper(props: PropsWithChildren): JSX.Element { + return ( + new Map(), + shouldRetryOnError: false, + }} + > + + {props.children} + + + ) + } +} + +describe('useFetchChallengeResults canonical project results', () => { + beforeEach(() => { + jest.clearAllMocks() + mockedFetchAllChallengeReviews.mockResolvedValue([]) + mockedFetchAllSubmissions.mockResolvedValue([]) + }) + + it('keeps loading until the canonical result resolves and never selects a sibling', async () => { + let resolveProjectResults: (results: ProjectResult[]) => void = () => undefined + const projectResultsPromise = new Promise(resolve => { + resolveProjectResults = resolve + }) + mockedFetchAllProjectResults.mockReturnValue(projectResultsPromise) + const submissions = [ + buildSubmission({ + aggregateScore: 99, + id: 'higher-scoring-sibling', + submittedDate: '2026-01-06T00:00:00.000Z', + }), + buildSubmission({ aggregateScore: 70 }), + ] + + const { result }: RenderHookResult< + useFetchChallengeResultsProps, + unknown + > = renderHook( + () => useFetchChallengeResults(submissions), + { wrapper: createWrapper(buildContextValue(submissions)) }, + ) + + await waitFor(() => expect(mockedFetchAllProjectResults) + .toHaveBeenCalledWith('challenge-id', 100)) + expect(result.current.isLoading) + .toBe(true) + + await act(async () => { + resolveProjectResults([buildProjectResult()]) + await projectResultsPromise + }) + + await waitFor(() => expect(result.current.isLoading) + .toBe(false)) + expect(result.current.projectResults) + .toHaveLength(1) + expect(result.current.projectResults[0]) + .toMatchObject({ + finalScore: 81, + initialScore: 75, + submissionId: 'canonical-submission', + }) + expect(mockedFetchAllChallengeReviews) + .not + .toHaveBeenCalled() + }) + + it('renders a canonical winner for a registered-only viewer without fetching reviews', async () => { + mockedFetchAllProjectResults.mockResolvedValue([buildProjectResult()]) + const contextValue = buildContextValue([]) + contextValue.myResources = [{ + challengeId: 'challenge-id', + created: '2026-01-01T00:00:00.000Z', + createdBy: 'registered-viewer', + id: 'registered-viewer-resource', + memberHandle: 'registered-viewer', + memberId: '2002', + roleId: 'submitter-role', + roleName: 'Submitter', + }] + contextValue.myRoles = ['Submitter'] + + const { result }: RenderHookResult< + useFetchChallengeResultsProps, + unknown + > = renderHook( + () => useFetchChallengeResults([]), + { wrapper: createWrapper(contextValue) }, + ) + + await waitFor(() => expect(result.current.isLoading) + .toBe(false)) + expect(result.current.projectResults) + .toHaveLength(1) + expect(result.current.projectResults[0].submissionId) + .toBe('canonical-submission') + expect(mockedFetchAllChallengeReviews) + .not + .toHaveBeenCalled() + }) + + it('reports a canonical result request failure and returns no inferred winner', async () => { + const requestError = new Error('project results unavailable') + mockedFetchAllProjectResults.mockRejectedValue(requestError) + const submissions = [buildSubmission({ id: 'possible-sibling' })] + + const { result }: RenderHookResult< + useFetchChallengeResultsProps, + unknown + > = renderHook( + () => useFetchChallengeResults(submissions), + { wrapper: createWrapper(buildContextValue(submissions)) }, + ) + + await waitFor(() => expect(mockedHandleError) + .toHaveBeenCalledWith(requestError)) + await waitFor(() => expect(result.current.isLoading) + .toBe(false)) + expect(result.current.projectResults) + .toEqual([]) + }) +}) diff --git a/src/apps/review/src/lib/hooks/useFetchChallengeResults.spec.ts b/src/apps/review/src/lib/hooks/useFetchChallengeResults.spec.ts index 7e0d38696..e82b86d7b 100644 --- a/src/apps/review/src/lib/hooks/useFetchChallengeResults.spec.ts +++ b/src/apps/review/src/lib/hooks/useFetchChallengeResults.spec.ts @@ -1,7 +1,38 @@ -import type { ChallengeWinner, SubmissionInfo } from '../models' +import type { + BackendResource, + ChallengeWinner, + ProjectResult, + ReviewResult, + SubmissionInfo, +} from '../models' import { getSubmissionFinalScoreCandidate } from '../utils/challengeResultSubmissions' import { submissionMatchesWinner } from '../utils/winnerMatching' +import { buildCanonicalChallengeResults } from './useFetchChallengeResults' + +jest.mock('~/libs/core', () => ({ + getRatingColor: jest.fn() + .mockReturnValue('#000000'), +}), { virtual: true }) + +jest.mock('~/config', () => ({ + EnvironmentConfig: { + API: { + V6: 'https://api.topcoder.test/v6', + }, + }, +}), { virtual: true }) + +jest.mock('~/libs/shared', () => ({ + handleError: jest.fn(), +}), { virtual: true }) + +jest.mock('../services', () => ({ + fetchAllChallengeReviews: jest.fn(), + fetchAllProjectResults: jest.fn(), + fetchAllSubmissions: jest.fn(), +})) + const buildWinner = (overrides: Partial = {}): ChallengeWinner => ({ handle: 'winner-handle', placement: 1, @@ -18,6 +49,57 @@ const buildSubmission = (overrides: Partial = {}): SubmissionInf ...overrides, }) +/** + * Creates a canonical project-result row for pure result-builder tests. + * + * @param overrides fields that differ from the default first-place result. + * @returns A project result with a canonical submission identifier. + */ +const buildProjectResult = (overrides: Partial = {}): ProjectResult => ({ + challengeId: 'challenge-id', + createdAt: '2026-01-05T00:00:00.000Z', + finalScore: 81, + initialScore: 75, + placement: 1, + reviews: [], + submissionId: 'canonical-submission', + userId: '1001', + ...overrides, +}) + +/** + * Creates a display review for exact-submission enrichment tests. + * + * @param overrides fields that differ from the default review. + * @returns A review result associated by the test's submission map. + */ +const buildReview = (overrides: Partial = {}): ReviewResult => ({ + appeals: [], + createdAt: '2026-01-04T00:00:00.000Z', + resourceId: 'reviewer-resource', + reviewerHandle: 'reviewer', + reviewerHandleColor: '#000000', + score: 81, + ...overrides, +}) + +/** + * Creates a challenge resource for winner display enrichment tests. + * + * @param overrides fields that differ from the default winner resource. + * @returns A mapped challenge resource for member 1001. + */ +const buildResource = (overrides: Partial = {}): BackendResource => ({ + challengeId: 'challenge-id', + created: '2026-01-01T00:00:00.000Z', + createdBy: 'tester', + id: 'winner-resource', + memberHandle: 'winner-handle', + memberId: '1001', + roleId: 'submitter-role', + ...overrides, +}) + describe('submissionMatchesWinner', () => { it('matches submissions by member id when the ids agree', () => { expect(submissionMatchesWinner( @@ -79,3 +161,109 @@ describe('getSubmissionFinalScoreCandidate', () => { .toBe(100) }) }) + +describe('buildCanonicalChallengeResults', () => { + it('uses the exact canonical submission and ignores checkpoint and duplicate winner rows', () => { + const exactReview = buildReview() + const siblingReview = buildReview({ + id: 'sibling-review', + score: 99, + }) + const submittedDate = '2026-01-03T00:00:00.000Z' + const resource = buildResource() + + const results = buildCanonicalChallengeResults({ + canonicalResults: [buildProjectResult({ + finalScore: 81, + initialScore: 75, + submissionId: 'canonical-submission', + userId: ' 1001 ', + })], + challengeUuid: 'challenge-id', + memberMapping: { + 1001: resource, + }, + submissions: [ + buildSubmission({ + aggregateScore: 99, + id: 'higher-scoring-sibling', + reviews: [siblingReview], + submittedDate: '2026-01-06T00:00:00.000Z', + }), + buildSubmission({ + aggregateScore: 70, + id: 'canonical-submission', + reviews: [exactReview], + submittedDate, + }), + ], + winners: [ + buildWinner({ type: 'CHECKPOINT' }), + buildWinner({ type: 'PLACEMENT' }), + buildWinner({ type: 'PLACEMENT' }), + ], + }) + + expect(results) + .toHaveLength(1) + expect(results[0]) + .toMatchObject({ + finalScore: 81, + initialScore: 75, + placement: 1, + submissionId: 'canonical-submission', + userId: '1001', + userInfo: resource, + }) + expect(results[0].reviews) + .toHaveLength(1) + expect(results[0].reviews[0].score) + .toBe(81) + expect(results[0].submittedDate) + .toEqual(new Date(submittedDate)) + }) + + it('does not fall back to a sibling when the canonical result is absent or malformed', () => { + const params = { + challengeUuid: 'challenge-id', + memberMapping: {}, + submissions: [buildSubmission({ + aggregateScore: 99, + id: 'higher-scoring-sibling', + })], + winners: [buildWinner({ type: 'PLACEMENT' })], + } + + expect(buildCanonicalChallengeResults({ + ...params, + canonicalResults: [], + })) + .toEqual([]) + expect(buildCanonicalChallengeResults({ + ...params, + canonicalResults: [buildProjectResult({ submissionId: ' ' })], + })) + .toEqual([]) + }) + + it('supports legacy placement winner type aliases', () => { + const canonicalResults = [buildProjectResult()] + const sharedParams = { + canonicalResults, + challengeUuid: 'challenge-id', + memberMapping: {}, + submissions: [], + } + + expect(buildCanonicalChallengeResults({ + ...sharedParams, + winners: [buildWinner({ type: undefined })], + })) + .toHaveLength(1) + expect(buildCanonicalChallengeResults({ + ...sharedParams, + winners: [buildWinner({ type: 'Contest Submission' })], + })) + .toHaveLength(1) + }) +}) diff --git a/src/apps/review/src/lib/hooks/useFetchChallengeResults.ts b/src/apps/review/src/lib/hooks/useFetchChallengeResults.ts index 01b469fc2..e4d23f593 100644 --- a/src/apps/review/src/lib/hooks/useFetchChallengeResults.ts +++ b/src/apps/review/src/lib/hooks/useFetchChallengeResults.ts @@ -12,37 +12,42 @@ import { getRatingColor } from '~/libs/core' import { adjustProjectResult, BackendResource, - BackendReview, ChallengeDetailContextModel, ChallengeWinner, - convertBackendReviewToReviewResult, convertBackendSubmissionToSubmissionInfo, ProjectResult, ReviewResult, SubmissionInfo, } from '../models' -import { fetchAllChallengeReviews, fetchAllSubmissions } from '../services' -import { ChallengeDetailContext } from '../contexts' -import { PAST_CHALLENGE_STATUSES } from '../utils/challengeStatus' import { - isContestSubmissionType, -} from '../constants' + fetchAllProjectResults, + fetchAllSubmissions, +} from '../services' +import { ChallengeDetailContext } from '../contexts/ChallengeDetailContext' +import { PAST_CHALLENGE_STATUSES } from '../utils/challengeStatus' +import { isContestSubmissionType } from '../constants' import { buildChallengeResultSubmissionSource, - getSubmissionFinalScoreCandidate, } from '../utils/challengeResultSubmissions' -import { submissionMatchesWinner } from '../utils/winnerMatching' type ResourceMemberMapping = ChallengeDetailContextModel['resourceMemberIdMapping'] interface BuildProjectResultParams { + canonicalResult: ProjectResult challengeUuid: string memberMapping: ResourceMemberMapping - reviewsBySubmissionId: Map submissions: SubmissionInfo[] winner: ChallengeWinner } +interface BuildCanonicalChallengeResultsParams { + canonicalResults: ProjectResult[] + challengeUuid: string + memberMapping: ResourceMemberMapping + submissions: SubmissionInfo[] + winners: ChallengeWinner[] +} + interface ResolveUserInfoParams { challengeUuid: string memberId: string @@ -54,12 +59,6 @@ const toFiniteNumber = (value?: number | null): number | undefined => ( typeof value === 'number' && Number.isFinite(value) ? value : undefined ) -const toScorePrecision = (value: number): number => { - const normalized = Number(value.toFixed(2)) - - return Number.isNaN(normalized) ? value : normalized -} - const orderReviewsByCreatedDate = (reviews: ReviewResult[]): ReviewResult[] => orderBy( reviews, review => { @@ -118,116 +117,84 @@ const resolveUserInfo = ({ } } -const computeFinalScore = ( - reviews: ReviewResult[], - aggregateScore: number | undefined, -): number => { +/** + * Builds a stable lookup key for one final-placement winner or project-result row. + * + * @param userId member identifier from challenge winners or Review API project results. + * @param placement final placement associated with the member. + * @returns A normalized member-and-placement key, or undefined for incomplete/non-final data. + * @throws Does not throw. + */ +function buildWinnerResultKey( + userId: unknown, + placement: unknown, +): string | undefined { + const normalizedUserId = normalizeIdentifier(userId) + const normalizedPlacement = typeof placement === 'number' + ? placement + : Number(placement) + if ( - typeof aggregateScore === 'number' - && Number.isFinite(aggregateScore) + !normalizedUserId + || !Number.isInteger(normalizedPlacement) + || normalizedPlacement <= 0 ) { - return toScorePrecision(aggregateScore) + return undefined } - if (!reviews.length) { - return 0 - } + return `${normalizedUserId}:${normalizedPlacement}` +} - const totalScore = reviews.reduce( - (total, current) => total + (current.score ?? 0), - 0, - ) - const averageScore = totalScore / reviews.length +/** + * Determines whether a challenge winner represents a final placement. + * + * Canonical winner types must be PLACEMENT. An absent type and contest-submission aliases are + * accepted for legacy challenge records; checkpoint and all other typed winners are rejected. + * + * @param winner challenge winner supplied by the Challenge API. + * @returns Whether the winner may be matched to a canonical final project result. + * @throws Does not throw. + */ +function isFinalPlacementWinner(winner: ChallengeWinner): boolean { + const normalizedType = normalizeIdentifier(winner.type) + ?.toUpperCase() - return toScorePrecision(averageScore) + return !normalizedType + || normalizedType === 'PLACEMENT' + || isContestSubmissionType(winner.type) } +/** + * Enriches one canonical Review API result with display data from its exact local submission. + * + * The canonical result remains authoritative for submission identity, placement, and scores. + * Local data may supply reviews and the submitted date only when its submission id exactly + * matches the canonical id, which prevents a multi-submission winner's sibling submission from + * replacing the downloadable winner. + * + * @param params canonical result, matching challenge winner, submissions, reviews, and members. + * @returns The display-ready project result, or undefined when the canonical identity is invalid. + * @throws Does not throw. + */ const buildProjectResult = ({ + canonicalResult, challengeUuid, memberMapping, - reviewsBySubmissionId, submissions, winner, }: BuildProjectResultParams): ProjectResult | undefined => { - const memberId = `${winner.userId}` - - // Prefer exact member matches, then fall back to legacy winner identifiers. - const exactMemberSubmissions = submissions.filter(s => s.memberId === memberId) - const matchingSubmissions = exactMemberSubmissions.length - ? exactMemberSubmissions - : submissions.filter(submission => submissionMatchesWinner(submission, winner)) - const contestSubmissions = matchingSubmissions.filter( - submission => isContestSubmissionType( - submission.type, - { defaultToContest: true }, - ), - ) - - // Prefer contest submissions; fall back to everything so we still display something if data is inconsistent - const submissionsToEvaluate = contestSubmissions.length - ? contestSubmissions - : matchingSubmissions + const canonicalSubmissionId = normalizeIdentifier(canonicalResult.submissionId) + const memberId = normalizeIdentifier(canonicalResult.userId) - if (!submissionsToEvaluate.length) { + if (!canonicalSubmissionId || !memberId) { return undefined } - // Evaluate each submission's effective final score using available reviews - type EvaluatedSubmission = { - submission: SubmissionInfo - orderedReviews: ReviewResult[] - computedFinalScore: number - computedInitialScore: number - } - - const evaluated: EvaluatedSubmission[] = submissionsToEvaluate.map(submission => { - const fallbackReviews = submission?.reviews ?? [] - const mappedReviews = reviewsBySubmissionId.get(submission.id) ?? fallbackReviews - const orderedReviews = orderReviewsByCreatedDate(mappedReviews) - const finalScoreCandidate = getSubmissionFinalScoreCandidate(submission) - const computedFinalScore = computeFinalScore(orderedReviews, finalScoreCandidate) - const initialScoreCandidate = toFiniteNumber(submission?.review?.initialScore) - const computedInitialScore = initialScoreCandidate ?? computedFinalScore - - return { - computedFinalScore, - computedInitialScore, - orderedReviews, - submission, - } - }) - - // Pick the submission with the highest computed final score - const best = evaluated.reduce((bestSoFar, current) => { - if (!bestSoFar) { - return current - } - - if (current.computedFinalScore > bestSoFar.computedFinalScore) { - return current - } - - // Tie-breaker: prefer the one with the most recent review date - if (current.computedFinalScore === bestSoFar.computedFinalScore) { - const currentDate = current.orderedReviews[0]?.createdAt - ? new Date(current.orderedReviews[0].createdAt) - .getTime() - : 0 - const bestDate = bestSoFar.orderedReviews[0]?.createdAt - ? new Date(bestSoFar.orderedReviews[0].createdAt) - .getTime() - : 0 - if (currentDate > bestDate) { - return current - } - } - - return bestSoFar - }, undefined as EvaluatedSubmission | undefined) - - if (!best) { - return undefined - } + const exactSubmission = submissions.find( + submission => normalizeIdentifier(submission.id) === canonicalSubmissionId, + ) + const fallbackReviews = exactSubmission?.reviews ?? canonicalResult.reviews ?? [] + const orderedReviews = orderReviewsByCreatedDate(fallbackReviews) const userInfo = resolveUserInfo({ challengeUuid, @@ -237,30 +204,105 @@ const buildProjectResult = ({ }) return adjustProjectResult({ - challengeId: challengeUuid, - createdAt: best.submission?.review?.createdAt - ?? best.orderedReviews[0]?.createdAt - ?? new Date(), - finalScore: best.computedFinalScore, - initialScore: best.computedInitialScore, - placement: winner.placement, - reviews: best.orderedReviews, - submissionId: best.submission.id, - submittedDate: best.submission?.submittedDate, + ...canonicalResult, + challengeId: normalizeIdentifier(canonicalResult.challengeId) ?? challengeUuid, + reviews: orderedReviews, + submissionId: canonicalSubmissionId, + submittedDate: exactSubmission?.submittedDate ?? canonicalResult.submittedDate, userId: memberId, userInfo, }) } +/** + * Builds Winners-tab rows from canonical Review API project results. + * + * Winners are matched by normalized member id plus final placement. A winner without a matching + * canonical row, or a row without a usable submission id, is omitted. Explicitly typed winners + * must be final PLACEMENT winners; missing and contest-submission types remain supported for + * legacy challenges. + * + * @param params canonical results and local display-enrichment data for one challenge. + * @returns Display-ready final-placement results ordered by placement. + * @throws Does not throw. + */ +export function buildCanonicalChallengeResults({ + canonicalResults, + challengeUuid, + memberMapping, + submissions, + winners, +}: BuildCanonicalChallengeResultsParams): ProjectResult[] { + const canonicalResultsByWinner = new Map() + const consumedWinnerKeys = new Set() + + canonicalResults.forEach(canonicalResult => { + const key = buildWinnerResultKey( + canonicalResult.userId, + canonicalResult.placement, + ) + + if ( + !key + || !normalizeIdentifier(canonicalResult.submissionId) + || canonicalResultsByWinner.has(key) + ) { + return + } + + canonicalResultsByWinner.set(key, canonicalResult) + }) + + return orderBy(winners, ['placement'], ['asc']) + .reduce((results, winner) => { + if (!isFinalPlacementWinner(winner)) { + return results + } + + const key = buildWinnerResultKey(winner.userId, winner.placement) + const canonicalResult = key + ? canonicalResultsByWinner.get(key) + : undefined + + if (!key || !canonicalResult || consumedWinnerKeys.has(key)) { + return results + } + + consumedWinnerKeys.add(key) + + const projectResult = buildProjectResult({ + canonicalResult, + challengeUuid, + memberMapping, + submissions, + winner, + }) + + if (projectResult) { + results.push(projectResult) + } + + return results + }, []) +} + export interface useFetchChallengeResultsProps { projectResults: ProjectResult[] isLoading: boolean } /** - * Fetch challenge results - * @param submissions list of submission info - * @returns challenge results + * Fetches canonical Winners-tab results and enriches them with local display data. + * + * The Review API project-result endpoint is authoritative for the winning submission id, + * placement, and scores. Challenge submissions contribute display-only data for the exact + * canonical submission. Loading remains active until both request streams settle. Challenge + * reviews are deliberately not fetched because registered members without submissions may + * download winners but are not authorized to inspect challenge review data. + * + * @param submissions submissions already available in the challenge detail view. + * @returns Canonical display-ready project results and their combined loading state. + * @throws Does not throw; request failures are passed to the shared error handler. */ export function useFetchChallengeResults( submissions: SubmissionInfo[], @@ -280,7 +322,7 @@ export function useFetchChallengeResults( return [] }, [challengeInfo]) const challengeUuid = challengeInfo?.id ?? challengeId ?? '' - const shouldFetchReviews = Boolean(challengeUuid && winners.length) + const shouldFetchWinnerData = Boolean(challengeUuid && winners.length) const normalizedStatus = useMemo( () => (challengeInfo?.status ?? '') .trim() @@ -293,6 +335,19 @@ export function useFetchChallengeResults( : false), [normalizedStatus], ) + const { + data: canonicalProjectResults, + error: projectResultsError, + isValidating: isLoadingProjectResults, + }: SWRResponse = useSWR< + ProjectResult[], + Error + >( + shouldFetchWinnerData + ? `reviewBaseUrl/challengeProjectResults/${challengeUuid}` + : undefined, + () => fetchAllProjectResults(challengeUuid, 100), + ) const { data: winnerSubmissions, error: winnerSubmissionsError, @@ -301,7 +356,7 @@ export function useFetchChallengeResults( SubmissionInfo[], Error >( - shouldFetchReviews + shouldFetchWinnerData ? `reviewBaseUrl/challengeWinnerSubmissions/${challengeUuid}` : undefined, async () => { @@ -329,26 +384,11 @@ export function useFetchChallengeResults( winnerSubmissions, ]) - // Use swr hooks for challenge reviews fetching when winners are available - const { - data: challengeReviews, - error, - isValidating: isLoadingReviews, - }: SWRResponse = useSWR< - BackendReview[], - Error - >( - shouldFetchReviews - ? `reviewBaseUrl/challengeReviews/${challengeUuid}` - : undefined, - () => fetchAllChallengeReviews(challengeUuid, 100), - ) - // Show backend error when fetching data fail useEffect(() => { - if (error) { - handleError(error) + if (projectResultsError) { + handleError(projectResultsError) } - }, [error]) + }, [projectResultsError]) useEffect(() => { if (winnerSubmissionsError) { @@ -356,85 +396,30 @@ export function useFetchChallengeResults( } }, [winnerSubmissionsError]) - const reviewsBySubmissionId = useMemo(() => { - const result = new Map() - const reviewList = challengeReviews ?? [] - const submissionIdAliases = new Map() - - submissionSource.forEach(submission => { - const canonicalId = normalizeIdentifier(submission.id) - if (!canonicalId) { - return - } - - submissionIdAliases.set(canonicalId, canonicalId) - - const legacySubmissionId = normalizeIdentifier(submission.legacySubmissionId) - if (legacySubmissionId) { - submissionIdAliases.set(legacySubmissionId, canonicalId) - } - }) - - reviewList.forEach(review => { - const canonicalSubmissionId = [ - normalizeIdentifier(review.submissionId), - normalizeIdentifier(review.legacySubmissionId), - ] - .map(identifier => ( - identifier - ? (submissionIdAliases.get(identifier) ?? identifier) - : undefined - )) - .find((identifier): identifier is string => Boolean(identifier)) - - if (!canonicalSubmissionId) { - return - } - - const transformedReview = convertBackendReviewToReviewResult(review) - const existing = result.get(canonicalSubmissionId) ?? [] - result.set(canonicalSubmissionId, [...existing, transformedReview]) - }) - - return result - }, [challengeReviews, submissionSource]) - - const sortedWinners = useMemo( - () => orderBy(winners, ['placement'], ['asc']), - [winners], + const projectResults = useMemo( + () => buildCanonicalChallengeResults({ + canonicalResults: canonicalProjectResults ?? [], + challengeUuid, + memberMapping: resourceMemberIdMapping, + submissions: submissionSource, + winners, + }), + [ + canonicalProjectResults, + challengeUuid, + resourceMemberIdMapping, + submissionSource, + winners, + ], ) - const projectResults = useMemo(() => { - if (!sortedWinners.length) { - return [] - } - - return sortedWinners.reduce((accumulator, winner) => { - const projectResult = buildProjectResult({ - challengeUuid, - memberMapping: resourceMemberIdMapping, - reviewsBySubmissionId, - submissions: submissionSource, - winner, - }) - - if (projectResult) { - accumulator.push(projectResult) - return accumulator - } - - return accumulator - }, []) - }, [ - challengeUuid, - resourceMemberIdMapping, - reviewsBySubmissionId, - sortedWinners, - submissionSource, - ]) - return { - isLoading: shouldFetchReviews ? (isLoadingReviews || isLoadingWinnerSubmissions) : false, + isLoading: shouldFetchWinnerData + ? ( + isLoadingProjectResults + || isLoadingWinnerSubmissions + ) + : false, projectResults, } } diff --git a/src/apps/review/src/lib/hooks/useFetchScreeningReview.ts b/src/apps/review/src/lib/hooks/useFetchScreeningReview.ts index 0589d5b1e..080c00a0c 100644 --- a/src/apps/review/src/lib/hooks/useFetchScreeningReview.ts +++ b/src/apps/review/src/lib/hooks/useFetchScreeningReview.ts @@ -275,6 +275,7 @@ export function useFetchScreeningReview(): useFetchScreeningReviewProps { const { challengeId, challengeInfo, + isLoadingChallengeResources, resourceMemberIdMapping, reviewers: challengeReviewers, resources, @@ -520,13 +521,33 @@ export function useFetchScreeningReview(): useFetchScreeningReviewProps { [reviewerIds], ) + const isTaskChallenge = useMemo( + () => (challengeInfo?.type?.name ?? '') + .trim() + .toLowerCase() === 'task', + [challengeInfo?.type?.name], + ) + const shouldForceReviewFetch = useMemo( - () => shouldForceChallengeReviewFetch( + () => ( + isLoadingChallengeResources + ? false + : shouldForceChallengeReviewFetch( + actionChallengeRole, + challengeInfo?.status, + myResources, + visibleChallengeSubmissions.length > 0, + isTaskChallenge, + ) + ), + [ actionChallengeRole, challengeInfo?.status, + isTaskChallenge, + isLoadingChallengeResources, myResources, - ), - [actionChallengeRole, challengeInfo?.status, myResources], + visibleChallengeSubmissions.length, + ], ) const { diff --git a/src/apps/review/src/lib/models/BackendChallengeInfo.model.spec.ts b/src/apps/review/src/lib/models/BackendChallengeInfo.model.spec.ts index 5b25dd097..7328b71bf 100644 --- a/src/apps/review/src/lib/models/BackendChallengeInfo.model.spec.ts +++ b/src/apps/review/src/lib/models/BackendChallengeInfo.model.spec.ts @@ -58,6 +58,28 @@ const buildChallengeInfo = ( }) describe('convertBackendChallengeInfo winners mapping', () => { + it('keeps canonical placement winners', () => { + const result = convertBackendChallengeInfo(buildChallengeInfo([ + { + handle: 'winnerHandle', + placement: 1, + type: 'PLACEMENT', + userId: 1234, + }, + ])) + + expect(result?.winners) + .toEqual([ + { + handle: 'winnerHandle', + maxRating: undefined, + placement: 1, + type: 'PLACEMENT', + userId: 1234, + }, + ]) + }) + it('keeps contest winners when legacy winner type uses spaces', () => { const result = convertBackendChallengeInfo(buildChallengeInfo([ { @@ -80,8 +102,35 @@ describe('convertBackendChallengeInfo winners mapping', () => { ]) }) - it('filters out checkpoint winners', () => { + it('keeps untyped legacy placement winners', () => { const result = convertBackendChallengeInfo(buildChallengeInfo([ + { + handle: 'legacyWinner', + placement: 2, + userId: 5678, + }, + ])) + + expect(result?.winners) + .toEqual([ + { + handle: 'legacyWinner', + maxRating: undefined, + placement: 2, + type: undefined, + userId: 5678, + }, + ]) + }) + + it('filters out canonical and legacy checkpoint winners', () => { + const result = convertBackendChallengeInfo(buildChallengeInfo([ + { + handle: 'canonicalCheckpointHandle', + placement: 1, + type: 'CHECKPOINT', + userId: 8888, + }, { handle: 'checkpointHandle', placement: 1, diff --git a/src/apps/review/src/lib/models/BackendChallengeInfo.model.ts b/src/apps/review/src/lib/models/BackendChallengeInfo.model.ts index fab25041b..b342db623 100644 --- a/src/apps/review/src/lib/models/BackendChallengeInfo.model.ts +++ b/src/apps/review/src/lib/models/BackendChallengeInfo.model.ts @@ -113,11 +113,15 @@ function mapWinners( return undefined } - // Only expose contest submissions in the winners list - const contestWinners = winners.filter(winner => isContestSubmissionType( - winner.type, - { defaultToContest: true }, - )) + // Only expose final-placement winners. Older records used contest-submission aliases or no type. + const contestWinners = winners.filter(winner => { + const normalizedType = winner.type?.trim() + .toUpperCase() + + return !normalizedType + || normalizedType === 'PLACEMENT' + || isContestSubmissionType(winner.type) + }) return contestWinners.map(winner => ({ handle: winner.handle, diff --git a/src/apps/review/src/lib/models/BackendProjectResult.model.ts b/src/apps/review/src/lib/models/BackendProjectResult.model.ts index f34d569c2..03bf65925 100644 --- a/src/apps/review/src/lib/models/BackendProjectResult.model.ts +++ b/src/apps/review/src/lib/models/BackendProjectResult.model.ts @@ -5,6 +5,9 @@ export interface BackendProjectResult { challengeId: string userId: string paymentId: null | string + /** + * Canonical final-placement submission UUID selected by the Review API. + */ submissionId: string oldRating?: number newRating: number diff --git a/src/apps/review/src/lib/models/ProjectResult.model.spec.ts b/src/apps/review/src/lib/models/ProjectResult.model.spec.ts new file mode 100644 index 000000000..48d806a0f --- /dev/null +++ b/src/apps/review/src/lib/models/ProjectResult.model.spec.ts @@ -0,0 +1,42 @@ +import type { BackendProjectResult } from './BackendProjectResult.model' +import { convertBackendProjectResultToProjectResult } from './ProjectResult.model' + +jest.mock('~/libs/core', () => ({ + getRatingColor: jest.fn() + .mockReturnValue('#000000'), +}), { virtual: true }) + +describe('convertBackendProjectResultToProjectResult', () => { + it('preserves the canonical submission id and final-placement values', () => { + const backendResult: BackendProjectResult = { + challengeId: 'challenge-id', + createdAt: '2026-01-05T00:00:00.000Z', + createdBy: 'review-api', + finalScore: 81, + initialScore: 75, + newRating: 1500, + passedReview: true, + paymentId: null, // eslint-disable-line unicorn/no-null + placement: 1, + pointAdjustment: null, // eslint-disable-line unicorn/no-null + rated: false, + ratingOrder: 1, + submissionId: 'canonical-winning-submission', + updatedAt: '2026-01-05T00:00:00.000Z', + updatedBy: 'review-api', + userId: '1001', + validSubmission: true, + } + + expect(convertBackendProjectResultToProjectResult(backendResult)) + .toMatchObject({ + challengeId: 'challenge-id', + finalScore: 81, + initialScore: 75, + placement: 1, + reviews: [], + submissionId: 'canonical-winning-submission', + userId: '1001', + }) + }) +}) diff --git a/src/apps/review/src/lib/models/ProjectResult.model.ts b/src/apps/review/src/lib/models/ProjectResult.model.ts index 0761867dd..73a705c40 100644 --- a/src/apps/review/src/lib/models/ProjectResult.model.ts +++ b/src/apps/review/src/lib/models/ProjectResult.model.ts @@ -11,6 +11,9 @@ import { BackendResource } from './BackendResource.model' */ export interface ProjectResult { challengeId: string + /** + * Canonical winning submission UUID supplied by the Review API project-result row. + */ submissionId: string createdAt: string | Date createdAtString?: string // this field is calculated at frontend diff --git a/src/apps/review/src/lib/services/reviews.service.spec.ts b/src/apps/review/src/lib/services/reviews.service.spec.ts new file mode 100644 index 000000000..11c89309c --- /dev/null +++ b/src/apps/review/src/lib/services/reviews.service.spec.ts @@ -0,0 +1,112 @@ +import { xhrGetAsync } from '~/libs/core' + +import type { BackendProjectResult } from '../models' + +import { fetchAllProjectResults } from './reviews.service' + +jest.mock('~/config', () => ({ + EnvironmentConfig: { + API: { + V6: 'https://api.topcoder.test/v6', + }, + }, +}), { virtual: true }) + +jest.mock('~/libs/core', () => ({ + xhrDeleteAsync: jest.fn(), + xhrGetAsync: jest.fn(), + xhrGetBlobAsync: jest.fn(), + xhrPatchAsync: jest.fn(), + xhrPostAsync: jest.fn(), +}), { virtual: true }) + +const mockedXhrGetAsync = xhrGetAsync as jest.MockedFunction + +/** + * Creates a backend project-result row for pagination tests. + * + * @param overrides fields that differ from the default first-place result. + * @returns A complete Review API project-result payload. + */ +const buildBackendProjectResult = ( + overrides: Partial = {}, +): BackendProjectResult => ({ + challengeId: 'challenge-id', + createdAt: '2026-01-05T00:00:00.000Z', + createdBy: 'review-api', + finalScore: 81, + initialScore: 75, + newRating: 1500, + passedReview: true, + paymentId: null, // eslint-disable-line unicorn/no-null + placement: 1, + pointAdjustment: null, // eslint-disable-line unicorn/no-null + rated: false, + ratingOrder: 1, + submissionId: 'canonical-submission-1', + updatedAt: '2026-01-05T00:00:00.000Z', + updatedBy: 'review-api', + userId: '1001', + validSubmission: true, + ...overrides, +}) + +describe('fetchAllProjectResults', () => { + beforeEach(() => { + mockedXhrGetAsync.mockReset() + }) + + it('fetches every page and globally orders canonical results by placement', async () => { + mockedXhrGetAsync + .mockResolvedValueOnce({ + data: [buildBackendProjectResult({ + placement: 2, + submissionId: 'canonical-submission-2', + userId: '1002', + })], + meta: { + page: 1, + perPage: 1, + totalCount: 2, + totalPages: 2, + }, + } as never) + .mockResolvedValueOnce({ + data: [buildBackendProjectResult()], + meta: { + page: 2, + perPage: 1, + totalCount: 2, + totalPages: 2, + }, + } as never) + + const results = await fetchAllProjectResults('challenge-id', 1) + + expect(mockedXhrGetAsync) + .toHaveBeenNthCalledWith( + 1, + 'https://api.topcoder.test/v6/projectResult?challengeId=challenge-id&page=1&perPage=1', + ) + expect(mockedXhrGetAsync) + .toHaveBeenNthCalledWith( + 2, + 'https://api.topcoder.test/v6/projectResult?challengeId=challenge-id&page=2&perPage=1', + ) + expect(results.map(result => result.submissionId)) + .toEqual([ + 'canonical-submission-1', + 'canonical-submission-2', + ]) + expect(results.map(result => result.placement)) + .toEqual([1, 2]) + }) + + it('does not request project results without a challenge id', async () => { + await expect(fetchAllProjectResults('', 1)) + .resolves + .toEqual([]) + expect(mockedXhrGetAsync) + .not.toHaveBeenCalled() + }) +}) diff --git a/src/apps/review/src/lib/services/reviews.service.ts b/src/apps/review/src/lib/services/reviews.service.ts index 9ee2995b0..31a71289e 100644 --- a/src/apps/review/src/lib/services/reviews.service.ts +++ b/src/apps/review/src/lib/services/reviews.service.ts @@ -298,7 +298,8 @@ export const downloadSubmissionFile = async ( * @param page current page * @param perPage number of item per page * @param challengeId challenge id - * @returns resolves to the array of project results + * @returns resolves to converted project results and the API page count + * @throws rejects when the Review API request fails */ export const fetchProjectResults = async ( page: number, @@ -311,7 +312,7 @@ export const fetchProjectResults = async ( const results = await xhrGetAsync< BackendResponseWithMeta >( - `${EnvironmentConfig.API.V6}/review/projectResult?${qs.stringify({ + `${EnvironmentConfig.API.V6}/projectResult?${qs.stringify({ challengeId, page, perPage, @@ -339,6 +340,52 @@ export const fetchProjectResults = async ( } } +/** + * Fetch every canonical project-result page for a challenge. + * + * Results are globally ordered by final placement after pagination. Rows retain the canonical + * submission id supplied by the Review API for use by Winners-tab downloads. + * + * @param challengeId challenge UUID used to filter project results. + * @param perPage requested API page size; invalid values fall back to 100. + * @returns all converted project results ordered by placement. + * @throws rejects when any Review API page request fails. + */ +export const fetchAllProjectResults = async ( + challengeId: string, + perPage = 100, +): Promise => { + if (!challengeId) { + return [] + } + + const safePerPage = Number.isFinite(perPage) && perPage > 0 ? perPage : 100 + const firstPage = await fetchProjectResults(1, safePerPage, challengeId) + const combined = [...firstPage.data] + const totalPages = Math.max(firstPage.totalPages ?? 1, 1) + + const fetchRemainingPages = async ( + page: number, + currentTotal: number, + ): Promise => { + if (page > currentTotal) { + return + } + + const nextPage = await fetchProjectResults(page, safePerPage, challengeId) + combined.push(...nextPage.data) + const nextTotal = Number.isFinite(nextPage.totalPages) + ? Math.max(nextPage.totalPages, currentTotal) + : currentTotal + + await fetchRemainingPages(page + 1, nextTotal) + } + + await fetchRemainingPages(2, totalPages) + + return orderBy(combined, ['placement'], ['asc']) +} + /** * Fetch reviews * diff --git a/src/apps/review/src/lib/utils/index.ts b/src/apps/review/src/lib/utils/index.ts index 7cde335d8..d8bb19957 100644 --- a/src/apps/review/src/lib/utils/index.ts +++ b/src/apps/review/src/lib/utils/index.ts @@ -21,3 +21,4 @@ export * from './phaseResolution' export * from './metadataMatching' export * from './reviewMatching' export * from './reviewBuilding' +export * from './submissionOwnership' diff --git a/src/apps/review/src/lib/utils/reviewFetchPolicy.spec.ts b/src/apps/review/src/lib/utils/reviewFetchPolicy.spec.ts index a52cb64b0..d81a64c09 100644 --- a/src/apps/review/src/lib/utils/reviewFetchPolicy.spec.ts +++ b/src/apps/review/src/lib/utils/reviewFetchPolicy.spec.ts @@ -10,9 +10,22 @@ const createResource = (roleName: string): BackendResource => ({ } as BackendResource) describe('shouldForceChallengeReviewFetch', () => { - it('forces review fetching for past challenges even when the viewer is only an observer', () => { + it('does not use past challenge status alone to fetch protected reviews', () => { expect(shouldForceChallengeReviewFetch(undefined, 'COMPLETED')) - .toBe(true) + .toBe(false) + }) + + it('fails closed while challenge resource role names are unresolved', () => { + expect(shouldForceChallengeReviewFetch( + undefined, + 'COMPLETED', + [{ + ...createResource('Submitter'), + roleName: undefined, + }], + true, + )) + .toBe(false) }) it('does not force review fetching for active observer views without privileged resources', () => { @@ -20,8 +33,63 @@ describe('shouldForceChallengeReviewFetch', () => { .toBe(false) }) + it('waits for challenge context before fetching for a privileged role', () => { + expect(shouldForceChallengeReviewFetch( + 'Reviewer', + undefined, + [createResource('Reviewer')], + )) + .toBe(false) + }) + it('keeps forcing review fetching for submitter views', () => { - expect(shouldForceChallengeReviewFetch('Submitter', 'ACTIVE', [createResource('Submitter')])) + expect(shouldForceChallengeReviewFetch( + 'Submitter', + 'ACTIVE', + [createResource('Submitter')], + true, + )) + .toBe(true) + }) + + it('does not fetch reviews for a completed registered-only submitter', () => { + expect(shouldForceChallengeReviewFetch( + 'Submitter', + 'COMPLETED', + [createResource('Submitter')], + false, + )) + .toBe(false) + }) + + it('retains completed review access for a submitter with a visible submission', () => { + expect(shouldForceChallengeReviewFetch( + 'Submitter', + 'COMPLETED', + [createResource('Submitter')], + true, + )) + .toBe(true) + }) + + it('retains review access for an assigned Task submitter without a submission', () => { + expect(shouldForceChallengeReviewFetch( + 'Submitter', + 'COMPLETED', + [createResource('Submitter')], + false, + true, + )) + .toBe(true) + }) + + it('retains past review access for privileged viewers without visible submissions', () => { + expect(shouldForceChallengeReviewFetch( + 'Copilot', + 'COMPLETED', + [createResource('Copilot')], + false, + )) .toBe(true) }) }) diff --git a/src/apps/review/src/lib/utils/reviewFetchPolicy.ts b/src/apps/review/src/lib/utils/reviewFetchPolicy.ts index 492418eed..041422f31 100644 --- a/src/apps/review/src/lib/utils/reviewFetchPolicy.ts +++ b/src/apps/review/src/lib/utils/reviewFetchPolicy.ts @@ -1,7 +1,5 @@ import type { BackendResource } from '../models' -import { PAST_CHALLENGE_STATUSES } from './challengeStatus' - const ADMIN_ROLE = 'Admin' const COPILOT_ROLE = 'Copilot' const MANAGER_ROLE = 'Manager' @@ -10,43 +8,55 @@ const SUBMITTER_ROLE = 'Submitter' /** * Determines whether challenge reviews should be fetched regardless of reviewer assignments. - * Past challenges need the full review list even for observer-style views because legacy - * submissions often do not embed their review rows locally. + * An explicit review-capable challenge role is required; past challenge status alone never + * authorizes an observer or unresolved role context to request protected review data. Ordinary + * Submitters additionally need a visible owned submission, except for the established Task + * challenge assignment flow. * * @param actionChallengeRole - Current challenge action role. - * @param challengeStatus - Challenge status from challenge info. + * @param challengeStatus - Challenge status from challenge info. A missing status delays the + * request until challenge context has resolved. * @param myResources - Current member resources for the challenge. + * @param hasVisibleSubmissions - Whether the current viewer has at least one locally visible + * submission. Ordinary Submitters without one cannot access review data and do not need it for + * winner downloads. + * @param allowSubmitterWithoutVisibleSubmissions - Whether the challenge explicitly permits an + * assigned Submitter to access reviews without a submission, as Task challenges do. * @returns True when the UI should force the full challenge-review fetch. + * @throws Does not throw. */ export function shouldForceChallengeReviewFetch( actionChallengeRole: string | undefined, challengeStatus: string | undefined, myResources?: BackendResource[], + hasVisibleSubmissions: boolean = true, + allowSubmitterWithoutVisibleSubmissions: boolean = false, ): boolean { - const normalizedStatus = (challengeStatus ?? '') - .trim() - .toUpperCase() + const normalizedActionRole = actionChallengeRole ?? '' if ( - normalizedStatus - && PAST_CHALLENGE_STATUSES.some(status => normalizedStatus.startsWith(status)) + normalizedActionRole === SUBMITTER_ROLE + && !hasVisibleSubmissions + && !allowSubmitterWithoutVisibleSubmissions ) { - return true + return false } - const normalizedActionRole = actionChallengeRole ?? '' + const normalizedStatus = (challengeStatus ?? '') + .trim() + .toUpperCase() + if (!normalizedStatus) { + return false + } - if ( + const hasEligibleActionRole = ( normalizedActionRole === SUBMITTER_ROLE || normalizedActionRole === REVIEWER_ROLE || normalizedActionRole === COPILOT_ROLE || normalizedActionRole === ADMIN_ROLE || normalizedActionRole === MANAGER_ROLE - ) { - return true - } - - return (myResources ?? []).some(resource => { + ) + const hasEligibleResourceRole = (myResources ?? []).some(resource => { const normalizedRoleName = (resource.roleName ?? '').toLowerCase() if (!normalizedRoleName) { @@ -59,4 +69,6 @@ export function shouldForceChallengeReviewFetch( || normalizedRoleName.includes('admin') || normalizedRoleName.includes('manager') }) + + return hasEligibleActionRole || hasEligibleResourceRole } diff --git a/src/apps/review/src/lib/utils/submissionOwnership.spec.ts b/src/apps/review/src/lib/utils/submissionOwnership.spec.ts new file mode 100644 index 000000000..a538bedab --- /dev/null +++ b/src/apps/review/src/lib/utils/submissionOwnership.spec.ts @@ -0,0 +1,97 @@ +import type { SubmissionInfo } from '../models' + +import { filterSubmissionRowsByOwnership } from './submissionOwnership' + +/** + * Builds a minimal submission row for ownership-filter regression tests. + * + * @param id - Submission identifier. + * @param memberId - Challenge member that owns the submission. + * @param overrides - Optional submission fields needed by a test case. + * @returns A submission row containing the requested values. + * @throws This test helper does not throw. + */ +const buildSubmission = ( + id: string, + memberId: string, + overrides: Partial = {}, +): SubmissionInfo => ({ + id, + memberId, + ...overrides, +}) + +describe('filterSubmissionRowsByOwnership', () => { + it('keeps all current-member submissions and excludes other winners and non-winners', () => { + const ownPassing = buildSubmission('own-passing', 'member-current', { + isPassingReview: true, + }) + const ownFailed = buildSubmission('own-failed', 'member-current', { + isPassingReview: false, + }) + const otherWinner = buildSubmission('other-winner', 'member-winner', { + placement: 1, + }) + const otherNonWinner = buildSubmission('other-non-winner', 'member-other', { + isPassingReview: false, + }) + + expect(filterSubmissionRowsByOwnership( + [ + ownPassing, + otherWinner, + ownFailed, + otherNonWinner, + ], + new Set(['member-current']), + )) + .toEqual([ownPassing, ownFailed]) + }) + + it('uses known submission IDs when a legacy review row omits its member ID', () => { + const directIdMatch = buildSubmission('owned-submission', '') + const legacyIdMatch = buildSubmission('review-row', '', { + legacySubmissionId: 'owned-legacy-submission', + }) + const reviewIdMatch = buildSubmission('another-review-row', '', { + review: { + submissionId: 'owned-review-submission', + }, + } as Partial) + const unknownSubmission = buildSubmission('unknown-submission', '') + + expect(filterSubmissionRowsByOwnership( + [ + directIdMatch, + legacyIdMatch, + reviewIdMatch, + unknownSubmission, + ], + new Set(['member-current']), + new Set([ + 'owned-submission', + 'owned-legacy-submission', + 'owned-review-submission', + ]), + )) + .toEqual([ + directIdMatch, + legacyIdMatch, + reviewIdMatch, + ]) + }) + + it('treats an explicit foreign member ID as authoritative', () => { + const foreignRowWithOwnedId = buildSubmission( + 'owned-submission', + 'member-other', + ) + + expect(filterSubmissionRowsByOwnership( + [foreignRowWithOwnedId], + new Set(['member-current']), + new Set(['owned-submission']), + )) + .toEqual([]) + }) +}) diff --git a/src/apps/review/src/lib/utils/submissionOwnership.ts b/src/apps/review/src/lib/utils/submissionOwnership.ts new file mode 100644 index 000000000..c64a43df0 --- /dev/null +++ b/src/apps/review/src/lib/utils/submissionOwnership.ts @@ -0,0 +1,50 @@ +import type { SubmissionInfo } from '../models' + +/** + * Filters submission rows to those owned by the current challenge member. + * + * A populated member ID is authoritative, while submission IDs provide a + * fallback for legacy review rows that omit member ownership. + * + * @param submissions - Submission or review rows to scope for a submitter. + * @param ownedMemberIds - Challenge member IDs associated with the current user. + * @param ownedSubmissionIds - Submission IDs known to belong to the current user. + * @returns Rows owned by the current user, preserving their original order and type. + * @throws This helper does not throw. + * Used by submitter Appeals views before rendering review and download actions. + */ +export function filterSubmissionRowsByOwnership( + submissions: readonly T[], + ownedMemberIds: ReadonlySet, + ownedSubmissionIds: ReadonlySet = new Set(), +): T[] { + const normalizedMemberIds = new Set( + Array.from(ownedMemberIds) + .map(memberId => `${memberId}`.trim()) + .filter(Boolean), + ) + const normalizedSubmissionIds = new Set( + Array.from(ownedSubmissionIds) + .map(submissionId => `${submissionId}`.trim()) + .filter(Boolean), + ) + + return submissions.filter(submission => { + const memberId = `${submission.memberId ?? ''}`.trim() + if (memberId.length) { + return normalizedMemberIds.has(memberId) + } + + const submissionIds = [ + submission.id, + submission.legacySubmissionId, + submission.review?.submissionId, + ] + + return submissionIds.some(submissionId => { + const normalizedSubmissionId = `${submissionId ?? ''}`.trim() + return normalizedSubmissionId.length > 0 + && normalizedSubmissionIds.has(normalizedSubmissionId) + }) + }) +} diff --git a/src/apps/status/README.md b/src/apps/status/README.md new file mode 100644 index 000000000..af586f08c --- /dev/null +++ b/src/apps/status/README.md @@ -0,0 +1,514 @@ +# Status app + +## Document status + +The administrator-only Status app is registered in Platform UI and implements +the ECS, API, SendGrid, and Database views described below. It uses only +read-only `GET /v6/status/*` calls, lazy route/section fetching, explicit +refresh, and last-good-data retention. The cross-project design and API +contract are in +[`../../../../status-api-v6/IMPLEMENTATION_PLAN.md`](../../../../status-api-v6/IMPLEMENTATION_PLAN.md). + +The Status UI ships as part of the normal Platform UI bundle. Deploying the +bundle and provisioning an optional `status.` alias are environment +operations outside this directory. + +## Local development + +The local environment routes `/v6/status` to the Status API on port 3000. Start +`status-api-v6` first, then run the normal Platform UI development server: + +```bash +nvm use +yarn start +``` + +## Outcome + +Add a standalone, administrator-only Status application to Platform UI with +four tabs: + +- ECS +- API +- SendGrid +- Database + +The app follows Review's visual language and responsive behavior. It must use +shared `libs` components or status-local components; it must not import private +components from Review or Admin. + +## Authorization + +The root Platform route must contain both: + +```ts +authRequired: true, +rolesRequired: [UserRole.administrator], +``` + +Do not use `adminReportsAccessRoles`. The system-admin root grants additional +Product Manager and Talent Manager roles, which would violate the requirement +that Status is administrator-only. + +The shared restricted-route role comparison is case-sensitive. Use the existing +`UserRole.administrator` enum value rather than a new string. + +The UI guard is defense in depth and navigation control. Every API call still +requires the Status API to verify the administrator JWT independently. + +## Route tree + +Use a dedicated `AppSubdomain.status = 'status'` and the same conditional-root +pattern as Review/Reports. This gives `/status/...` on the combined Platform UI +host and `/...` if a `status.` alias is provisioned later. + +```text +/status + -> /status/ecs + /ecs + /api + /api/:serviceId + /api/:serviceId/endpoints/:endpointId + /sendgrid + /database +``` + +Drill-downs are routes rather than modal-only state so browser back/forward, +refresh, bookmarks, and deep links work. Server-issued IDs are opaque; the UI +does not construct arbitrary resource ARNs or log groups. + +## Existing patterns to follow + +### Structure and authentication + +- `src/apps/reports/src/reports-app.routes.tsx` for a standalone, + subdomain-aware authenticated app. +- `src/apps/review/src/ReviewApp.tsx` for app context/layout/outlet composition. +- `src/apps/review/src/config/routes.config.ts` for conditional root routes. +- `src/apps/review/src/review-app.routes.tsx` for lazy-loaded route entries. + +### Layout and visual language + +- `src/apps/review/src/lib/components/Layout` for constrained content and + responsive spacing. +- `src/apps/review/src/lib/components/NavTabs` and its separate tab config for + desktop/mobile navigation driven by `useLocation`. +- `src/apps/review/src/lib/components/PageWrapper` for titles, breadcrumbs, + back navigation, actions, and safe external links. +- `src/apps/review/src/lib/styles/index.scss` for neutral background, teal + active state, Nunito Sans table typography, and Review table treatments. +- `src/libs/ui/lib/components/table/Table.tsx` for declarative columns, sorting, + expandable rows, clicks, and `rowClassName`. +- Review's active-review table styling as the visual reference for critical red + rows and health/status pills. + +Review currently imports some private Admin components. Status must not extend +that coupling. If Pagination, TableMobile, or TableLoading is genuinely reusable, +move it to `src/libs/ui` in a separately reviewable change with tests and +documentation; otherwise create a status-local wrapper. + +## App shell and navigation + +`StatusApp` should: + +1. Register child routes from the platform router. +2. Add/remove a status-specific body class. +3. Provide only app-wide presentation/query defaults, not eager provider data. +4. Render the Review-style `Layout`, `NavTabs`, and child route outlet. + +The tab config contains four always-visible entries for an already authorized +administrator. Do not run a second broad role calculation that can drift from +the root route. + +On narrow screens, use Review's dropdown/menu treatment. The selected tab is +derived from the URL and updates when navigation changes. + +## Shared Status components + +Implement small status-local components over `libs/ui`: + +| Component | Purpose | +| --- | --- | +| `HealthBadge` | Critical/warning/healthy/unknown icon, label, and accessible text | +| `DataFreshness` | Source names, “as of” timestamp, incomplete warnings | +| `MetricCard` | Label, value, unit, trend/context, loading/unknown states | +| `StatusTable` | Shared desktop/mobile behavior and failure row class | +| `TimeWindowSelect` | Server-supported windows only | +| `RetryableErrorState` | Safe error plus Retry action | +| `IncompleteDataNotice` | Visible warning when API metadata is incomplete | +| `ExternalAwsLink` | Validated URL, new tab, `noopener noreferrer` | + +Red must not be the only failure signal. Critical state also uses icon, badge +text, row accessible label, and a visible reason. + +## ECS page + +### Default view + +Display a filterable service/task table. The default rows are failure-first +service parents; expanding a service or switching to the task view enumerates +its actual running, pending, and retained recent stopped tasks. + +Service parents show: + +- Severity. +- Cluster and service. +- Desired/running/pending task counts. +- Latest deployment status/time. +- Deployment counts for the last 24 hours and seven days. +- Current task-definition family/revision linked to AWS. +- Latest recent failure summary and time. +- Task/log actions. + +Every task child row shows its opaque task ID, actual status/health, launch or +stop time, deployment association, container state, and the task-definition +family/revision taken from that task's own ARN. This matters during rolling +deployments, when one service can temporarily run multiple revisions. Recent +stopped tasks are paginated; the UI does not collapse the inventory to only the +latest failure. + +Filters: + +- Text across cluster/service/task-definition. +- Cluster. +- Service and task status. +- Task-definition family/revision. +- Severity/state. +- Optional “issues only”. + +Fetch the server's already failure-first list once and filter ordinary text +locally. If a server filter is needed for a large catalog, debounce or require +Apply; do not request on every keystroke. + +The dedicated “Expanded task definition” filter applies to the lazy inventory +request and never removes service parents. This preserves access to retained +tasks running an older revision when the service's current task definition has +already advanced. When stopped-task history is warming or unavailable, +`recentStoppedCount` is rendered as unknown and the latest-failure cell states +that history is incomplete rather than claiming that no failures occurred. + +### Sorting and highlighting + +The API owns canonical severity. The UI uses it as the primary sort and cannot +place a healthy row before a critical row because a user selected another +column. Secondary sorting may operate within a severity group. + +Critical failure/error-redeployment rows use Review's red-row treatment. An +evidenced error-driven redeployment inside the configured recent window stays +red even if capacity has recovered. Warnings use a distinct warning treatment, +including unexplained repeated replacements. Normal recent deployments are +visible but not red unless the API supplies error/rollback/failure evidence. + +### Failure detail + +An expandable row or routed drawer/page shows: + +- ECS stop code and sanitized reason. +- Container and exit code. +- Generic exit interpretation explicitly labelled “generic”. +- Stopped time and source completeness. +- Task-definition and CloudWatch links. + +Unknown exit codes say no generic interpretation is available; the UI never +invents a cause. + +## API pages + +### API overview + +Show time-window controls and summary cards for: + +- Total requests. +- 2xx success ratio. +- 4xx ratio. +- 5xx ratio. +- p50 and p95 response latency. +- Unhealthy ALB targets. + +Keep 3xx visible in the service table even if it is not a headline card. + +The service table contains request counts, 2xx/3xx/4xx/5xx ratios, p50/p95/p99 +latency, target health, and source completeness. Selecting a service navigates +to its endpoint page. + +The UI honors the API's `dataComplete` flag independently on the global, +service, and endpoint aggregates. Incomplete traffic counts, ratios, and +latencies render as unknown instead of displaying parser fallback zeroes. ALB +target health remains independently visible when its source is complete. + +### Endpoint page + +Group by stable method + route template. Do not display raw identifier-bearing +paths as endpoint identities. Columns: + +- Method and route template. +- Requests. +- 2xx/4xx/5xx ratios. +- p50/p95/p99 application/Gateway latency with the source labelled. +- Recent failure count. + +Selecting an endpoint navigates to its failure page or expands a routed detail. + +Show attributed-request coverage and any `EDGE_UNATTRIBUTED` failure count above +the table. Gateway/ALB failures that never reached application routing are +assigned only when the API's safe edge route catalog supplies a template; the +UI never derives an endpoint from a raw path. Any unattributed edge failure +makes endpoint ratios visibly incomplete. + +### Failure page + +Show at most the API-provided bounded set: + +- Timestamp and request ID. +- Status code/class. +- Safe error code/type/summary. +- Response and integration/application latency with source labels. +- CloudWatch link. + +Do not render raw log messages, request/response bodies, headers, query strings, +source IPs, user agents, stack traces, or unknown HTML. Treat all summaries as +plain text. + +## SendGrid page + +Show one row/card per fixed cumulative window: 15 minutes, 1, 3, 6, 12, and 24 +hours. + +Each shows: + +- Recipient messages accepted by SendGrid after any retries. +- Recipient messages that permanently failed after retry exhaustion. +- Success/failure ratio weighted by safe numeric recipient counts. +- Accepted/failed logical send-operation counts as secondary diagnostics. +- Last terminal send time. +- Source/completeness warning. + +The first release does not show a pending count: its safe aggregate source +contains terminal completion events, and the Status API does not read the email +service's PII-bearing attempt database. A provider `processed` state may appear +in the separately labelled recent-activity section, but it is not mixed into +the terminal acceptance ratio. Retries share one opaque send-operation ID and +recipient count, and must not double-count messages as both failed and accepted. +The UI never receives recipient addresses from this aggregate source. + +Use the exact label “SendGrid API acceptance” so it is not confused with final +recipient delivery. + +A collapsed “Recent provider activity” section must always be available and +fetch up to 50 sanitized records only when opened. It displays masked recipient, +provider status, and event time; subject/body are omitted. If the provider +result is capped or rate limited, display the incomplete warning and last +successful refresh time. + +Do not auto-refresh this section faster than the API/provider policy. + +## Database page + +Show: + +- RDS instance identifier/engine/status. +- Allocated, used, and free storage, with used percentage. +- Logical database size only when the separately approved read-only aggregate + exporter is configured. +- Latest, average, and peak connection counts for the selected window. +- Recent RDS infrastructure events. +- Sanitized PostgreSQL warning/error entries and CloudWatch/RDS links. + +Label the storage metric “RDS storage used” and explain that it is allocated +minus free, not a logical `pg_database_size` query. + +The product owner must approve whether that infrastructure-storage value meets +the requested “total database size” requirement. If exact logical database size +is required, show the exporter-backed value separately; until it exists, render +that field as incomplete rather than relabelling infrastructure usage as logical +size. + +Missing/stale CloudWatch samples are unknown, not zero. PostgreSQL engine logs +remain visibly incomplete until export is enabled and the API says the source +is complete. + +## Data service and hooks + +Create a typed read-only `status.service.ts` using only `xhrGetAsync` with base: + +```ts +`${EnvironmentConfig.API.V6}/status` +``` + +Do not add a dedicated environment base unless there is a demonstrated need; +the v6 base and `/status` path match existing routing. + +Keep: + +- Wire DTOs in `models`. +- Pure display transforms/sorting in `utils`. +- Request lifecycle, stale-response suppression, and retry in hooks. +- Page layout/rendering in pages/components. + +Recommended hooks: + +- `useEcsStatus` +- `useEcsTaskDetail` +- `useApiStatus` +- `useApiEndpointStatus` +- `useApiFailures` +- `useSendgridStatus` +- `useSendgridMessages` +- `useDatabaseStatus` + +Each hook runs only when its route/section is active and all IDs are ready. +Review's SWR defaults (mount fetch, no focus revalidation, no polling) are a good +baseline. Expose explicit Refresh/Retry and show generated/source timestamps. + +Do not load all four tabs in a global context and do not add hidden polling in +the initial release. + +## Implemented file structure + +```text +src/apps/status/ + README.md + index.ts + src/ + index.ts + StatusApp.tsx + status-app.routes.tsx + status-app.routes.spec.tsx + config/ + routes.config.ts + lib/ + components/ + Layout/ + NavTabs/ + StatusTable/ + StatusUi/ + hooks/ + models/ + services/ + status.service.ts + status.service.spec.ts + styles/ + index.scss + utils/ + pages/ + api/ + database/ + ecs/ + sendgrid/ +``` + +Co-locate focused component/hook/utility tests with their source. + +## Integration files + +- `src/config/constants.ts` defines `AppSubdomain.status` and `ToolTitle.status`. +- `src/apps/platform/src/platform.routes.tsx` registers `statusRoutes` before + the root route. +- `src/config/environments/local.env.ts` routes local `/v6/status` calls to port + 3000. +- The root `README.md` lists the hosted Status app. +- Environment/global config models only if implementation proves a separate + `STATUS_API` base is required. + +Platform UI deploys as one bundle; the existing CircleCI deployment should not +need a Status-specific deploy job. Add CI test execution if the repository still +does not enforce `yarn test:no-watch` when implementation begins. + +## Loading, empty, error, and partial states + +Every page must distinguish: + +- Initial loading. +- Refreshing with existing data. +- Complete empty/zero traffic. +- Partial/incomplete source. +- Stale data. +- Authorization failure. +- Provider timeout/throttling. +- General retryable failure. + +Never use the same “No data” view for a complete zero and an unavailable source. +Retain the last successful response during a refresh failure, mark it stale, and +offer Retry. + +## Accessibility and responsive behavior + +- Keyboard-accessible tabs, filters, table rows/actions, and disclosures. +- Proper headings and page titles. +- Accessible names for icon-only refresh/external-link controls. +- Text/icon state in addition to color. +- Sufficient contrast for red/warning rows and badges. +- Desktop table and a readable mobile card/table treatment without hiding the + failure reason or source completeness. +- Respect reduced-motion preferences for loading/refresh affordances. + +## Tests + +Minimum tests: + +### Routes/auth + +- Root redirects to ECS. +- All child/drill routes render through the Status root. +- `authRequired` and exact administrator role are present. +- Non-admin role does not render the app. +- Dedicated/combined host root resolution is correct. + +### Services/hooks + +- Every service call uses GET and `/v6/status`. +- Query windows/IDs are encoded and no arbitrary ARN/log group is constructed. +- Hooks do not fetch before route IDs are ready. +- Inactive tabs and collapsed SendGrid detail do not fetch. +- Stale response suppression and Retry work. + +### Pages/components + +- Critical ECS rows always precede healthy rows and have non-color labels. +- Normal deployments are not falsely red. +- Loading, refreshing, complete empty, incomplete, stale, timeout, and error + states render distinctly. +- Zero-request ratios render as unavailable rather than divide-by-zero values. +- Unattributed edge failures remain separate and mark endpoint coverage + incomplete. +- AWS links include `target="_blank"` and `rel="noopener noreferrer"`. +- API nested drill-down works across refresh/back navigation. +- SendGrid acceptance semantics and fixed windows are labelled correctly. +- Database used-storage explanation and incomplete engine-log state render. +- Responsive/accessibility behavior meets the shared UI standard. + +## Implementation gate + +From `platform-ui`, after every code change: + +```bash +nvm use +yarn lint +yarn test:no-watch +yarn run build +``` + +Fix every reported issue. New/changed functions, methods, and classes require +the documentation mandated by the root `AGENTS.md`. + +## UI acceptance checklist + +- [x] Only an authenticated `administrator` can enter any Status route. +- [x] ECS, API, SendGrid, and Database tabs exist and are responsive. +- [x] Only the active tab reads data. +- [x] ECS failures/error redeployments are first, red, and text/icon labelled. +- [x] Expanded task inventory shows each task's own task-definition revision, + including mixed revisions during a rolling deployment. +- [x] Task-definition and CloudWatch links are safe and usable. +- [x] API service and endpoint drill-down survives refresh/back/deep link. +- [x] Response ratios and p50/p95/p99 latencies are clearly sourced. +- [x] Recent failures show safe reasons without raw request/log data. +- [x] All six SendGrid windows and their acceptance semantics are visible. +- [x] Bounded, sanitized recent SendGrid provider activity loads on demand. +- [x] Database storage, connections, RDS events, and engine warning/error state + are visible. +- [x] The approved database-size definition is shown; any required logical-size + source remains explicitly incomplete until its exporter is available. +- [x] Incomplete/stale/capped sources never appear as healthy zeroes. +- [x] No mutation request or UI action exists. +- [x] Status-focused tests, repository lint, and the production build pass. diff --git a/src/apps/status/index.ts b/src/apps/status/index.ts new file mode 100644 index 000000000..6f39cd49b --- /dev/null +++ b/src/apps/status/index.ts @@ -0,0 +1 @@ +export * from './src' diff --git a/src/apps/status/src/StatusApp.tsx b/src/apps/status/src/StatusApp.tsx new file mode 100644 index 000000000..624c26b8f --- /dev/null +++ b/src/apps/status/src/StatusApp.tsx @@ -0,0 +1,38 @@ +/** + * Root application shell for administrator operational status views. + */ +import { FC, useContext, useEffect, useMemo } from 'react' +import { Outlet, Routes } from 'react-router-dom' + +import { routerContext, RouterContextData } from '~/libs/core' + +import { Layout } from './lib/components' +import { toolTitle } from './status-app.routes' +import './lib/styles/index.scss' + +/** + * Renders the Status navigation, layout, and lazily active child route. + * + * @returns the Status application shell. + * @throws Does not throw. + */ +const StatusApp: FC = () => { + const { getChildRoutes }: RouterContextData = useContext(routerContext) + const childRoutes = useMemo(() => getChildRoutes(toolTitle), [getChildRoutes]) + + useEffect(() => { + document.body.classList.add('status-app') + return () => { + document.body.classList.remove('status-app') + } + }, []) + + return ( + + + {childRoutes} + + ) +} + +export default StatusApp diff --git a/src/apps/status/src/config/routes.config.ts b/src/apps/status/src/config/routes.config.ts new file mode 100644 index 000000000..5cf691762 --- /dev/null +++ b/src/apps/status/src/config/routes.config.ts @@ -0,0 +1,41 @@ +/** + * Route identifiers and builders for the Status application. + */ +import { AppSubdomain, EnvironmentConfig } from '~/config' + +/** + * Resolves the Status app root for combined Platform UI and dedicated hosts. + * + * @param subdomain current host's leading subdomain. + * @returns an empty dedicated-host root or `/status` on the combined host. + * @throws Does not throw. + */ +export function getStatusRootRoute(subdomain: string): string { + return subdomain === AppSubdomain.status + ? '' + : `/${AppSubdomain.status}` +} + +export const rootRoute: string = getStatusRootRoute(EnvironmentConfig.SUBDOMAIN) + +export const ecsRouteId = 'ecs' +export const apiRouteId = 'api' +export const sendgridRouteId = 'sendgrid' +export const databaseRouteId = 'database' + +/** + * Builds an absolute in-app Status path for combined and dedicated hosts. + * + * @param segments URL-safe path segments, excluding the Status root. + * @returns a normalized path beginning with `/`. + * @throws Does not throw. + */ +export function buildStatusPath(...segments: string[]): string { + const suffix = segments + .filter(Boolean) + .map(segment => encodeURIComponent(segment)) + .join('/') + const root = rootRoute || '' + + return `${root}/${suffix}`.replace(/\/+/g, '/') +} diff --git a/src/apps/status/src/index.ts b/src/apps/status/src/index.ts new file mode 100644 index 000000000..a74e2d8fc --- /dev/null +++ b/src/apps/status/src/index.ts @@ -0,0 +1,2 @@ +export { statusRoutes } from './status-app.routes' +export { rootRoute as statusRootRoute } from './config/routes.config' diff --git a/src/apps/status/src/lib/components/Layout/Layout.module.scss b/src/apps/status/src/lib/components/Layout/Layout.module.scss new file mode 100644 index 000000000..085004e23 --- /dev/null +++ b/src/apps/status/src/lib/components/Layout/Layout.module.scss @@ -0,0 +1,23 @@ +@import '@libs/ui/styles/includes'; + +.main { + box-sizing: border-box; + color: var(--Primary); + font-family: $font-roboto; + min-height: 60vh; + padding: $sp-4 0 $sp-10; + width: 100%; + + @include ltemd { + padding-top: $sp-2; + } +} + +.contentLayoutOuter { + margin: $sp-6 auto !important; +} + +.contentLayoutInner { + box-sizing: border-box; + width: 100%; +} diff --git a/src/apps/status/src/lib/components/Layout/Layout.tsx b/src/apps/status/src/lib/components/Layout/Layout.tsx new file mode 100644 index 000000000..c64f4afd2 --- /dev/null +++ b/src/apps/status/src/lib/components/Layout/Layout.tsx @@ -0,0 +1,31 @@ +/** + * Responsive page layout shared by all Status routes. + */ +import { FC, PropsWithChildren } from 'react' + +import { ContentLayout } from '~/libs/ui' + +import { NavTabs } from '../NavTabs' + +import styles from './Layout.module.scss' + +/** + * Places Status content below its Review-style navigation bar. + * + * @param props React children rendered in the constrained content region. + * @returns the Status page layout. + * @throws Does not throw. + */ +export const Layout: FC = props => ( + <> + + +
{props.children}
+
+ +) + +export default Layout diff --git a/src/apps/status/src/lib/components/Layout/index.ts b/src/apps/status/src/lib/components/Layout/index.ts new file mode 100644 index 000000000..358469a05 --- /dev/null +++ b/src/apps/status/src/lib/components/Layout/index.ts @@ -0,0 +1 @@ +export { Layout } from './Layout' diff --git a/src/apps/status/src/lib/components/NavTabs/NavTabs.module.scss b/src/apps/status/src/lib/components/NavTabs/NavTabs.module.scss new file mode 100644 index 000000000..0079ad073 --- /dev/null +++ b/src/apps/status/src/lib/components/NavTabs/NavTabs.module.scss @@ -0,0 +1,135 @@ +@import '@libs/ui/styles/includes'; + +.navBar { + background: #f7f5f1; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); + position: relative; + z-index: 20; +} + +.inner { + align-items: center; + display: flex; + justify-content: space-between; + margin: 0 auto; + max-width: $xxl-min; + padding: $sp-3 0; + width: 100%; + + @include pagePaddings; +} + +.title, +.tab, +.mobileTrigger { + color: var(--FontColor); + font-family: 'Nunito Sans', sans-serif; +} + +.title { + font-weight: 700; + line-height: 32px; +} + +.tabs { + align-items: center; + display: flex; + gap: $sp-8; + list-style: none; + margin: 0; + padding: 0; +} + +.tab { + border-bottom: 3px solid transparent; + display: block; + font-size: 16px; + line-height: 29px; + text-decoration: none; + + &:focus-visible { + border-radius: 2px; + outline: 2px solid var(--Actived); + outline-offset: 2px; + } +} + +.active { + border-bottom-color: var(--Actived); + font-weight: 700; +} + +.mobileTrigger { + align-items: center; + background: transparent; + border: 0; + display: none; + font-size: 16px; + font-weight: 700; + justify-content: space-between; + padding: 0; + width: 100%; +} + +.chevron { + font-size: 24px; + transition: transform 120ms ease; +} + +@include ltemd { + .navBar { + position: sticky; + top: 0; + } + + .inner { + display: block; + position: relative; + } + + .title { + display: none; + } + + .mobileTrigger { + display: flex; + } + + .tabs { + background: #f7f5f1; + box-shadow: 0 8px 12px rgba(0, 0, 0, 0.12); + display: none; + left: 0; + padding: $sp-2 $sp-4 $sp-4; + position: absolute; + right: 0; + top: 100%; + } + + .tab { + border-bottom: 0; + border-radius: 4px; + padding: $sp-2 $sp-4; + } + + .active { + background: var(--Actived); + color: var(--invertButtonColor); + } + + .open { + .tabs { + display: block; + } + + .chevron { + transform: rotate(180deg); + } + } +} + +@media (prefers-reduced-motion: reduce) { + .chevron { + transition: none; + } +} diff --git a/src/apps/status/src/lib/components/NavTabs/NavTabs.spec.tsx b/src/apps/status/src/lib/components/NavTabs/NavTabs.spec.tsx new file mode 100644 index 000000000..682203538 --- /dev/null +++ b/src/apps/status/src/lib/components/NavTabs/NavTabs.spec.tsx @@ -0,0 +1,33 @@ +/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */ +import { MemoryRouter } from 'react-router-dom' +import { fireEvent, render, screen } from '@testing-library/react' + +import { NavTabs } from './NavTabs' + +jest.mock('~/config', () => ({ + AppSubdomain: { status: 'status' }, + EnvironmentConfig: { SUBDOMAIN: 'platform-ui' }, +}), { virtual: true }) + +describe('Status navigation tabs', () => { + it('derives the active tab from a nested route and exposes the mobile menu state', () => { + render( + + + , + ) + + expect(screen.getByRole('link', { name: 'API' }) + .getAttribute('aria-current')) + .toBe('page') + + const trigger = screen.getByRole('button', { name: /Status · API/ }) + expect(trigger.getAttribute('aria-expanded')) + .toBe('false') + + fireEvent.click(trigger) + + expect(trigger.getAttribute('aria-expanded')) + .toBe('true') + }) +}) diff --git a/src/apps/status/src/lib/components/NavTabs/NavTabs.tsx b/src/apps/status/src/lib/components/NavTabs/NavTabs.tsx new file mode 100644 index 000000000..ddcfb129c --- /dev/null +++ b/src/apps/status/src/lib/components/NavTabs/NavTabs.tsx @@ -0,0 +1,98 @@ +/* eslint-disable react/jsx-no-bind */ +/** + * URL-driven desktop and mobile navigation for Status tabs. + */ +import { FC, useMemo, useState } from 'react' +import { Link, useLocation } from 'react-router-dom' +import classNames from 'classnames' + +import { + apiRouteId, + buildStatusPath, + databaseRouteId, + ecsRouteId, + sendgridRouteId, +} from '../../../config/routes.config' + +import styles from './NavTabs.module.scss' + +interface StatusTab { + id: string + label: string +} + +const STATUS_TABS: StatusTab[] = [ + { id: ecsRouteId, label: 'ECS' }, + { id: apiRouteId, label: 'API' }, + { id: sendgridRouteId, label: 'SendGrid' }, + { id: databaseRouteId, label: 'Database' }, +] + +/** + * Gets the active top-level tab from a Status route pathname. + * + * @param pathname current browser pathname. + * @returns the matching tab ID, defaulting to ECS. + * @throws Does not throw. + */ +export function getActiveStatusTab(pathname: string): string { + const matchingTab = STATUS_TABS.find(tab => ( + pathname === buildStatusPath(tab.id) + || pathname.startsWith(`${buildStatusPath(tab.id)}/`) + )) + + return matchingTab?.id ?? ecsRouteId +} + +/** + * Renders always-visible administrator Status tabs and an accessible mobile disclosure. + * + * @returns responsive Status navigation. + * @throws Does not throw. + */ +export const NavTabs: FC = () => { + const { pathname }: { pathname: string } = useLocation() + const [isOpen, setIsOpen] = useState(false) + const activeTab = useMemo(() => getActiveStatusTab(pathname), [pathname]) + const activeLabel = STATUS_TABS.find(tab => tab.id === activeTab)?.label ?? 'ECS' + + return ( + + ) +} + +export default NavTabs diff --git a/src/apps/status/src/lib/components/NavTabs/index.ts b/src/apps/status/src/lib/components/NavTabs/index.ts new file mode 100644 index 000000000..4f605ea86 --- /dev/null +++ b/src/apps/status/src/lib/components/NavTabs/index.ts @@ -0,0 +1,2 @@ +export { NavTabs } from './NavTabs' +export { getActiveStatusTab } from './NavTabs' diff --git a/src/apps/status/src/lib/components/StatusTable/StatusTable.module.scss b/src/apps/status/src/lib/components/StatusTable/StatusTable.module.scss new file mode 100644 index 000000000..faaa130b5 --- /dev/null +++ b/src/apps/status/src/lib/components/StatusTable/StatusTable.module.scss @@ -0,0 +1,141 @@ +@import '@libs/ui/styles/includes'; + +.wrapper { + overflow: hidden; + width: 100%; +} + +.desktop { + overflow-x: auto; + + table { + border-collapse: collapse; + min-width: 960px; + width: 100%; + } + + th { + background: #f4f5f5; + border-bottom: 1px solid #c9cdd0; + color: #555c62; + font-family: 'Nunito Sans', sans-serif; + font-size: 11px; + letter-spacing: 0.04em; + padding: $sp-3 $sp-4; + text-align: left; + text-transform: uppercase; + white-space: nowrap; + } + + td { + border-bottom: 1px solid #e3e5e6; + color: #303438; + font-size: 13px; + line-height: 19px; + padding: $sp-4; + vertical-align: top; + } +} + +.rowGroup:last-child td { + border-bottom: 0; +} + +.critical > td, +.critical.card { + background: #fff0f1; + border-color: #d77a84; +} + +.warning > td, +.warning.card { + background: #fff9e9; + border-color: #e4b84b; +} + +.unknown > td, +.unknown.card { + background: #f6f7f7; +} + +.healthy-change > td, +.healthy-change.card { + background: #f0fafb; +} + +.clickable { + cursor: pointer; + + &:hover > td, + &:focus > td { + box-shadow: inset 0 1px #087d8b, inset 0 -1px #087d8b; + } + + &:focus { + outline: 2px solid #087d8b; + outline-offset: -2px; + } +} + +.expandedArea { + background: #f8fafb; + padding: $sp-5; +} + +.mobile { + display: none; +} + +.card { + background: #fff; + border: 1px solid #d7dadd; + border-left: 4px solid #087d8b; + border-radius: 6px; + margin: $sp-3; + padding: $sp-4; + + button { + background: #fff; + border: 1px solid #087d8b; + border-radius: 4px; + color: #087d8b; + font-weight: 700; + margin-top: $sp-3; + padding: $sp-2 $sp-3; + width: 100%; + } +} + +.cardField { + border-bottom: 1px solid #e7e8e9; + display: grid; + gap: $sp-3; + grid-template-columns: minmax(100px, 36%) 1fr; + padding: $sp-2 0; + + > strong { + color: #5b6166; + font-size: 12px; + text-transform: uppercase; + } +} + +.srOnly { + height: 1px; + margin: -1px; + overflow: hidden; + padding: 0; + position: absolute; + width: 1px; + clip: rect(0, 0, 0, 0); +} + +@include ltemd { + .desktop { + display: none; + } + + .mobile { + display: block; + } +} diff --git a/src/apps/status/src/lib/components/StatusTable/StatusTable.spec.tsx b/src/apps/status/src/lib/components/StatusTable/StatusTable.spec.tsx new file mode 100644 index 000000000..7d9fa7c2b --- /dev/null +++ b/src/apps/status/src/lib/components/StatusTable/StatusTable.spec.tsx @@ -0,0 +1,41 @@ +/* eslint-disable import/no-extraneous-dependencies, react/jsx-no-bind */ +import { fireEvent, render, screen } from '@testing-library/react' + +import { StatusColumn, StatusTable } from './StatusTable' + +interface Row { + id: string + label: string + severity: 'critical' | 'healthy' +} + +const columns: StatusColumn[] = [{ + id: 'label', + label: 'Service', + render: row => row.label, +}] + +const rows: Row[] = [{ id: 'failure', label: 'Email API', severity: 'critical' }] + +describe('StatusTable', () => { + it('keeps a non-color label and keyboard activation on interactive failure rows', () => { + const onRowClick = jest.fn() + render( + row.id} + getRowLabel={row => `${row.severity}: ${row.label}`} + getSeverity={row => row.severity} + onRowClick={onRowClick} + rows={rows} + />, + ) + + const desktopRow = screen.getAllByLabelText('critical: Email API')[0] + fireEvent.keyDown(desktopRow, { key: 'Enter' }) + + expect(onRowClick) + .toHaveBeenCalledWith(expect.objectContaining({ id: 'failure' })) + }) +}) diff --git a/src/apps/status/src/lib/components/StatusTable/StatusTable.tsx b/src/apps/status/src/lib/components/StatusTable/StatusTable.tsx new file mode 100644 index 000000000..7bfc63c13 --- /dev/null +++ b/src/apps/status/src/lib/components/StatusTable/StatusTable.tsx @@ -0,0 +1,125 @@ +/* eslint-disable react/function-component-definition, react/jsx-no-bind */ +/** + * Responsive desktop table and mobile card renderer for operational data. + */ +import { ReactNode } from 'react' +import classNames from 'classnames' + +import { StatusSeverity } from '../../models' + +import styles from './StatusTable.module.scss' + +export interface StatusColumn { + id: string + label: string + render: (row: T) => ReactNode + mobileLabel?: string + className?: string +} + +export interface StatusTableProps { + caption: string + columns: StatusColumn[] + rows: readonly T[] + getKey: (row: T) => string + getSeverity?: (row: T) => StatusSeverity + getRowLabel?: (row: T) => string + onRowClick?: (row: T) => void + expandedRow?: (row: T) => ReactNode +} + +/** + * Renders all important fields in both desktop and mobile representations. + * Critical and warning rows carry accessible labels in addition to color. + * + * @param props columns, rows, keys, severity, and optional row interaction. + * @returns responsive read-only data table. + * @throws Does not throw. + */ +export function StatusTable(props: StatusTableProps): JSX.Element { + return ( +
+
+ + + + + {props.columns.map(column => ( + + ))} + + + {props.rows.map(row => { + const key = props.getKey(row) + const severity = props.getSeverity?.(row) + const clickable = Boolean(props.onRowClick) + const content = ( + props.onRowClick?.(row) : undefined} + onKeyDown={clickable ? event => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + props.onRowClick?.(row) + } + } : undefined} + tabIndex={clickable ? 0 : undefined} + > + {props.columns.map(column => ( + + ))} + + ) + return ( + + {content} + + ) + })} +
{props.caption}
+ {column.label} +
+ {column.render(row)} +
+
+
+ {props.rows.map(row => { + const severity = props.getSeverity?.(row) + return ( +
+ {props.columns.map(column => ( +
+ {column.mobileLabel || column.label} +
{column.render(row)}
+
+ ))} + {props.onRowClick && ( + + )} +
+ ) + })} +
+ {props.expandedRow && ( +
+ {props.rows.map(row => { + const expandedContent = props.expandedRow?.(row) + return expandedContent + ?
{expandedContent}
+ : undefined + })} +
+ )} +
+ ) +} diff --git a/src/apps/status/src/lib/components/StatusTable/index.ts b/src/apps/status/src/lib/components/StatusTable/index.ts new file mode 100644 index 000000000..403115146 --- /dev/null +++ b/src/apps/status/src/lib/components/StatusTable/index.ts @@ -0,0 +1 @@ +export * from './StatusTable' diff --git a/src/apps/status/src/lib/components/StatusUi/StatusUi.module.scss b/src/apps/status/src/lib/components/StatusUi/StatusUi.module.scss new file mode 100644 index 000000000..5c28851d4 --- /dev/null +++ b/src/apps/status/src/lib/components/StatusUi/StatusUi.module.scss @@ -0,0 +1,331 @@ +@import '@libs/ui/styles/includes'; + +.page { + color: #2a2a2a; +} + +.pageHeader { + align-items: flex-start; + display: flex; + gap: $sp-6; + justify-content: space-between; + margin-bottom: $sp-6; + + h1 { + color: var(--FontColor); + font-family: 'Nunito Sans', sans-serif; + font-size: 32px; + line-height: 40px; + margin: 0 0 $sp-2; + } + + p { + color: #555; + line-height: 24px; + margin: 0; + max-width: 760px; + } +} + +.actions { + align-items: flex-end; + display: flex; + flex-shrink: 0; + gap: $sp-3; +} + +.backLink { + color: #087d8b; + display: inline-flex; + font-weight: 700; + gap: $sp-2; + margin-bottom: $sp-4; + text-decoration: none; +} + +.badge { + align-items: center; + border: 1px solid currentColor; + border-radius: 999px; + display: inline-flex; + font-size: 12px; + font-weight: 700; + gap: 6px; + line-height: 18px; + padding: 2px 9px; + white-space: nowrap; +} + +.badgeIcon { + align-items: center; + border: 1px solid currentColor; + border-radius: 50%; + display: inline-flex; + font-size: 10px; + height: 14px; + justify-content: center; + line-height: 1; + width: 14px; +} + +.critical { + background: #fff0f1; + color: #a30d19; +} + +.warning { + background: #fff8e5; + color: #7a5200; +} + +.healthy { + background: #eaf8ef; + color: #176b36; +} + +.healthy-change { + background: #e8f6f7; + color: #086a75; +} + +.unknown { + background: #f1f2f3; + color: #4f5459; +} + +.freshness { + align-items: center; + color: #60666c; + display: flex; + flex-wrap: wrap; + font-size: 13px; + gap: $sp-2; + margin: $sp-3 0 $sp-5; +} + +.stale, +.incomplete { + border-radius: 3px; + padding: 2px $sp-2; +} + +.stale { + background: #f1f2f3; + color: #4f5459; +} + +.incomplete { + background: #fff8e5; + color: #7a5200; +} + +.metricCard { + background: #fff; + border: 1px solid #d7dadd; + border-left: 4px solid #087d8b; + border-radius: 6px; + box-shadow: 0 2px 7px rgba(0, 0, 0, 0.05); + min-width: 0; + padding: $sp-5; + + h2 { + color: #555c62; + font-size: 13px; + font-weight: 700; + letter-spacing: 0.02em; + margin: 0 0 $sp-3; + text-transform: uppercase; + } + + p { + color: #60666c; + font-size: 13px; + line-height: 18px; + margin: $sp-2 0 0; + } +} + +.metric-critical { + border-left-color: #c01b2b; +} + +.metric-warning { + border-left-color: #b47700; +} + +.metric-healthy { + border-left-color: #238547; +} + +.metric-healthy-change { + border-left-color: #087d8b; +} + +.metric-unknown { + border-left-color: #72777c; +} + +.metricValue { + color: #24272a; + font-family: 'Nunito Sans', sans-serif; + font-size: 28px; + font-weight: 700; + line-height: 34px; +} + +.windowLabel { + color: #444a4f; + display: flex; + flex-direction: column; + font-size: 12px; + font-weight: 700; + gap: 4px; + + select { + background: #fff; + border: 1px solid #a9afb4; + border-radius: 4px; + color: #24272a; + font: inherit; + font-size: 14px; + min-width: 112px; + padding: 8px 28px 8px 10px; + } +} + +.notice, +.errorState, +.emptyState, +.loading { + border-radius: 6px; + line-height: 22px; + margin: $sp-4 0; + padding: $sp-4 $sp-5; + + p { + margin: $sp-1 0; + } +} + +.notice { + background: #fff8e5; + border: 1px solid #e4b84b; + color: #654600; + + ul { + margin: $sp-2 0 0; + padding-left: $sp-6; + } +} + +.errorState { + background: #fff0f1; + border: 1px solid #d77a84; + color: #78121c; + + button { + background: #a30d19; + border: 0; + border-radius: 4px; + color: #fff; + cursor: pointer; + font-weight: 700; + margin-top: $sp-2; + padding: $sp-2 $sp-4; + } +} + +.emptyState { + background: #fff; + border: 1px dashed #a9afb4; + color: #555c62; + text-align: center; +} + +.loading { + align-items: center; + background: #fff; + border: 1px solid #d7dadd; + display: flex; + gap: $sp-3; + + span { + animation: status-spin 800ms linear infinite; + border: 3px solid #d7dadd; + border-radius: 50%; + border-top-color: #087d8b; + height: 18px; + width: 18px; + } +} + +.refreshButton { + background: #fff; + border: 1px solid #087d8b; + border-radius: 4px; + color: #087d8b; + cursor: pointer; + font-weight: 700; + padding: 9px $sp-4; + + &:disabled { + cursor: wait; + opacity: 0.65; + } +} + +.panel { + background: #fff; + border: 1px solid #d7dadd; + border-radius: 6px; + box-shadow: 0 2px 7px rgba(0, 0, 0, 0.04); + margin: $sp-5 0; + overflow: hidden; +} + +.panelTitle { + color: #24272a; + font-size: 18px; + margin: 0; + padding: $sp-5 $sp-5 0; +} + +.awsLink { + color: #087d8b; + font-weight: 700; + white-space: nowrap; +} + +.unavailable { + color: #72777c; + font-style: italic; +} + +@keyframes status-spin { + to { + transform: rotate(360deg); + } +} + +@include ltemd { + .pageHeader { + display: block; + + h1 { + font-size: 28px; + } + } + + .actions { + align-items: stretch; + flex-wrap: wrap; + margin-top: $sp-4; + } +} + +@media (prefers-reduced-motion: reduce) { + .loading span { + animation: none; + border-top-color: #087d8b; + } +} diff --git a/src/apps/status/src/lib/components/StatusUi/StatusUi.spec.tsx b/src/apps/status/src/lib/components/StatusUi/StatusUi.spec.tsx new file mode 100644 index 000000000..5a0aebcd8 --- /dev/null +++ b/src/apps/status/src/lib/components/StatusUi/StatusUi.spec.tsx @@ -0,0 +1,55 @@ +/* eslint-disable @typescript-eslint/typedef, import/no-extraneous-dependencies */ +import { render, screen } from '@testing-library/react' + +import { ExternalAwsLink, HealthBadge, IncompleteDataNotice } from './StatusUi' + +describe('Status UI primitives', () => { + it('uses icon and text for critical state', () => { + render() + + expect(screen.getByLabelText('Status: Critical')) + .toBeTruthy() + expect(screen.getByText('Critical')) + .toBeTruthy() + }) + + it('renders structured incomplete warnings', () => { + render( + , + ) + + expect(screen.getByText('Incomplete monitoring data')) + .toBeTruthy() + expect(screen.getByText(/History is still warming/)) + .toBeTruthy() + }) + + it('allows only HTTPS AWS Console links and applies safe target attributes', () => { + const { rerender } = render( + AWS, + ) + const link = screen.getByRole('link', { name: /AWS/ }) + + expect(link.getAttribute('target')) + .toBe('_blank') + expect(link.getAttribute('rel')) + .toBe('noopener noreferrer') + + rerender(Unsafe) + expect(screen.queryByRole('link')) + .toBeNull() + expect(screen.getByText('Unavailable')) + .toBeTruthy() + }) +}) diff --git a/src/apps/status/src/lib/components/StatusUi/StatusUi.tsx b/src/apps/status/src/lib/components/StatusUi/StatusUi.tsx new file mode 100644 index 000000000..8be500e0b --- /dev/null +++ b/src/apps/status/src/lib/components/StatusUi/StatusUi.tsx @@ -0,0 +1,364 @@ +/* eslint-disable react/jsx-no-bind, unicorn/no-null */ +/** + * Small accessible presentation components shared across Status pages. + */ +import { + ChangeEvent, + FC, + PropsWithChildren, + ReactNode, +} from 'react' +import { Link } from 'react-router-dom' +import classNames from 'classnames' + +import { + StatusMeta, + StatusRequestError, + StatusSeverity, + StatusWindow, +} from '../../models' +import { formatTimestamp } from '../../utils' + +import styles from './StatusUi.module.scss' + +const SEVERITY_LABELS: Record = { + critical: 'Critical', + healthy: 'Healthy', + 'healthy-change': 'Healthy · recent change', + unknown: 'Unknown', + warning: 'Warning', +} + +const SEVERITY_SYMBOLS: Record = { + critical: '!', + healthy: '✓', + 'healthy-change': '↻', + unknown: '?', + warning: '▲', +} + +export interface HealthBadgeProps { + severity: StatusSeverity + label?: string +} + +/** + * Renders a non-color-only operational severity badge. + * + * @param props severity and optional server-facing label. + * @returns icon-and-text health badge. + * @throws Does not throw. + */ +export const HealthBadge: FC = props => { + const label = props.label || SEVERITY_LABELS[props.severity] + return ( + + + {label} + + ) +} + +export interface DataFreshnessProps { + meta: StatusMeta + refreshing?: boolean + stale?: boolean +} + +/** + * Shows response sources, generation time, refresh state, and completeness. + * + * @param props response metadata plus current lifecycle flags. + * @returns source and freshness line. + * @throws Does not throw. + */ +export const DataFreshness: FC = props => ( +
+ + Sources: + {' '} + {props.meta.source.join(', ') || 'unknown'} + + + + As of + {' '} + + + {props.meta.window && ( + <> + + + Window: + {' '} + {props.meta.window} + + + )} + {props.refreshing && Refreshing…} + {props.stale && Stale} + {!props.meta.complete && Incomplete} +
+) + +export interface MetricCardProps { + label: string + value: ReactNode + context?: ReactNode + state?: StatusSeverity +} + +/** + * Renders one compact headline metric with optional context. + * + * @param props metric label, value, context, and health state. + * @returns semantic metric card. + * @throws Does not throw. + */ +export const MetricCard: FC = props => ( +
+

{props.label}

+
{props.value}
+ {props.context &&

{props.context}

} +
+) + +export interface TimeWindowSelectProps { + id: string + value: StatusWindow + windows?: StatusWindow[] + onChange: (window: StatusWindow) => void +} + +/** + * Restricts monitoring queries to windows supported by status-api-v6. + * + * @param props control ID, selected value, optional allowed windows, and handler. + * @returns labelled select control. + * @throws Does not throw. + */ +export const TimeWindowSelect: FC = props => { + const windows = props.windows ?? ['15m', '1h', '3h', '6h', '12h', '24h', '7d'] + const handleChange = (event: ChangeEvent): void => { + props.onChange(event.target.value as StatusWindow) + } + + return ( + + ) +} + +export interface IncompleteDataNoticeProps { + meta: StatusMeta + message?: string +} + +/** + * Explains why partial monitoring data must not be interpreted as healthy zeroes. + * + * @param props incomplete response metadata and optional contextual message. + * @returns warning notice, or nothing for complete data. + * @throws Does not throw. + */ +export const IncompleteDataNotice: FC = props => { + if (props.meta.complete) { + return null + } + + const fallbackMessage = 'Some sources are unavailable or still warming. ' + + 'Missing values are unknown, not zero.' + + return ( + + ) +} + +export interface RetryableErrorStateProps { + error: StatusRequestError + hasStaleData?: boolean + onRetry: () => void +} + +/** + * Renders a categorized request failure and explicit retry action. + * + * @param props safe error, stale-data flag, and retry handler. + * @returns accessible error notice. + * @throws Does not throw. + */ +export const RetryableErrorState: FC = props => ( +
+ + {props.error.kind === 'authorization' ? 'Access unavailable' : 'Monitoring source unavailable'} + +

{props.error.message}

+ {props.hasStaleData &&

The last successful result remains visible below.

} + +
+) + +export interface ExternalAwsLinkProps { + href?: string | null + children?: ReactNode + ariaLabel?: string +} + +/** + * Validates and renders a safe AWS Console link in a new tab. + * + * @param props candidate URL, label, and optional accessible name. + * @returns safe external anchor or an unavailable marker. + * @throws Does not throw; invalid URLs render as unavailable. + */ +export const ExternalAwsLink: FC = props => { + let safeHref: string | undefined + try { + if (props.href) { + const parsed = new URL(props.href) + const isAwsConsole = parsed.hostname === 'console.aws.amazon.com' + || parsed.hostname.endsWith('.console.aws.amazon.com') + if (parsed.protocol === 'https:' && isAwsConsole) { + safeHref = parsed.toString() + } + } + } catch { + safeHref = undefined + } + + if (!safeHref) { + return Unavailable + } + + return ( + + {props.children || 'Open in AWS'} + {' '} + + + ) +} + +export interface StatusPageProps extends PropsWithChildren { + title: string + description: string + backTo?: string + backLabel?: string + actions?: ReactNode +} + +/** + * Provides consistent headings, breadcrumbs/back links, and page actions. + * + * @param props page title, description, navigation, actions, and body. + * @returns Status page frame. + * @throws Does not throw. + */ +export const StatusPage: FC = props => ( +
+ {props.backTo && ( + + + {' '} + {props.backLabel || 'Back'} + + )} +
+
+

{props.title}

+

{props.description}

+
+ {props.actions &&
{props.actions}
} +
+ {props.children} +
+) + +/** + * Renders a restrained initial loading state that respects reduced motion. + * + * @returns accessible loading panel. + * @throws Does not throw. + */ +export const StatusLoading: FC = () => ( +
+
+) + +/** + * Renders a complete, source-backed empty result distinct from unavailable data. + * + * @param props explanatory empty-state content. + * @returns empty-state panel. + * @throws Does not throw. + */ +export const CompleteEmptyState: FC = props => ( +
{props.children}
+) + +/** + * Renders a standard read-only refresh button. + * + * @param props refresh handler and in-progress state. + * @returns refresh control. + * @throws Does not throw. + */ +export const RefreshButton: FC<{ onRefresh: () => void; refreshing?: boolean }> = props => ( + +) + +/** + * Visually groups related Status content under an accessible heading. + * + * @param props heading and child content. + * @returns content panel. + * @throws Does not throw. + */ +export const StatusPanel: FC> = props => ( +
+ {props.title &&

{props.title}

} + {props.children} +
+) diff --git a/src/apps/status/src/lib/components/StatusUi/index.ts b/src/apps/status/src/lib/components/StatusUi/index.ts new file mode 100644 index 000000000..1b581ffdc --- /dev/null +++ b/src/apps/status/src/lib/components/StatusUi/index.ts @@ -0,0 +1 @@ +export * from './StatusUi' diff --git a/src/apps/status/src/lib/components/index.ts b/src/apps/status/src/lib/components/index.ts new file mode 100644 index 000000000..67dba41ba --- /dev/null +++ b/src/apps/status/src/lib/components/index.ts @@ -0,0 +1,4 @@ +export * from './Layout' +export * from './NavTabs' +export * from './StatusTable' +export * from './StatusUi' diff --git a/src/apps/status/src/lib/hooks/index.ts b/src/apps/status/src/lib/hooks/index.ts new file mode 100644 index 000000000..d8776c61d --- /dev/null +++ b/src/apps/status/src/lib/hooks/index.ts @@ -0,0 +1,2 @@ +export * from './status.hooks' +export * from './useStatusResource' diff --git a/src/apps/status/src/lib/hooks/status.hooks.spec.tsx b/src/apps/status/src/lib/hooks/status.hooks.spec.tsx new file mode 100644 index 000000000..ffb20f791 --- /dev/null +++ b/src/apps/status/src/lib/hooks/status.hooks.spec.tsx @@ -0,0 +1,45 @@ +/* eslint-disable @typescript-eslint/typedef, import/no-extraneous-dependencies */ +import { renderHook, waitFor } from '@testing-library/react' + +import { getSendgridMessages } from '../services' + +import { useSendgridMessages } from './status.hooks' + +jest.mock('../services', () => ({ + getSendgridMessages: jest.fn(), +})) + +const mockedGetSendgridMessages = getSendgridMessages as jest.Mock + +describe('Status route-scoped hooks', () => { + beforeEach(() => { + mockedGetSendgridMessages.mockReset() + mockedGetSendgridMessages.mockResolvedValue({ + data: { messages: [] }, + meta: { + complete: true, + generatedAt: '2026-07-20T00:00:00.000Z', + source: ['sendgrid'], + warnings: [], + }, + }) + }) + + it('does not fetch SendGrid activity until its disclosure opens', async () => { + const { rerender, result } = renderHook( + ({ enabled }) => useSendgridMessages(enabled), + { initialProps: { enabled: false } }, + ) + + expect(mockedGetSendgridMessages).not.toHaveBeenCalled() + expect(result.current.loading) + .toBe(false) + + rerender({ enabled: true }) + + await waitFor(() => expect(mockedGetSendgridMessages) + .toHaveBeenCalledTimes(1)) + await waitFor(() => expect(result.current.data?.data.messages) + .toEqual([])) + }) +}) diff --git a/src/apps/status/src/lib/hooks/status.hooks.ts b/src/apps/status/src/lib/hooks/status.hooks.ts new file mode 100644 index 000000000..cb6ea5efb --- /dev/null +++ b/src/apps/status/src/lib/hooks/status.hooks.ts @@ -0,0 +1,189 @@ +/** + * Route-scoped Status data hooks. Each hook is disabled until all identifiers + * required by its active view are available. + */ +import { useCallback } from 'react' + +import { + ApiEndpointsData, + ApiFailuresData, + ApiServicesData, + DatabaseSummaryData, + EcsClustersData, + EcsServiceData, + EcsTaskData, + EcsTasksData, + SendgridMessagesData, + SendgridSummaryData, + StatusEnvelope, + StatusWindow, +} from '../models' +import { + EcsTaskQuery, + getApiEndpoints, + getApiFailures, + getApiServices, + getDatabaseSummary, + getEcsClusters, + getEcsService, + getEcsTask, + getEcsTasks, + getSendgridMessages, + getSendgridSummary, +} from '../services' + +import { StatusResourceState, useStatusResource } from './useStatusResource' + +/** + * Loads ECS cluster/service summaries for the active ECS tab. + * + * @returns request lifecycle state for the ECS overview envelope. + * @throws Does not throw; failures are exposed through the returned state. + */ +export function useEcsStatus(): StatusResourceState> { + return useStatusResource('ecs:clusters', getEcsClusters) +} + +/** + * Loads one task page only while a service inventory is expanded. + * + * @param query allowlisted service, task, and cursor filters. + * @param enabled whether the expanded inventory may issue a request. + * @returns request lifecycle state for one ECS task page. + * @throws Does not throw; failures are exposed through the returned state. + */ +export function useEcsTasks( + query: EcsTaskQuery, + enabled: boolean, +): StatusResourceState> { + const key = enabled && query.serviceId && query.clusterId + ? `ecs:tasks:${JSON.stringify(query)}` + : undefined + const request = useCallback(() => getEcsTasks(query), [query]) + return useStatusResource(key, request) +} + +/** + * Loads current details for a selected service when its ID is available. + * + * @param serviceId server-issued opaque service identifier, or undefined to disable. + * @returns request lifecycle state for the selected service. + * @throws Does not throw; failures are exposed through the returned state. + */ +export function useEcsService( + serviceId?: string, +): StatusResourceState> { + const request = useCallback(() => getEcsService(serviceId as string), [serviceId]) + return useStatusResource(serviceId ? `ecs:service:${serviceId}` : undefined, request) +} + +/** + * Loads sanitized stopped-task details when a task ID is available. + * + * @param taskId server-issued opaque task identifier, or undefined to disable. + * @returns request lifecycle state for the selected task. + * @throws Does not throw; failures are exposed through the returned state. + */ +export function useEcsTaskDetail( + taskId?: string, +): StatusResourceState> { + const request = useCallback(() => getEcsTask(taskId as string), [taskId]) + return useStatusResource(taskId ? `ecs:task:${taskId}` : undefined, request) +} + +/** + * Loads API service aggregates for the selected supported window. + * + * @param window supported API aggregation window. + * @returns request lifecycle state for the API overview. + * @throws Does not throw; failures are exposed through the returned state. + */ +export function useApiStatus( + window: StatusWindow, +): StatusResourceState> { + const request = useCallback(() => getApiServices(window), [window]) + return useStatusResource(`api:services:${window}`, request) +} + +/** + * Loads endpoint aggregates after the routed service ID is available. + * + * @param serviceId routed opaque service identifier, or undefined to disable. + * @param window supported endpoint aggregation window. + * @returns request lifecycle state for the endpoint drilldown. + * @throws Does not throw; failures are exposed through the returned state. + */ +export function useApiEndpointStatus( + serviceId: string | undefined, + window: StatusWindow, +): StatusResourceState> { + const request = useCallback( + () => getApiEndpoints(serviceId as string, window), + [serviceId, window], + ) + const key = serviceId ? `api:endpoints:${serviceId}:${window}` : undefined + return useStatusResource(key, request) +} + +/** + * Loads bounded safe failures after both routed identifiers are available. + * + * @param serviceId routed opaque service identifier, or undefined to disable. + * @param endpointId routed opaque endpoint identifier, or undefined to disable. + * @param window supported failure aggregation window. + * @returns request lifecycle state for the failure drilldown. + * @throws Does not throw; failures are exposed through the returned state. + */ +export function useApiFailures( + serviceId: string | undefined, + endpointId: string | undefined, + window: StatusWindow, +): StatusResourceState> { + const request = useCallback( + () => getApiFailures(serviceId as string, endpointId as string, window), + [endpointId, serviceId, window], + ) + const key = serviceId && endpointId + ? `api:failures:${serviceId}:${endpointId}:${window}` + : undefined + return useStatusResource(key, request) +} + +/** + * Loads exact rolling SendGrid acceptance aggregates for the active tab. + * + * @returns request lifecycle state for all six fixed SendGrid windows. + * @throws Does not throw; failures are exposed through the returned state. + */ +export function useSendgridStatus(): StatusResourceState> { + return useStatusResource('sendgrid:summary', getSendgridSummary) +} + +/** + * Loads the server-bounded first provider activity page only while its disclosure is open. + * + * @param enabled whether the activity disclosure is open and the request may run. + * @returns resource state containing up to 50 sanitized provider records. + * @throws Does not throw; failures are exposed through the returned state. + */ +export function useSendgridMessages( + enabled: boolean, +): StatusResourceState> { + const request = useCallback(() => getSendgridMessages(), []) + const key = enabled ? 'sendgrid:messages:first' : undefined + return useStatusResource(key, request) +} + +/** + * Loads database telemetry for the selected supported window. + * + * @param window supported database telemetry and event window. + * @returns request lifecycle state for the database overview. + * @throws Does not throw; failures are exposed through the returned state. + */ +export function useDatabaseStatus( + window: StatusWindow, +): StatusResourceState> { + const request = useCallback(() => getDatabaseSummary(window), [window]) + return useStatusResource(`database:summary:${window}`, request) +} diff --git a/src/apps/status/src/lib/hooks/useStatusResource.spec.tsx b/src/apps/status/src/lib/hooks/useStatusResource.spec.tsx new file mode 100644 index 000000000..cfea1900f --- /dev/null +++ b/src/apps/status/src/lib/hooks/useStatusResource.spec.tsx @@ -0,0 +1,77 @@ +/* eslint-disable @typescript-eslint/typedef, import/no-extraneous-dependencies, react/jsx-no-bind */ +import { act, renderHook, waitFor } from '@testing-library/react' + +import { useStatusResource } from './useStatusResource' + +interface Deferred { + promise: Promise + reject: (reason?: unknown) => void + resolve: (value: T) => void +} + +/** + * Creates an externally controlled promise for request-order tests. + * + * @returns deferred promise and settlement functions. + * @throws Does not throw. + */ +function deferred(): Deferred { + let resolve!: (value: T) => void + let reject!: (reason?: unknown) => void + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise + reject = rejectPromise + }) + return { promise, reject, resolve } +} + +describe('useStatusResource', () => { + it('does not fetch while a route identifier is unavailable', () => { + const request = jest.fn() + + const { result } = renderHook(() => useStatusResource(undefined, request)) + + expect(request).not.toHaveBeenCalled() + expect(result.current.loading) + .toBe(false) + }) + + it('suppresses a stale response after the request key changes', async () => { + const first = deferred() + const second = deferred() + const request = jest.fn() + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise) + const { rerender, result } = renderHook( + ({ key }) => useStatusResource(key, request), + { initialProps: { key: 'first' } }, + ) + + rerender({ key: 'second' }) + await act(async () => second.resolve('new')) + await waitFor(() => expect(result.current.data) + .toBe('new')) + await act(async () => first.resolve('old')) + + expect(result.current.data) + .toBe('new') + }) + + it('retains last-good data and marks it stale when refresh fails', async () => { + const request = jest.fn() + .mockResolvedValueOnce('fresh') + .mockRejectedValueOnce({ status: 503 }) + const { result } = renderHook(() => useStatusResource('resource', request)) + + await waitFor(() => expect(result.current.data) + .toBe('fresh')) + act(() => result.current.refresh()) + await waitFor(() => expect(result.current.error?.kind) + .toBe('throttled')) + + expect(result.current.data) + .toBe('fresh') + expect(result.current.stale) + .toBe(true) + }) +}) diff --git a/src/apps/status/src/lib/hooks/useStatusResource.ts b/src/apps/status/src/lib/hooks/useStatusResource.ts new file mode 100644 index 000000000..4e8d39e10 --- /dev/null +++ b/src/apps/status/src/lib/hooks/useStatusResource.ts @@ -0,0 +1,170 @@ +/** + * Shared request lifecycle for Status pages, including last-good-data retention + * and stale-response suppression. + */ +import { + Dispatch, + SetStateAction, + useCallback, + useEffect, + useRef, + useState, +} from 'react' + +import { StatusRequestError } from '../models' + +export interface StatusResourceState { + data?: T + error?: StatusRequestError + loading: boolean + refreshing: boolean + stale: boolean + refresh: () => void +} + +interface InternalState { + data?: T + error?: StatusRequestError + loading: boolean + refreshing: boolean + stale: boolean +} + +/** + * Converts an intercepted XHR error into a safe UI category and message. + * + * @param error unknown rejected request value. + * @returns sanitized request error suitable for administrator UI display. + * @throws Does not throw. + */ +export function classifyStatusError(error: unknown): StatusRequestError { + const candidate = error as { + code?: string + message?: string + status?: number + response?: { status?: number } + } + const status = candidate?.status ?? candidate?.response?.status + + if (status === 401 || status === 403) { + return { + kind: 'authorization', + message: status === 401 + ? 'Your session is missing or expired. Sign in again to view Status.' + : 'Administrator access is required to view this Status data.', + status, + } + } + + if (status === 504 || candidate?.code === 'ECONNABORTED') { + return { + kind: 'timeout', + message: 'The monitoring source timed out. Existing data may be stale.', + status, + } + } + + if (status === 429 || status === 503) { + return { + kind: 'throttled', + message: 'The monitoring source is temporarily unavailable or rate limited.', + status, + } + } + + return { + kind: 'general', + message: 'Status data could not be loaded. Try again.', + status, + } +} + +/** + * Loads a read-only Status resource when enabled and retains the last successful + * result during refresh failures. + * + * @param key stable identity for the current request, or undefined to disable it. + * @param request function performing the GET request. + * @returns request state and an explicit refresh action. + * @throws Does not throw; failures are returned in state. + */ +export function useStatusResource( + key: string | undefined, + request: () => Promise, +): StatusResourceState { + const requestRef = useRef(request) + requestRef.current = request + const requestSequence = useRef(0) + const activeKey = useRef(undefined) + const [revision, setRevision]: [number, Dispatch>] = useState(0) + const [state, setState] = useState>({ + loading: Boolean(key), + refreshing: false, + stale: false, + }) + + useEffect(() => { + if (!key) { + activeKey.current = undefined + setState({ loading: false, refreshing: false, stale: false }) + return undefined + } + + const keyChanged = activeKey.current !== key + activeKey.current = key + const sequence = requestSequence.current + 1 + requestSequence.current = sequence + + setState(previous => ({ + data: keyChanged ? undefined : previous.data, + error: undefined, + loading: keyChanged || !previous.data, + refreshing: !keyChanged && Boolean(previous.data), + stale: false, + })) + + requestRef.current() + .then(data => { + if (requestSequence.current !== sequence) { + return + } + + setState({ + data, + loading: false, + refreshing: false, + stale: false, + }) + }) + .catch((error: unknown) => { + if (requestSequence.current !== sequence) { + return + } + + setState(previous => ({ + data: previous.data, + error: classifyStatusError(error), + loading: false, + refreshing: false, + stale: Boolean(previous.data), + })) + }) + + return () => { + if (requestSequence.current === sequence) { + requestSequence.current += 1 + } + } + }, [key, revision]) + + const refresh = useCallback(() => { + if (key) { + setRevision(current => current + 1) + } + }, [key]) + + return { + ...state, + refresh, + } +} diff --git a/src/apps/status/src/lib/models/index.ts b/src/apps/status/src/lib/models/index.ts new file mode 100644 index 000000000..7c6019307 --- /dev/null +++ b/src/apps/status/src/lib/models/index.ts @@ -0,0 +1 @@ +export * from './status.models' diff --git a/src/apps/status/src/lib/models/status.models.ts b/src/apps/status/src/lib/models/status.models.ts new file mode 100644 index 000000000..3c1c3d65c --- /dev/null +++ b/src/apps/status/src/lib/models/status.models.ts @@ -0,0 +1,329 @@ +/** + * Wire models returned by status-api-v6. Values remain nullable where a source + * cannot prove a metric so the UI never turns missing telemetry into zero. + */ + +export type StatusWindow = '15m' | '1h' | '3h' | '6h' | '12h' | '24h' | '7d' +export type StatusSeverity = 'critical' | 'warning' | 'healthy-change' | 'healthy' | 'unknown' + +export interface StatusWarning { + code: string + message: string + source?: string +} + +export interface StatusMeta { + generatedAt: string + source: string[] + window?: StatusWindow + complete: boolean + warnings: StatusWarning[] +} + +export interface StatusEnvelope { + data: T + meta: StatusMeta +} + +export interface StatusErrorPayload { + code?: string + message?: string + timestamp?: string + requestId?: string +} + +export interface StatusRequestError { + kind: 'authorization' | 'timeout' | 'throttled' | 'general' + message: string + status?: number +} + +export interface AwsLinkValue { + family: string + revision: number + url?: string | null +} + +export interface EcsDeploymentSummary { + status: string + startedAt?: string | null + finishedAt?: string | null + reason?: string | null +} + +export interface GenericExitInterpretation { + kind: 'generic' + summary: string +} + +export interface EcsFailureSummary { + taskId?: string + timestamp?: string | null + reason?: string | null + stoppedAt?: string | null + stopCode?: string | null + stoppedReason?: string | null + containerName?: string | null + exitCode?: number | null + exitInterpretation?: GenericExitInterpretation | string | null + cloudWatchUrl?: string | null +} + +export interface EcsServiceSummary { + id: string + name: string + clusterId: string + clusterName?: string + desiredCount: number | null + runningCount: number | null + pendingCount: number | null + recentStoppedCount: number | null + stoppedHistoryComplete: boolean + taskDefinition: AwsLinkValue | null + latestDeployment: EcsDeploymentSummary | null + deploymentCounts: { + last24Hours: number + last7Days: number + } + latestFailure?: EcsFailureSummary | null + severity: StatusSeverity + severityReasons: string[] + dataComplete: boolean +} + +export interface EcsClusterSummary { + id: string + name: string + status: string + registeredContainerInstances: number | null + runningTasks: number | null + pendingTasks: number | null + services: EcsServiceSummary[] + severity: StatusSeverity +} + +export interface EcsClustersData { + clusters: EcsClusterSummary[] +} + +export interface EcsContainerStatus { + name: string + lastStatus: string + healthStatus?: string | null + reason?: string | null + exitCode?: number | null + exitInterpretation?: GenericExitInterpretation | string | null +} + +export interface EcsTaskSummary { + id: string + opaqueTaskId?: string + clusterId: string + serviceId: string | null + lastStatus: string + desiredStatus?: string | null + healthStatus?: string | null + startedAt?: string | null + launchedAt?: string | null + stoppedAt?: string | null + stopCode?: string | null + stoppedReason?: string | null + launchType?: string | null + availabilityZone?: string | null + deploymentId?: string | null + taskDefinition: AwsLinkValue | null + taskUrl?: string | null + containers: EcsContainerStatus[] + cloudWatchUrl?: string | null + severity: StatusSeverity + severityReasons: string[] + dataComplete: boolean +} + +export interface EcsTasksData { + tasks: EcsTaskSummary[] + nextCursor?: string | null +} + +export interface EcsServiceData { + service: EcsServiceSummary +} + +export interface EcsTaskData { + task: EcsTaskSummary +} + +export interface ResponseClasses { + success: T + redirect: T + clientError: T + serverError: T +} + +export interface LatencyPercentiles { + p50: number | null + p95: number | null + p99: number | null +} + +export interface ApiLatency { + response: LatencyPercentiles + integration: LatencyPercentiles +} + +export interface ApiTargetHealth { + healthy: number | null + unhealthy: number | null + unknown?: boolean | number | null +} + +export interface ApiServiceSummary { + id: string + name: string + requests: number + responseCounts: ResponseClasses + responseRatios: ResponseClasses + latencyMs: ApiLatency + targetHealth: ApiTargetHealth + dataComplete: boolean +} + +export interface ApiServicesData { + services: ApiServiceSummary[] + summary?: { + requests: number + responseCounts: ResponseClasses + responseRatios: ResponseClasses + latencyMs: ApiLatency + healthyTargets: number | null + unhealthyTargets: number | null + dataComplete: boolean + } +} + +export interface ApiEndpointSummary { + id: string + method: string + routeTemplate: string + requests: number + responseCounts: ResponseClasses + responseRatios: ResponseClasses + latencyMs: ApiLatency + recentFailureCount?: number + dataComplete: boolean +} + +export interface ApiEndpointsData { + service: Pick + endpoints: ApiEndpointSummary[] + coverage: { + attributedRequests: number + unattributedEdgeFailures: number + complete?: boolean + } +} + +export interface ApiFailureRecord { + timestamp: string + requestId: string + method: string + routeTemplate: string + statusCode: number + responseClass: string + errorCode?: string | null + errorType?: string | null + errorSummary?: string | null + responseLatencyMs?: number | null + integrationLatencyMs?: number | null + cloudWatchUrl?: string | null +} + +export interface ApiFailuresData { + service: Pick + endpoint: Pick + failures: ApiFailureRecord[] +} + +export interface SendgridWindowSummary { + window: Exclude + acceptedMessages: number | null + failedMessages: number | null + acceptedOperations: number | null + failedOperations: number | null + successRatio: number | null + failureRatio: number | null + lastTerminalSendAt: string | null +} + +export interface SendgridSummaryData { + semantics: 'sendgrid_api_acceptance' + windows: SendgridWindowSummary[] +} + +export interface SendgridMessage { + id: string + timestamp: string | null + status: string + toMasked: string | null +} + +export interface SendgridMessagesData { + messages: SendgridMessage[] +} + +export interface DatabaseStorage { + meaning?: 'rds_allocation_usage' + allocatedBytes: number | null + freeBytes: number | null + usedBytes: number | null + usedRatio: number | null + maxAllocatedBytes?: number | null + logicalSizeBytes?: number | null + logicalSizeComplete?: boolean + sampledAt?: string | null +} + +export interface DatabaseConnections { + latest: number | null + average: number | null + maximum: number | null + sampledAt?: string | null +} + +export interface DatabaseEvent { + id?: string + timestamp: string | null + category?: string | null + categories?: string[] + message?: string + summary?: string + sourceType?: string | null + sourceIdentifier?: string | null +} + +export interface DatabaseEngineMessage { + id?: string + timestamp: string | null + severity: string + summary: string + cloudWatchUrl?: string | null +} + +export interface DatabaseSummary { + id: string + engine?: string + engineVersion?: string | null + status: string + storage: DatabaseStorage + connections: DatabaseConnections + events: DatabaseEvent[] + engineMessages: DatabaseEngineMessage[] + awsUrl?: string | null + consoleUrl?: string | null + logicalSizeComplete?: boolean + engineLogsComplete?: boolean +} + +export interface DatabaseSummaryData { + database: DatabaseSummary | null +} diff --git a/src/apps/status/src/lib/services/index.ts b/src/apps/status/src/lib/services/index.ts new file mode 100644 index 000000000..c1bbab2ad --- /dev/null +++ b/src/apps/status/src/lib/services/index.ts @@ -0,0 +1 @@ +export * from './status.service' diff --git a/src/apps/status/src/lib/services/status.service.spec.ts b/src/apps/status/src/lib/services/status.service.spec.ts new file mode 100644 index 000000000..6ff88991f --- /dev/null +++ b/src/apps/status/src/lib/services/status.service.spec.ts @@ -0,0 +1,92 @@ +/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */ +import { xhrGetAsync } from '~/libs/core' + +import { + getApiEndpoints, + getApiFailures, + getApiServices, + getDatabaseSummary, + getEcsClusters, + getEcsService, + getEcsTask, + getEcsTasks, + getSendgridMessages, + getSendgridSummary, + STATUS_API_BASE, +} from './status.service' + +jest.mock('~/config', () => ({ + EnvironmentConfig: { + API: { + V6: 'https://api.example.test/v6', + }, + }, +}), { virtual: true }) + +jest.mock('~/libs/core', () => ({ + xhrGetAsync: jest.fn() + .mockResolvedValue({ data: {}, meta: {} }), +}), { virtual: true }) + +const mockedGet = xhrGetAsync as jest.Mock + +describe('Status API service', () => { + beforeEach(() => { + mockedGet.mockClear() + }) + + it('uses the shared v6 status base and GET helper for every provider route', async () => { + await getEcsClusters() + await getEcsService('service/id') + await getEcsTask('task/id') + await getApiServices('1h') + await getApiEndpoints('api/id', '3h') + await getApiFailures('api/id', 'endpoint/id', '15m') + await getSendgridSummary() + await getSendgridMessages() + await getDatabaseSummary('24h') + + expect(STATUS_API_BASE) + .toBe('https://api.example.test/v6/status') + expect(mockedGet.mock.calls.map(call => call[0])) + .toEqual([ + `${STATUS_API_BASE}/ecs/clusters`, + `${STATUS_API_BASE}/ecs/services/service%2Fid`, + `${STATUS_API_BASE}/ecs/tasks/task%2Fid`, + `${STATUS_API_BASE}/api/services?window=1h`, + `${STATUS_API_BASE}/api/services/api%2Fid/endpoints?window=3h`, + `${STATUS_API_BASE}/api/services/api%2Fid/endpoints/endpoint%2Fid/failures?limit=50&window=15m`, + `${STATUS_API_BASE}/sendgrid/summary`, + `${STATUS_API_BASE}/sendgrid/messages?window=1h`, + `${STATUS_API_BASE}/database/summary?window=24h`, + ]) + }) + + it('encodes only allowlisted ECS task filters', async () => { + await getEcsTasks({ + clusterId: 'cluster one', + cursor: 'next/token', + limit: 50, + serviceId: 'service-one', + severity: 'critical', + status: 'STOPPED', + taskDefinition: 'email:42', + }) + + const requestedUrl: string = mockedGet.mock.calls[0][0] + expect(requestedUrl) + .toContain(`${STATUS_API_BASE}/ecs/tasks?`) + expect(requestedUrl) + .toContain('clusterId=cluster+one') + expect(requestedUrl) + .toContain('cursor=next%2Ftoken') + expect(requestedUrl) + .toContain('serviceId=service-one') + expect(requestedUrl) + .toContain('status=STOPPED') + expect(requestedUrl) + .toContain('taskDefinition=email%3A42') + expect(requestedUrl).not.toContain('arn=') + expect(requestedUrl).not.toContain('logGroup=') + }) +}) diff --git a/src/apps/status/src/lib/services/status.service.ts b/src/apps/status/src/lib/services/status.service.ts new file mode 100644 index 000000000..27022c3ce --- /dev/null +++ b/src/apps/status/src/lib/services/status.service.ts @@ -0,0 +1,196 @@ +/** + * Read-only HTTP client for status-api-v6. + */ +import { EnvironmentConfig } from '~/config' +import { xhrGetAsync } from '~/libs/core' + +import { + ApiEndpointsData, + ApiFailuresData, + ApiServicesData, + DatabaseSummaryData, + EcsClustersData, + EcsServiceData, + EcsTaskData, + EcsTasksData, + SendgridMessagesData, + SendgridSummaryData, + StatusEnvelope, + StatusSeverity, + StatusWindow, +} from '../models' + +export const STATUS_API_BASE = `${EnvironmentConfig.API.V6}/status` + +export interface EcsTaskQuery { + clusterId?: string + serviceId?: string + status?: string + severity?: StatusSeverity + taskDefinition?: string + cursor?: string + limit?: number +} + +/** + * Appends allowlisted query parameters to a Status API path. + * + * @param path server-owned route path. + * @param values allowlisted scalar query values. + * @returns absolute Status API URL with encoded values. + * @throws Does not throw. + */ +function withQuery( + path: string, + values: Record, +): string { + const params = new URLSearchParams() + Object.entries(values) + .forEach(([key, value]) => { + if (value !== undefined && value !== '') { + params.set(key, String(value)) + } + }) + const query = params.toString() + return `${STATUS_API_BASE}${path}${query ? `?${query}` : ''}` +} + +/** + * Encodes an opaque server-issued identifier as one URL segment. + * + * @param value opaque resource identifier. + * @returns safely encoded path segment. + * @throws Does not throw. + */ +function resourceId(value: string): string { + return encodeURIComponent(value) +} + +/** + * Fetches failure-first ECS cluster and service summaries for the ECS overview. + * + * @returns the catalogued cluster and service response envelope. + * @throws Does not throw synchronously; the returned promise rejects when the GET fails. + */ +export function getEcsClusters(): Promise> { + return xhrGetAsync(`${STATUS_API_BASE}/ecs/clusters`) +} + +/** + * Fetches a bounded page of ECS tasks using only supported filters. + * + * @param query allowlisted task filters and cursor. + * @returns the task page response. + * @throws Does not throw synchronously; the returned promise rejects when the GET fails. + */ +export function getEcsTasks(query: EcsTaskQuery): Promise> { + return xhrGetAsync(withQuery('/ecs/tasks', { + clusterId: query.clusterId, + cursor: query.cursor, + limit: query.limit, + serviceId: query.serviceId, + severity: query.severity, + status: query.status, + taskDefinition: query.taskDefinition, + })) +} + +/** + * Fetches one catalogued ECS service for an expanded service view. + * + * @param serviceId server-issued opaque service identifier. + * @returns the matching service response envelope. + * @throws Does not throw synchronously; the returned promise rejects when the GET fails. + */ +export function getEcsService(serviceId: string): Promise> { + return xhrGetAsync(`${STATUS_API_BASE}/ecs/services/${resourceId(serviceId)}`) +} + +/** + * Fetches one catalogued ECS task for the task failure drilldown. + * + * @param taskId server-issued opaque task identifier. + * @returns the matching sanitized task response envelope. + * @throws Does not throw synchronously; the returned promise rejects when the GET fails. + */ +export function getEcsTask(taskId: string): Promise> { + return xhrGetAsync(`${STATUS_API_BASE}/ecs/tasks/${resourceId(taskId)}`) +} + +/** + * Fetches Gateway and ALB service aggregates for the API overview. + * + * @param window supported aggregate time window. + * @returns API service aggregates and global summary metadata. + * @throws Does not throw synchronously; the returned promise rejects when the GET fails. + */ +export function getApiServices(window: StatusWindow): Promise> { + return xhrGetAsync(withQuery('/api/services', { window })) +} + +/** + * Fetches safe route-template aggregates for one API service drilldown. + * + * @param serviceId server-issued opaque API service identifier. + * @param window supported aggregate time window. + * @returns endpoint aggregate response envelope. + * @throws Does not throw synchronously; the returned promise rejects when the GET fails. + */ +export function getApiEndpoints( + serviceId: string, + window: StatusWindow, +): Promise> { + return xhrGetAsync(withQuery(`/api/services/${resourceId(serviceId)}/endpoints`, { window })) +} + +/** + * Fetches at most 50 sanitized failures for an endpoint drilldown. + * + * @param serviceId server-issued opaque API service identifier. + * @param endpointId server-issued opaque endpoint identifier. + * @param window supported failure time window. + * @returns bounded endpoint failure response envelope. + * @throws Does not throw synchronously; the returned promise rejects when the GET fails. + */ +export function getApiFailures( + serviceId: string, + endpointId: string, + window: StatusWindow, +): Promise> { + const path = `/api/services/${resourceId(serviceId)}` + + `/endpoints/${resourceId(endpointId)}/failures` + return xhrGetAsync(withQuery(path, { limit: 50, window })) +} + +/** + * Fetches all six fixed SendGrid acceptance windows for the SendGrid overview. + * + * @returns recipient-weighted logical-send acceptance aggregates. + * @throws Does not throw synchronously; the returned promise rejects when the GET fails. + */ +export function getSendgridSummary(): Promise> { + return xhrGetAsync(`${STATUS_API_BASE}/sendgrid/summary`) +} + +/** + * Fetches the server-bounded first 50 sanitized provider activity records for the last hour. + * + * @returns the provider activity envelope; the promise rejects when the read request fails. + * @throws Does not throw synchronously; the returned promise rejects when the GET fails. + */ +export function getSendgridMessages(): Promise> { + return xhrGetAsync(withQuery('/sendgrid/messages', { window: '1h' })) +} + +/** + * Fetches RDS storage, connection, event, and engine-log status for the database page. + * + * @param window supported telemetry and event time window. + * @returns database telemetry response envelope. + * @throws Does not throw synchronously; the returned promise rejects when the GET fails. + */ +export function getDatabaseSummary( + window: StatusWindow, +): Promise> { + return xhrGetAsync(withQuery('/database/summary', { window })) +} diff --git a/src/apps/status/src/lib/styles/index.scss b/src/apps/status/src/lib/styles/index.scss new file mode 100644 index 000000000..bd4e4f1b6 --- /dev/null +++ b/src/apps/status/src/lib/styles/index.scss @@ -0,0 +1,5 @@ +@import '@libs/ui/styles/includes'; + +body.status-app { + background: #fafafa; +} diff --git a/src/apps/status/src/lib/utils/index.ts b/src/apps/status/src/lib/utils/index.ts new file mode 100644 index 000000000..26b821095 --- /dev/null +++ b/src/apps/status/src/lib/utils/index.ts @@ -0,0 +1 @@ +export * from './status.utils' diff --git a/src/apps/status/src/lib/utils/status.utils.spec.ts b/src/apps/status/src/lib/utils/status.utils.spec.ts new file mode 100644 index 000000000..475fe4d97 --- /dev/null +++ b/src/apps/status/src/lib/utils/status.utils.spec.ts @@ -0,0 +1,33 @@ +/* eslint-disable import/no-extraneous-dependencies, unicorn/no-null */ +import { + formatBytes, + formatLatency, + formatRatio, + sortBySeverity, +} from './status.utils' + +describe('Status display utilities', () => { + it('keeps critical and warning rows above healthy changes and stable rows', () => { + const rows = [ + { id: 'healthy', severity: 'healthy' as const }, + { id: 'change', severity: 'healthy-change' as const }, + { id: 'critical', severity: 'critical' as const }, + { id: 'warning', severity: 'warning' as const }, + ] + + expect(sortBySeverity(rows, row => row.severity) + .map(row => row.id)) + .toEqual(['critical', 'warning', 'change', 'healthy']) + }) + + it('preserves unavailable ratios, latency, and storage as unknown', () => { + expect(formatRatio(null)) + .toBe('—') + expect(formatLatency(undefined)) + .toBe('—') + expect(formatBytes(null)) + .toBe('—') + expect(formatRatio(0)) + .toBe('0.0%') + }) +}) diff --git a/src/apps/status/src/lib/utils/status.utils.ts b/src/apps/status/src/lib/utils/status.utils.ts new file mode 100644 index 000000000..c5a57da7d --- /dev/null +++ b/src/apps/status/src/lib/utils/status.utils.ts @@ -0,0 +1,116 @@ +/** + * Pure formatting and ordering helpers for operational Status views. + */ +import { StatusSeverity } from '../models' + +const SEVERITY_ORDER: Record = { + critical: 0, + healthy: 4, + 'healthy-change': 3, + unknown: 2, + warning: 1, +} + +/** + * Sorts rows by canonical severity before applying an optional secondary sort. + * + * @param rows source rows to copy and order. + * @param severity extracts the server-issued severity. + * @param secondary orders rows only within one severity group. + * @returns a sorted copy that can never put healthy rows above critical rows. + * @throws Does not throw. + */ +export function sortBySeverity( + rows: readonly T[], + severity: (row: T) => StatusSeverity, + secondary?: (left: T, right: T) => number, +): T[] { + return [...rows].sort((left, right) => { + const severityDifference = SEVERITY_ORDER[severity(left)] + - SEVERITY_ORDER[severity(right)] + return severityDifference || secondary?.(left, right) || 0 + }) +} + +/** + * Formats an API ratio as a percentage without manufacturing a zero value. + * + * @param ratio decimal ratio returned by the API, or null when unavailable. + * @param digits number of fraction digits to show. + * @returns a percentage string or an em dash for unavailable values. + * @throws Does not throw. + */ +export function formatRatio(ratio: number | null | undefined, digits: number = 1): string { + if (ratio === null || ratio === undefined || !Number.isFinite(ratio)) { + return '—' + } + + return `${(ratio * 100).toFixed(digits)}%` +} + +/** + * Formats a millisecond metric while retaining unavailable state. + * + * @param value millisecond value or null. + * @returns a localized millisecond label or an em dash. + * @throws Does not throw. + */ +export function formatLatency(value: number | null | undefined): string { + return value === null || value === undefined || !Number.isFinite(value) + ? '—' + : `${Math.round(value) + .toLocaleString()} ms` +} + +/** + * Formats bytes using binary units for RDS storage values. + * + * @param bytes byte count or null when unavailable. + * @returns compact binary-unit label or an em dash. + * @throws Does not throw. + */ +export function formatBytes(bytes: number | null | undefined): string { + if (bytes === null || bytes === undefined || !Number.isFinite(bytes)) { + return '—' + } + + if (bytes === 0) { + return '0 B' + } + + const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB'] + const unitIndex = Math.min( + Math.floor(Math.log(Math.abs(bytes)) / Math.log(1024)), + units.length - 1, + ) + const normalized = bytes / (1024 ** unitIndex) + return `${normalized.toFixed(unitIndex > 2 ? 1 : 0)} ${units[unitIndex]}` +} + +/** + * Formats an ISO timestamp in the administrator's locale. + * + * @param value ISO timestamp or null. + * @returns localized date/time or an em dash for missing/invalid input. + * @throws Does not throw. + */ +export function formatTimestamp(value: string | null | undefined): string { + if (!value) { + return '—' + } + + const date = new Date(value) + return Number.isNaN(date.getTime()) ? '—' : date.toLocaleString() +} + +/** + * Joins safe server-issued reasons for compact table display. + * + * @param reasons severity or failure reason strings. + * @returns visible reason text, falling back to a neutral explanation. + * @throws Does not throw. + */ +export function formatReasons(reasons: string[] | undefined): string { + return reasons?.filter(Boolean) + .join('; ') || 'No current issue reported' +} diff --git a/src/apps/status/src/pages/StatusPages.module.scss b/src/apps/status/src/pages/StatusPages.module.scss new file mode 100644 index 000000000..f6b163a89 --- /dev/null +++ b/src/apps/status/src/pages/StatusPages.module.scss @@ -0,0 +1,256 @@ +@import '@libs/ui/styles/includes'; + +.metricGrid { + display: grid; + gap: $sp-4; + grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); + margin: $sp-5 0; +} + +.filters { + align-items: end; + background: #f7f8f8; + border-bottom: 1px solid #d7dadd; + display: grid; + gap: $sp-3; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + padding: $sp-4 $sp-5; + + label { + color: #444a4f; + display: flex; + flex-direction: column; + font-size: 12px; + font-weight: 700; + gap: 4px; + } + + input, + select { + background: #fff; + border: 1px solid #a9afb4; + border-radius: 4px; + box-sizing: border-box; + color: #24272a; + font: inherit; + font-size: 14px; + min-height: 38px; + padding: 8px 10px; + width: 100%; + } +} + +.checkboxLabel { + align-items: center !important; + flex-direction: row !important; + min-height: 38px; + + input { + min-height: auto; + width: auto; + } +} + +.primaryText { + color: #24272a; + display: block; + font-weight: 700; +} + +.secondaryText, +.reason, +.sourceLabel { + color: #5d6368; + display: block; + font-size: 12px; + line-height: 17px; + margin-top: 3px; +} + +.criticalReason { + color: #8c1520; + display: block; + font-size: 12px; + font-weight: 700; + line-height: 17px; + margin-top: $sp-2; +} + +.warningReason { + color: #704d00; +} + +.counts { + white-space: nowrap; +} + +.inlineActions { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: $sp-3; +} + +.linkButton, +.disclosureButton, +.loadMoreButton { + background: #fff; + border: 1px solid #087d8b; + border-radius: 4px; + color: #087d8b; + cursor: pointer; + font-size: 13px; + font-weight: 700; + padding: 7px 10px; +} + +.taskInventory, +.detailPanel { + background: #f8fafb; + border: 1px solid #d7dadd; + border-radius: 5px; + margin: $sp-2 0; + padding: $sp-4; + + h3 { + font-size: 16px; + margin: 0 0 $sp-3; + } +} + +.detailGrid { + display: grid; + gap: $sp-3 $sp-6; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + margin: $sp-3 0; + + dt { + color: #5d6368; + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + } + + dd { + margin: 3px 0 0; + overflow-wrap: anywhere; + } +} + +.coverage { + align-items: center; + background: #eef8f9; + border: 1px solid #98cdd2; + border-radius: 5px; + display: flex; + flex-wrap: wrap; + gap: $sp-5; + margin: $sp-4 0; + padding: $sp-3 $sp-4; +} + +.coverageIncomplete { + background: #fff8e5; + border-color: #e4b84b; +} + +.method { + background: #e7f5f6; + border-radius: 3px; + color: #086a75; + display: inline-block; + font-family: monospace; + font-size: 12px; + font-weight: 700; + margin-right: $sp-2; + padding: 2px 5px; +} + +.routeTemplate, +.requestId { + font-family: monospace; + overflow-wrap: anywhere; +} + +.sendgridIntro, +.storageExplanation { + background: #eef8f9; + border-left: 4px solid #087d8b; + line-height: 22px; + margin: $sp-4 0; + padding: $sp-3 $sp-4; +} + +.activityDisclosure { + padding: $sp-5; +} + +.activityHeader { + align-items: center; + display: flex; + gap: $sp-4; + justify-content: space-between; + + h2 { + font-size: 18px; + margin: 0; + } +} + +.messageList, +.eventList { + list-style: none; + margin: $sp-4 0 0; + padding: 0; + + li { + border-top: 1px solid #e3e5e6; + display: grid; + gap: $sp-3; + grid-template-columns: minmax(155px, 0.7fr) minmax(130px, 0.7fr) minmax(200px, 1.6fr); + padding: $sp-3 0; + } +} + +.statusValue { + font-weight: 700; + text-transform: capitalize; +} + +.logicalUnknown { + color: #704d00; + font-weight: 700; +} + +.engineMessage { + border-left: 4px solid #b47700; + padding-left: $sp-3 !important; +} + +.loadMoreWrap { + display: flex; + justify-content: center; + padding: $sp-4; +} + +@include ltemd { + .filters { + grid-template-columns: 1fr; + padding: $sp-4; + } + + .messageList li, + .eventList li { + display: block; + + > * { + display: block; + margin: 3px 0; + } + } + + .activityHeader { + align-items: stretch; + flex-direction: column; + } +} diff --git a/src/apps/status/src/pages/api/ApiEndpointsPage.tsx b/src/apps/status/src/pages/api/ApiEndpointsPage.tsx new file mode 100644 index 000000000..ce147c643 --- /dev/null +++ b/src/apps/status/src/pages/api/ApiEndpointsPage.tsx @@ -0,0 +1,217 @@ +/* eslint-disable complexity, ordered-imports/ordered-imports, react/jsx-no-bind */ +/** + * Route-template endpoint aggregates for one catalogued API service. + */ +import { FC, useMemo, useState } from 'react' +import { useNavigate, useParams } from 'react-router-dom' + +import { + CompleteEmptyState, + DataFreshness, + IncompleteDataNotice, + RefreshButton, + RetryableErrorState, + StatusColumn, + StatusLoading, + StatusPage, + StatusPanel, + StatusTable, + TimeWindowSelect, +} from '../../lib/components' +import { apiRouteId, buildStatusPath } from '../../config/routes.config' +import { useApiEndpointStatus } from '../../lib/hooks' +import { ApiEndpointSummary, StatusWindow } from '../../lib/models' +import { formatLatency, formatRatio } from '../../lib/utils' + +import styles from '../StatusPages.module.scss' + +/** + * Renders endpoint telemetry grouped only by safe method and route template. + * + * @returns routed service endpoint page. + * @throws Does not throw; request failures render in the page state. + */ +export const ApiEndpointsPage: FC = () => { + const serviceId: string | undefined = useParams<{ serviceId: string }>().serviceId + const [window, setWindow] = useState('1h') + const resource = useApiEndpointStatus(serviceId, window) + const navigate = useNavigate() + const data = resource.data?.data + const coverageComplete = Boolean(data?.coverage.complete ?? ( + data && data.coverage.unattributedEdgeFailures === 0 && resource.data?.meta.complete + )) + const coverageAvailable = Boolean(data) && !resource.data?.meta.warnings.some(warning => ( + warning.code === 'ENDPOINT_TELEMETRY_UNAVAILABLE' + || warning.code.startsWith('QUERY_') + )) + const columns = useMemo[]>(() => [ + { + id: 'endpoint', + label: 'Endpoint', + render: endpoint => ( + <> + {endpoint.method} + {endpoint.routeTemplate} + + ), + }, + { + id: 'requests', + label: 'Requests', + render: endpoint => (endpoint.dataComplete ? endpoint.requests.toLocaleString() : '—'), + }, + { + id: 'responses', + label: '2xx / 4xx / 5xx', + render: endpoint => ( + <> + {formatRatio(endpoint.dataComplete ? endpoint.responseRatios.success : undefined)} + {' '} + / + {' '} + {formatRatio(endpoint.dataComplete ? endpoint.responseRatios.clientError : undefined)} + {' '} + / + {' '} + {formatRatio(endpoint.dataComplete ? endpoint.responseRatios.serverError : undefined)} + + ), + }, + { + id: 'responseLatency', + label: 'Response p50 / p95 / p99', + render: endpoint => ( + <> + {formatLatency(endpoint.dataComplete ? endpoint.latencyMs.response.p50 : undefined)} + {' '} + / + {' '} + {formatLatency(endpoint.dataComplete ? endpoint.latencyMs.response.p95 : undefined)} + {' '} + / + {' '} + {formatLatency(endpoint.dataComplete ? endpoint.latencyMs.response.p99 : undefined)} + Gateway/application response + + ), + }, + { + id: 'integrationLatency', + label: 'Integration p50 / p95 / p99', + render: endpoint => ( + <> + {formatLatency(endpoint.dataComplete ? endpoint.latencyMs.integration.p50 : undefined)} + {' '} + / + {' '} + {formatLatency(endpoint.dataComplete ? endpoint.latencyMs.integration.p95 : undefined)} + {' '} + / + {' '} + {formatLatency(endpoint.dataComplete ? endpoint.latencyMs.integration.p99 : undefined)} + Gateway integration + + ), + }, + { + id: 'failures', + label: 'Recent failures', + render: endpoint => (endpoint.dataComplete + ? endpoint.recentFailureCount + ?? endpoint.responseCounts.clientError + endpoint.responseCounts.serverError + : '—'), + }, + ], []) + + return ( + + + + + )} + backLabel='Back to API services' + backTo={buildStatusPath(apiRouteId)} + description={'Stable route-template aggregates; raw identifier-bearing request paths ' + + 'are never used as endpoint identities.'} + title={data ? `${data.service.name} endpoints` : 'API endpoints'} + > + {resource.error && ( + + )} + {resource.loading && !resource.data && } + {resource.data && data && ( + <> + + +
+ Endpoint attribution coverage + + {coverageAvailable + ? data.coverage.attributedRequests.toLocaleString() + : '—'} + {' '} + attributed requests + + + {coverageAvailable + ? data.coverage.unattributedEdgeFailures.toLocaleString() + : '—'} + {' '} + EDGE_UNATTRIBUTED failures + + {coverageComplete ? 'Complete' : 'Incomplete'} +
+ + {data.endpoints.length > 0 + ? ( + endpoint.id} + getRowLabel={endpoint => ( + `View recent failures for ${endpoint.method} ${endpoint.routeTemplate}` + )} + onRowClick={endpoint => navigate(buildStatusPath( + apiRouteId, + data.service.id, + 'endpoints', + endpoint.id, + ))} + rows={data.endpoints} + /> + ) + : ( + + {resource.data.meta.complete && coverageComplete + ? 'No endpoint requests were recorded in this complete window.' + : 'No endpoint rows are available from the incomplete telemetry source.'} + + )} + + + )} +
+ ) +} + +export default ApiEndpointsPage diff --git a/src/apps/status/src/pages/api/ApiFailuresPage.tsx b/src/apps/status/src/pages/api/ApiFailuresPage.tsx new file mode 100644 index 000000000..b4f1b33be --- /dev/null +++ b/src/apps/status/src/pages/api/ApiFailuresPage.tsx @@ -0,0 +1,177 @@ +/* eslint-disable ordered-imports/ordered-imports, react/jsx-no-bind */ +/** + * Bounded, sanitized failure records for one safe API endpoint identity. + */ +import { FC, useMemo, useState } from 'react' +import { useParams } from 'react-router-dom' + +import { + CompleteEmptyState, + DataFreshness, + ExternalAwsLink, + IncompleteDataNotice, + RefreshButton, + RetryableErrorState, + StatusColumn, + StatusLoading, + StatusPage, + StatusPanel, + StatusTable, + TimeWindowSelect, +} from '../../lib/components' +import { apiRouteId, buildStatusPath } from '../../config/routes.config' +import { useApiFailures } from '../../lib/hooks' +import { ApiFailureRecord, StatusWindow } from '../../lib/models' +import { formatLatency, formatTimestamp } from '../../lib/utils' + +import styles from '../StatusPages.module.scss' + +/** + * Renders only allowlisted plain-text failure fields returned by status-api-v6. + * + * @returns routed endpoint failure page. + * @throws Does not throw; request failures render in the page state. + */ +export const ApiFailuresPage: FC = () => { + const params = useParams<{ + endpointId: string + serviceId: string + }>() + const endpointId: string | undefined = params.endpointId + const serviceId: string | undefined = params.serviceId + const [window, setWindow] = useState('1h') + const resource = useApiFailures(serviceId, endpointId, window) + const data = resource.data?.data + const columns = useMemo[]>(() => [ + { + id: 'time', + label: 'Time / request ID', + render: failure => ( + <> + {formatTimestamp(failure.timestamp)} + {failure.requestId} + + ), + }, + { + id: 'endpoint', + label: 'Endpoint', + render: failure => ( + <> + {failure.method} + {failure.routeTemplate} + + ), + }, + { + id: 'status', + label: 'HTTP status', + render: failure => ( + <> + {failure.statusCode} + {failure.responseClass} + + ), + }, + { + id: 'reason', + label: 'Safe reason', + render: failure => ( + <> + + {failure.errorCode || failure.errorType || 'Unclassified failure'} + + + {failure.errorSummary || 'No safe summary is available.'} + + + ), + }, + { + id: 'latency', + label: 'Response / integration', + render: failure => ( + <> + {formatLatency(failure.responseLatencyMs)} + Response + {formatLatency(failure.integrationLatencyMs)} + Integration/application + + ), + }, + { + id: 'logs', + label: 'Logs', + render: failure => ( + + CloudWatch + + ), + }, + ], []) + + const endpointBackPath = serviceId + ? buildStatusPath(apiRouteId, serviceId) + : buildStatusPath(apiRouteId) + const title = data + ? `${data.endpoint.method} ${data.endpoint.routeTemplate} failures` + : 'Endpoint failures' + + return ( + + + + + )} + backLabel='Back to endpoints' + backTo={endpointBackPath} + description={'Bounded safe failure details. Bodies, headers, query strings, source IPs, ' + + 'agents, and stack traces are never rendered.'} + title={title} + > + {resource.error && ( + + )} + {resource.loading && !resource.data && } + {resource.data && data && ( + <> + + + + {data.failures.length > 0 + ? ( + `${failure.timestamp}:${failure.requestId}`} + rows={data.failures} + /> + ) + : ( + + {resource.data.meta.complete + ? 'No failures were recorded in this complete window.' + : 'No failure rows are available from the incomplete telemetry source.'} + + )} + + + )} + + ) +} + +export default ApiFailuresPage diff --git a/src/apps/status/src/pages/api/ApiStatusPage.tsx b/src/apps/status/src/pages/api/ApiStatusPage.tsx new file mode 100644 index 000000000..4c0080699 --- /dev/null +++ b/src/apps/status/src/pages/api/ApiStatusPage.tsx @@ -0,0 +1,302 @@ +/* eslint-disable complexity, ordered-imports/ordered-imports, react/jsx-no-bind */ +/** + * Gateway and ALB API overview with catalogued service drill-down navigation. + */ +import { FC, useMemo, useState } from 'react' +import { useNavigate } from 'react-router-dom' + +import { + CompleteEmptyState, + DataFreshness, + IncompleteDataNotice, + MetricCard, + RefreshButton, + RetryableErrorState, + StatusColumn, + StatusLoading, + StatusPage, + StatusPanel, + StatusTable, + TimeWindowSelect, +} from '../../lib/components' +import { apiRouteId, buildStatusPath } from '../../config/routes.config' +import { useApiStatus } from '../../lib/hooks' +import { ApiServiceSummary, StatusWindow } from '../../lib/models' +import { formatLatency, formatRatio } from '../../lib/utils' + +import styles from '../StatusPages.module.scss' + +/** + * Renders API response classes, latency percentiles, target health, and services. + * + * @returns active API overview page. + * @throws Does not throw; request failures render in the page state. + */ +export const ApiStatusPage: FC = () => { + const [window, setWindow] = useState('1h') + const resource = useApiStatus(window) + const navigate = useNavigate() + const services = resource.data?.data.services ?? [] + const summary = resource.data?.data.summary + const summaryAvailable: boolean = summary?.dataComplete === true + const targetHealthAvailable: boolean = Boolean(summary) + && !resource.data?.meta.warnings.some(warning => ( + warning.code === 'ALB_TARGET_HEALTH_UNAVAILABLE' + || warning.code === 'API_TELEMETRY_UNAVAILABLE' + )) + + const columns = useMemo[]>(() => [ + { + id: 'service', + label: 'Service', + render: service => ( + <> + {service.name} + {service.id} + + ), + }, + { + id: 'requests', + label: 'Requests', + render: service => (service.dataComplete ? service.requests.toLocaleString() : '—'), + }, + { + id: 'responses', + label: '2xx / 3xx / 4xx / 5xx', + render: service => ( + <> + + {formatRatio(service.dataComplete ? service.responseRatios.success : undefined)} + {' '} + / + {' '} + {formatRatio(service.dataComplete ? service.responseRatios.redirect : undefined)} + {' '} + / + {' '} + {formatRatio(service.dataComplete ? service.responseRatios.clientError : undefined)} + {' '} + / + {' '} + {formatRatio(service.dataComplete ? service.responseRatios.serverError : undefined)} + + + Counts: + {' '} + {service.dataComplete ? service.responseCounts.success : '—'} + {' '} + / + {' '} + {service.dataComplete ? service.responseCounts.redirect : '—'} + {' / '} + {service.dataComplete ? service.responseCounts.clientError : '—'} + {' '} + / + {' '} + {service.dataComplete ? service.responseCounts.serverError : '—'} + + + ), + }, + { + id: 'latency', + label: 'Response p50 / p95 / p99', + render: service => ( + <> + + {formatLatency(service.dataComplete ? service.latencyMs.response.p50 : undefined)} + {' '} + / + {' '} + + + {formatLatency(service.dataComplete ? service.latencyMs.response.p95 : undefined)} + {' '} + / + {' '} + + + {formatLatency(service.dataComplete ? service.latencyMs.response.p99 : undefined)} + + Gateway response latency + + ), + }, + { + id: 'integration', + label: 'Integration p50 / p95 / p99', + render: service => ( + <> + + {formatLatency(service.dataComplete ? service.latencyMs.integration.p50 : undefined)} + {' '} + / + {' '} + + + {formatLatency(service.dataComplete ? service.latencyMs.integration.p95 : undefined)} + {' '} + / + {' '} + + + {formatLatency(service.dataComplete ? service.latencyMs.integration.p99 : undefined)} + + Gateway integration latency + + ), + }, + { + id: 'targets', + label: 'Targets healthy / unhealthy', + render: service => (service.targetHealth.unknown + ? 'Unknown' + : ( + + {service.targetHealth.healthy ?? '—'} + {' '} + / + {service.targetHealth.unhealthy ?? '—'} + + )), + }, + { + id: 'coverage', + label: 'Coverage', + render: service => (service.dataComplete === false ? 'Incomplete' : 'Complete'), + }, + ], []) + + return ( + + + + + )} + description='End-to-end Gateway request classes, ALB target health, and response latency by service.' + title='API status' + > + {resource.error && ( + + )} + {resource.loading && !resource.data && } + {resource.data && ( + <> + + +
+ + + + 0 + ? 'critical' + : undefined} + value={formatRatio(summaryAvailable + ? summary?.responseRatios.serverError + : undefined)} + /> + + + +
+ {!summaryAvailable && ( +
+ Overview aggregate is unavailable; service percentiles are not combined + into a false global value. +
+ )} + {!targetHealthAvailable && ( +
+ ALB target health is unknown for one or more services; zero is not assumed. +
+ )} + + {services.length > 0 + ? ( + service.id} + getRowLabel={service => `View endpoints for ${service.name}`} + onRowClick={service => navigate(buildStatusPath(apiRouteId, service.id))} + rows={services} + /> + ) + : ( + + {resource.data.meta.complete + ? 'No API requests were recorded in this complete window.' + : 'No API service aggregates are available from the incomplete source.'} + + )} + + + )} +
+ ) +} + +export default ApiStatusPage diff --git a/src/apps/status/src/pages/database/DatabaseStatusPage.tsx b/src/apps/status/src/pages/database/DatabaseStatusPage.tsx new file mode 100644 index 000000000..8e2332cc3 --- /dev/null +++ b/src/apps/status/src/pages/database/DatabaseStatusPage.tsx @@ -0,0 +1,233 @@ +/* eslint-disable complexity, ordered-imports/ordered-imports, react/jsx-no-bind */ +/** + * RDS infrastructure storage, connections, events, and sanitized engine logs. + */ +import { FC, useState } from 'react' + +import { + CompleteEmptyState, + DataFreshness, + ExternalAwsLink, + IncompleteDataNotice, + MetricCard, + RefreshButton, + RetryableErrorState, + StatusLoading, + StatusPage, + StatusPanel, + TimeWindowSelect, +} from '../../lib/components' +import { useDatabaseStatus } from '../../lib/hooks' +import { StatusWindow } from '../../lib/models' +import { + formatBytes, + formatRatio, + formatTimestamp, +} from '../../lib/utils' + +import styles from '../StatusPages.module.scss' + +/** + * Renders database status without direct database credentials or SQL queries. + * + * @returns active Database Status page. + * @throws Does not throw; request failures render in the page state. + */ +export const DatabaseStatusPage: FC = () => { + const [window, setWindow] = useState('1h') + const resource = useDatabaseStatus(window) + const database = resource.data?.data.database + const warningCodes: string[] = resource.data?.meta.warnings + .map(warning => warning.code) ?? [] + const engineLogsComplete: boolean = database?.engineLogsComplete + ?? !warningCodes.includes('RDS_ENGINE_TELEMETRY_UNAVAILABLE') + const logicalSizeComplete: boolean = database?.storage.logicalSizeComplete + ?? (database?.storage.logicalSizeBytes !== null + && database?.storage.logicalSizeBytes !== undefined + && !warningCodes.includes('DATABASE_SIZE_INTERPRETATION_PENDING')) + + return ( + + + + + )} + description={'Read-only RDS control-plane and sanitized telemetry for the ' + + 'topcoder-services PostgreSQL instance.'} + title='Database status' + > + {resource.error && ( + + )} + {resource.loading && !resource.data && } + {resource.data && !database && ( + <> + + + + The database monitoring source is not configured. Values remain unknown. + + + )} + {resource.data && database && ( + <> + + +
+ + + + + + + + +
+

+ RDS storage used + {' '} + is allocated storage minus the latest non-stale + CloudWatch FreeStorageSpace sample. It is not a logical + {' '} + pg_database_size + {' '} + query. Exact logical size appears separately only + when the approved aggregate exporter is configured. +

+
+ + Open RDS in AWS + +
+ + {database.events.length > 0 + ? ( +
    + {database.events.map(event => ( +
  • + + + {event.categories?.join(', ') + || event.category + || event.sourceType + || 'RDS event'} + + + {event.summary + || event.message + || 'No safe summary available'} + +
  • + ))} +
+ ) + : ( + + No RDS infrastructure events were returned for the selected window. + + )} +
+ + {!engineLogsComplete && ( +
+ Engine-log coverage is incomplete until sanitized PostgreSQL log + export is enabled. +
+ )} + {database.engineMessages.length > 0 + ? ( +
    + {database.engineMessages.map(message => ( +
  • + + {message.severity} + + {message.summary} + {' '} + + CloudWatch + + +
  • + ))} +
+ ) + : ( + + {!engineLogsComplete + ? 'Engine warnings and errors are unknown because ' + + 'the source is incomplete.' + : 'No PostgreSQL warnings or errors were recorded in this complete window.'} + + )} +
+ + )} +
+ ) +} + +export default DatabaseStatusPage diff --git a/src/apps/status/src/pages/ecs/EcsStatusPage.tsx b/src/apps/status/src/pages/ecs/EcsStatusPage.tsx new file mode 100644 index 000000000..8606c99da --- /dev/null +++ b/src/apps/status/src/pages/ecs/EcsStatusPage.tsx @@ -0,0 +1,727 @@ +/* eslint-disable ordered-imports/ordered-imports, react/jsx-no-bind */ +/* eslint-disable react/no-unstable-nested-components, unicorn/no-null */ +/** + * Failure-first ECS service overview with lazy task inventory and task details. + */ +import { + FC, + MouseEvent, + useEffect, + useMemo, + useState, +} from 'react' + +import { + CompleteEmptyState, + DataFreshness, + ExternalAwsLink, + HealthBadge, + IncompleteDataNotice, + RefreshButton, + RetryableErrorState, + StatusColumn, + StatusLoading, + StatusPage, + StatusPanel, + StatusTable, +} from '../../lib/components' +import { useEcsStatus, useEcsTaskDetail, useEcsTasks } from '../../lib/hooks' +import { + EcsServiceSummary, + EcsTaskSummary, + StatusSeverity, +} from '../../lib/models' +import { + formatReasons, + formatTimestamp, + sortBySeverity, +} from '../../lib/utils' + +import styles from '../StatusPages.module.scss' + +interface ServiceRow extends EcsServiceSummary { + clusterName: string +} + +interface EcsFilters { + search: string + clusterId: string + severity: '' | StatusSeverity + taskStatus: string + taskDefinition: string + issuesOnly: boolean +} + +const INITIAL_FILTERS: EcsFilters = { + clusterId: '', + issuesOnly: false, + search: '', + severity: '', + taskDefinition: '', + taskStatus: '', +} + +/** + * Builds the non-color task status label announced by the table row. + * + * @param task task row to describe for assistive technology. + * @returns severity, identity, and safe reasons in one label. + * @throws Does not throw. + */ +function getTaskRowLabel(task: EcsTaskSummary): string { + const taskId = task.opaqueTaskId || task.id + return `${task.severity} task ${taskId}: ${formatReasons(task.severityReasons)}` +} + +/** + * Builds the non-color service status label announced by the table row. + * + * @param service service row to describe for assistive technology. + * @returns severity, service name, and safe reasons in one label. + * @throws Does not throw. + */ +function getServiceRowLabel(service: ServiceRow): string { + return `${service.severity} service ${service.name}: ${formatReasons(service.severityReasons)}` +} + +/** + * Formats the API's generic exit interpretation for task failure display. + * + * @param interpretation structured or legacy generic interpretation. + * @returns safe summary text, or an explicit unknown message. + * @throws Does not throw. + */ +function getExitInterpretation( + interpretation: EcsTaskSummary['containers'][number]['exitInterpretation'], +): string { + if (!interpretation) { + return 'No generic interpretation is available.' + } + + return typeof interpretation === 'string' ? interpretation : interpretation.summary +} + +/** + * Formats a nullable task-definition identity without manufacturing a revision. + * + * @param taskDefinition task-owned definition identity. + * @returns family/revision or an explicit unknown label. + * @throws Does not throw. + */ +function getTaskDefinitionLabel(taskDefinition: EcsTaskSummary['taskDefinition']): string { + return taskDefinition + ? `${taskDefinition.family}:${taskDefinition.revision}` + : 'Unknown task definition' +} + +/** + * Deduplicates cursor pages by opaque task ID while preserving insertion order. + * + * @param tasks combined task pages. + * @returns one current row per task ID. + * @throws Does not throw. + */ +function deduplicateTasks(tasks: EcsTaskSummary[]): EcsTaskSummary[] { + const tasksById = tasks.reduce>((result, task) => { + result[task.id] = task + return result + }, {}) + return Object.keys(tasksById) + .map(taskId => tasksById[taskId]) +} + +/** + * Renders a task's server-sanitized failure details and links. + * + * @param props selected task ID. + * @returns task detail request state and content. + * @throws Does not throw; request failures render in the component state. + */ +const TaskDetailPanel: FC<{ taskId: string }> = props => { + const resource = useEcsTaskDetail(props.taskId) + if (resource.loading) { + return + } + + if (resource.error && !resource.data) { + return + } + + const task = resource.data?.data.task + if (!task || !resource.data) { + return null + } + + return ( +
+

Task failure detail

+ + +
+
+
Task ID
+
{task.opaqueTaskId || task.id}
+
+
+
ECS stop code
+
{task.stopCode || 'Not reported'}
+
+
+
Sanitized reason
+
{task.stoppedReason || 'No safe reason available'}
+
+
+
Stopped
+
{formatTimestamp(task.stoppedAt)}
+
+
+
Task definition
+
{getTaskDefinitionLabel(task.taskDefinition)}
+
+
+ {task.containers.map(container => ( +
+
+
Container
+
{container.name}
+
+
+
Container state
+
{container.lastStatus}
+
+
+
Exit code
+
{container.exitCode ?? 'Not reported'}
+
+
+
Generic interpretation
+
{getExitInterpretation(container.exitInterpretation)}
+
+
+ ))} +
+ Task definition + CloudWatch logs +
+
+ ) +} + +/** + * Loads and renders the task inventory for one expanded ECS service. + * + * @param props service, task-status filter, and task-definition filter. + * @returns lazy task table with cursor pagination. + * @throws Does not throw; request failures render in the component state. + */ +const ServiceTaskInventory: FC<{ + service: ServiceRow + taskStatus: string + taskDefinition: string +}> = props => { + const [cursor, setCursor] = useState() + const [tasks, setTasks] = useState([]) + const [selectedTaskId, setSelectedTaskId] = useState() + const query = useMemo(() => ({ + clusterId: props.service.clusterId, + cursor, + limit: 50, + serviceId: props.service.id, + status: props.taskStatus || undefined, + taskDefinition: props.taskDefinition || undefined, + }), [cursor, props.service.clusterId, props.service.id, props.taskDefinition, props.taskStatus]) + const resource = useEcsTasks(query, true) + + useEffect(() => { + setCursor(undefined) + setTasks([]) + setSelectedTaskId(undefined) + }, [props.service.id, props.taskDefinition, props.taskStatus]) + + useEffect(() => { + const page = resource.data?.data.tasks + if (!page) { + return + } + + setTasks(current => { + const merged = cursor ? [...current, ...page] : page + return deduplicateTasks(merged) + }) + }, [cursor, resource.data]) + + const taskColumns = useMemo[]>(() => [ + { + id: 'health', + label: 'Health', + render: task => ( + <> + + {formatReasons(task.severityReasons)} + + ), + }, + { + id: 'task', + label: 'Task', + render: task => ( + <> + {task.opaqueTaskId || task.id} + {task.launchType || 'Launch type unknown'} + Task in AWS + + ), + }, + { + id: 'state', + label: 'State / deployment', + render: task => ( + <> + {task.lastStatus} + + Desired: + {' '} + {task.desiredStatus || 'unknown'} + + + Health: + {' '} + {task.healthStatus || 'unknown'} + + + Deployment: + {' '} + {task.deploymentId || 'not reported'} + + + ), + }, + { + id: 'time', + label: 'Launch / stop time', + render: task => formatTimestamp(task.stoppedAt || task.launchedAt || task.startedAt), + }, + { + id: 'definition', + label: 'Task definition', + render: task => ( + <> + + {getTaskDefinitionLabel(task.taskDefinition)} + + AWS + + ), + }, + { + id: 'container', + label: 'Container / exit', + render: task => (task.containers.length > 0 + ? task.containers.map(container => ( + + {container.name} + : + {container.lastStatus} + , exit + {' '} + {container.exitCode ?? '—'} + + )) + : '—'), + }, + ], []) + + return ( +
+

+ Task inventory for + {' '} + {props.service.name} +

+ {resource.error && ( + 0} + onRetry={resource.refresh} + /> + )} + {resource.loading && tasks.length === 0 && } + {resource.data && ( + <> + + + + )} + {tasks.length > 0 && ( + task.id} + getRowLabel={getTaskRowLabel} + getSeverity={task => task.severity} + onRowClick={task => setSelectedTaskId( + selectedTaskId === task.id ? undefined : task.id, + )} + rows={sortBySeverity(tasks, task => task.severity)} + /> + )} + {!resource.loading && !resource.error && tasks.length === 0 && ( + + {resource.data?.meta.complete + ? 'No tasks match the selected filters.' + : 'No task rows are available from the incomplete monitoring source.'} + + )} + {resource.data?.data.nextCursor && ( +
+ +
+ )} + {selectedTaskId && } +
+ ) +} + +/** + * Renders the ECS Status page with local filters and failure-first service ordering. + * + * @returns active ECS page. + * @throws Does not throw; request failures render in the page state. + */ +export const EcsStatusPage: FC = () => { + const resource = useEcsStatus() + const [filters, setFilters] = useState(INITIAL_FILTERS) + const [expandedServiceId, setExpandedServiceId] = useState() + const clusters = useMemo( + () => resource.data?.data.clusters ?? [], + [resource.data?.data.clusters], + ) + const services = useMemo(() => clusters.flatMap(cluster => ( + cluster.services.map(service => ({ + ...service, + clusterName: service.clusterName || cluster.name, + })) + )), [clusters]) + const visibleServices = useMemo(() => { + const search = filters.search.trim() + .toLowerCase() + return sortBySeverity( + services.filter(service => { + const definition = service.taskDefinition + ? `${service.taskDefinition.family}:${service.taskDefinition.revision}` + : '' + return (!search || [ + service.clusterName, + service.name, + definition, + ].some(value => value.toLowerCase() + .includes(search))) + && (!filters.clusterId || service.clusterId === filters.clusterId) + && (!filters.severity || service.severity === filters.severity) + && (!filters.issuesOnly + || ['critical', 'warning', 'unknown'].includes(service.severity)) + }), + service => service.severity, + (left, right) => left.name.localeCompare(right.name), + ) + }, [filters, services]) + + const serviceColumns = useMemo[]>(() => [ + { + id: 'severity', + label: 'Status', + render: service => ( + <> + + + {formatReasons(service.severityReasons)} + + + ), + }, + { + id: 'service', + label: 'Cluster / service', + render: service => ( + <> + {service.name} + {service.clusterName} + + ), + }, + { + id: 'tasks', + label: 'Tasks D / R / P / stopped', + render: service => ( + + {service.desiredCount ?? '—'} + {' '} + / + {service.runningCount ?? '—'} + {' '} + / + {service.pendingCount ?? '—'} + {' / '} + {service.recentStoppedCount ?? '—'} + + ), + }, + { + id: 'deployment', + label: 'Latest deployment', + render: service => ( + <> + + {service.latestDeployment?.status || 'No deployment available'} + + {service.latestDeployment && ( + + {formatTimestamp(service.latestDeployment.finishedAt + || service.latestDeployment.startedAt)} + + )} + {service.latestDeployment?.reason && ( + {service.latestDeployment.reason} + )} + + ), + }, + { + id: 'deployCount', + label: 'Deploys 24h / 7d', + render: service => ( + + {service.deploymentCounts.last24Hours} + {' '} + / + {' '} + {service.deploymentCounts.last7Days} + + ), + }, + { + id: 'definition', + label: 'Task definition', + render: service => ( + <> + + {getTaskDefinitionLabel(service.taskDefinition)} + + AWS + + ), + }, + { + id: 'failure', + label: 'Latest failure', + render: service => (!service.stoppedHistoryComplete + ? Failure history incomplete + : service.latestFailure + ? ( + <> + + {service.latestFailure.stopCode || 'Task stopped'} + + + {service.latestFailure.reason + || service.latestFailure.stoppedReason + || 'No safe reason available'} + + + {formatTimestamp( + service.latestFailure.timestamp || service.latestFailure.stoppedAt, + )} + + Logs + + ) + : No recent failure), + }, + { + id: 'actions', + label: 'Tasks', + render: service => ( + + ), + }, + ], [expandedServiceId]) + + return ( + } + description={'Live services, deployments, rolling task revisions, and retained failures. ' + + 'Critical issues remain first.'} + title='ECS status' + > + {resource.error && ( + + )} + {resource.loading && !resource.data && } + {resource.data && ( + <> + + + +
+ + + + + + +
+ {visibleServices.length > 0 + ? ( + (expandedServiceId === service.id + ? ( + + ) + : undefined)} + getKey={service => `${service.clusterId}:${service.id}`} + getRowLabel={getServiceRowLabel} + getSeverity={service => service.severity} + onRowClick={service => setExpandedServiceId(current => ( + current === service.id ? undefined : service.id + ))} + rows={visibleServices} + /> + ) + : ( + + {services.length === 0 + ? resource.data.meta.complete + ? 'The complete ECS catalog contains no services.' + : 'No ECS service rows are available from the incomplete source.' + : 'No services match the selected filters.'} + + )} +
+ + )} +
+ ) +} + +export default EcsStatusPage diff --git a/src/apps/status/src/pages/sendgrid/SendgridStatusPage.tsx b/src/apps/status/src/pages/sendgrid/SendgridStatusPage.tsx new file mode 100644 index 000000000..baa227baf --- /dev/null +++ b/src/apps/status/src/pages/sendgrid/SendgridStatusPage.tsx @@ -0,0 +1,212 @@ +/* eslint-disable ordered-imports/ordered-imports, react/jsx-no-bind */ +/** + * SendGrid terminal acceptance aggregates and lazy sanitized provider activity. + */ +import { FC, useMemo, useState } from 'react' + +import { + CompleteEmptyState, + DataFreshness, + IncompleteDataNotice, + RefreshButton, + RetryableErrorState, + StatusColumn, + StatusLoading, + StatusPage, + StatusPanel, + StatusTable, +} from '../../lib/components' +import { useSendgridMessages, useSendgridStatus } from '../../lib/hooks' +import { SendgridWindowSummary } from '../../lib/models' +import { formatRatio, formatTimestamp } from '../../lib/utils' + +import styles from '../StatusPages.module.scss' + +const WINDOW_ORDER = ['15m', '1h', '3h', '6h', '12h', '24h'] + +/** + * Renders exact logical-send acceptance counts and on-demand provider records. + * + * @returns active SendGrid Status page. + * @throws Does not throw; request failures render in the page state. + */ +export const SendgridStatusPage: FC = () => { + const summaryResource = useSendgridStatus() + const [activityOpen, setActivityOpen] = useState(false) + const messageResource = useSendgridMessages(activityOpen) + const windows = useMemo( + () => [...(summaryResource.data?.data.windows ?? [])] + .sort((left, right) => WINDOW_ORDER.indexOf(left.window) - WINDOW_ORDER.indexOf(right.window)), + [summaryResource.data], + ) + const columns = useMemo[]>(() => [ + { + id: 'window', + label: 'Cumulative window', + render: row => {row.window}, + }, + { + id: 'accepted', + label: 'Accepted recipient messages', + render: row => row.acceptedMessages?.toLocaleString() ?? '—', + }, + { + id: 'failed', + label: 'Permanently failed recipient messages', + render: row => row.failedMessages?.toLocaleString() ?? '—', + }, + { + id: 'ratio', + label: 'Success / failure ratio', + render: row => ( + <> + + {formatRatio(row.successRatio)} + {' '} + / + {formatRatio(row.failureRatio)} + + Weighted by recipient count + + ), + }, + { + id: 'operations', + label: 'Accepted / failed operations', + render: row => ( + <> + {row.acceptedOperations?.toLocaleString() ?? '—'} + {' / '} + {row.failedOperations?.toLocaleString() ?? '—'} + + ), + }, + { + id: 'latest', + label: 'Last terminal send', + render: row => formatTimestamp(row.lastTerminalSendAt), + }, + ], []) + + return ( + + )} + description={'Terminal logical-send outcomes and bounded provider diagnostics without ' + + 'recipient addresses or message content.'} + title='SendGrid status' + > +

+ SendGrid API acceptance. + {' '} + Accepted means SendGrid accepted the API request after retries; it does not mean + final recipient delivery. + Recipient-message totals are weighted by safe recipient counts and retries share one logical operation. +

+ {summaryResource.error && ( + + )} + {summaryResource.loading && !summaryResource.data && } + {summaryResource.data && ( + <> + + + + {windows.length > 0 + ? ( + row.window} + rows={windows} + /> + ) + : ( + + No terminal send operations were recorded in these complete windows. + + )} + + + )} + +
+
+
+

Recent provider activity

+

Up to 50 sanitized records, fetched only when this section is open.

+
+ +
+ {activityOpen && ( +
+ {messageResource.error && ( + + )} + {messageResource.loading && !messageResource.data && } + {messageResource.data && ( + <> + + + {messageResource.data.data.messages.length > 0 + ? ( +
    + {messageResource.data.data.messages.map(message => ( +
  • + + {message.status} + {message.toMasked || 'Recipient unavailable'} +
  • + ))} +
+ ) + : ( + + No recent provider activity was returned. + + )} + + )} +
+ )} +
+
+
+ ) +} + +export default SendgridStatusPage diff --git a/src/apps/status/src/pages/status-pages.contract.spec.tsx b/src/apps/status/src/pages/status-pages.contract.spec.tsx new file mode 100644 index 000000000..cb3bca92c --- /dev/null +++ b/src/apps/status/src/pages/status-pages.contract.spec.tsx @@ -0,0 +1,398 @@ +/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports, sort-keys, unicorn/no-null */ +import { MemoryRouter } from 'react-router-dom' +import { + fireEvent, + render, + screen, + within, +} from '@testing-library/react' + +import { + useApiEndpointStatus, + useApiStatus, + useDatabaseStatus, + useEcsStatus, + useEcsTasks, +} from '../lib/hooks' +import { ApiEndpointsPage } from './api/ApiEndpointsPage' +import { ApiStatusPage } from './api/ApiStatusPage' +import { DatabaseStatusPage } from './database/DatabaseStatusPage' +import { EcsStatusPage } from './ecs/EcsStatusPage' + +jest.mock('~/config', () => ({ + AppSubdomain: { status: 'status' }, + EnvironmentConfig: { SUBDOMAIN: 'platform-ui' }, +}), { virtual: true }) + +jest.mock('~/libs/ui', () => ({ + ContentLayout: (props: { children: React.ReactNode }): JSX.Element =>
{props.children}
, +}), { virtual: true }) + +jest.mock('../lib/hooks', () => ({ + useApiEndpointStatus: jest.fn(), + useApiStatus: jest.fn(), + useDatabaseStatus: jest.fn(), + useEcsStatus: jest.fn(), + useEcsTaskDetail: jest.fn(), + useEcsTasks: jest.fn(), +})) + +const mockedApiEndpointStatus = useApiEndpointStatus as jest.Mock +const mockedApiStatus = useApiStatus as jest.Mock +const mockedDatabaseStatus = useDatabaseStatus as jest.Mock +const mockedEcsStatus = useEcsStatus as jest.Mock +const mockedEcsTasks = useEcsTasks as jest.Mock + +const commonResource = { + error: undefined, + loading: false, + refresh: jest.fn(), + refreshing: false, + stale: false, +} + +describe('Status page live contract fixtures', () => { + beforeEach(() => { + jest.clearAllMocks() + mockedEcsTasks.mockReturnValue({ + ...commonResource, + data: { + data: { tasks: [] }, + meta: { + complete: true, + generatedAt: '2026-07-20T00:00:00.000Z', + source: ['ecs'], + warnings: [], + }, + }, + }) + }) + + it('renders nullable ECS definitions and deployments as unknown without crashing', () => { + mockedEcsStatus.mockReturnValue({ + ...commonResource, + data: { + data: { + clusters: [{ + id: 'infrastructure', + name: 'topcoder-infrastructure', + pendingTasks: null, + registeredContainerInstances: null, + runningTasks: null, + services: [{ + clusterId: 'infrastructure', + dataComplete: false, + deploymentCounts: { last24Hours: 0, last7Days: 0 }, + desiredCount: null, + id: 'missing-service', + latestDeployment: null, + latestFailure: null, + name: 'Missing service', + pendingCount: null, + recentStoppedCount: null, + runningCount: null, + severity: 'healthy-change', + severityReasons: ['Recently changed'], + stoppedHistoryComplete: false, + taskDefinition: null, + }], + severity: 'healthy-change', + status: 'ACTIVE', + }], + }, + meta: { + complete: false, + generatedAt: '2026-07-20T00:00:00.000Z', + source: ['ecs'], + warnings: [{ code: 'CATALOG_STALE', message: 'Catalog entry is stale' }], + }, + }, + }) + + render() + + expect(screen.getAllByText('Missing service').length) + .toBeGreaterThan(0) + expect(screen.getAllByText('Unknown task definition').length) + .toBeGreaterThan(0) + expect(screen.getAllByText('No deployment available').length) + .toBeGreaterThan(0) + expect(screen.getAllByText('Failure history incomplete').length) + .toBeGreaterThan(0) + expect(screen.queryByText('No recent failure')) + .toBeNull() + expect(screen.getAllByLabelText('Status: Healthy · recent change').length) + .toBeGreaterThan(0) + }) + + it('keeps a service visible while filtering its expanded inventory by an older revision', () => { + mockedEcsStatus.mockReturnValue({ + ...commonResource, + data: { + data: { + clusters: [{ + id: 'infrastructure', + name: 'topcoder-infrastructure', + pendingTasks: 0, + registeredContainerInstances: 0, + runningTasks: 1, + services: [{ + clusterId: 'infrastructure', + dataComplete: true, + deploymentCounts: { last24Hours: 1, last7Days: 2 }, + desiredCount: 1, + id: 'rolling-service', + latestDeployment: null, + latestFailure: null, + name: 'Rolling service', + pendingCount: 0, + recentStoppedCount: 1, + runningCount: 1, + severity: 'healthy-change', + severityReasons: ['Recently changed'], + stoppedHistoryComplete: true, + taskDefinition: { + family: 'rolling-service', + revision: 42, + url: null, + }, + }], + severity: 'healthy-change', + status: 'ACTIVE', + }], + }, + meta: { + complete: true, + generatedAt: '2026-07-20T00:00:00.000Z', + source: ['ecs'], + warnings: [], + }, + }, + }) + + render() + + fireEvent.click(screen.getAllByRole('button', { name: 'View tasks' })[0]) + fireEvent.change(screen.getByLabelText('Expanded task definition'), { + target: { value: 'rolling-service:41' }, + }) + + expect(screen.getAllByText('Rolling service').length) + .toBeGreaterThan(0) + expect(screen.getByText('Task inventory for Rolling service')) + .toBeTruthy() + const latestTaskQuery = mockedEcsTasks.mock.calls[mockedEcsTasks.mock.calls.length - 1][0] + expect(latestTaskQuery) + .toEqual(expect.objectContaining({ taskDefinition: 'rolling-service:41' })) + }) + + it('renders incomplete API aggregates as unknown instead of fallback counts', () => { + mockedApiStatus.mockReturnValue({ + ...commonResource, + data: { + data: { + services: [{ + dataComplete: false, + id: 'gateway', + latencyMs: { + integration: { p50: 11, p95: 22, p99: 33 }, + response: { p50: 44, p95: 55, p99: 66 }, + }, + name: 'Gateway', + requests: 987654, + responseCounts: { + clientError: 123, + redirect: 234, + serverError: 345, + success: 456, + }, + responseRatios: { + clientError: 0.1, + redirect: 0.2, + serverError: 0.3, + success: 0.4, + }, + targetHealth: { healthy: 0, unhealthy: 0, unknown: true }, + }], + summary: { + dataComplete: false, + healthyTargets: 0, + latencyMs: { + integration: { p50: 11, p95: 22, p99: 33 }, + response: { p50: 44, p95: 55, p99: 66 }, + }, + requests: 876543, + responseCounts: { + clientError: 123, + redirect: 234, + serverError: 345, + success: 456, + }, + responseRatios: { + clientError: 0.1, + redirect: 0.2, + serverError: 0.3, + success: 0.4, + }, + unhealthyTargets: 0, + }, + }, + meta: { + complete: false, + generatedAt: '2026-07-20T00:00:00.000Z', + source: ['cloudwatch-logs'], + warnings: [{ + code: 'QUERY_TIMEOUT', + message: 'The aggregate query timed out', + }], + window: '1h', + }, + }, + }) + + render( + + + , + ) + + const totalRequestsCard = screen.getByText('Total requests') + .closest('article') as HTMLElement + expect(within(totalRequestsCard) + .getByText('—')) + .toBeTruthy() + expect(screen.queryByText('876,543')) + .toBeNull() + expect(screen.queryByText('987,654')) + .toBeNull() + expect(screen.getAllByText('Incomplete').length) + .toBeGreaterThan(0) + }) + + it('renders incomplete endpoint aggregates and coverage as unknown', () => { + mockedApiEndpointStatus.mockReturnValue({ + ...commonResource, + data: { + data: { + coverage: { + attributedRequests: 876543, + unattributedEdgeFailures: 765432, + }, + endpoints: [{ + dataComplete: false, + id: 'get-resource', + latencyMs: { + integration: { p50: 11, p95: 22, p99: 33 }, + response: { p50: 44, p95: 55, p99: 66 }, + }, + method: 'GET', + requests: 987654, + responseCounts: { + clientError: 123, + redirect: 234, + serverError: 345, + success: 456, + }, + responseRatios: { + clientError: 0.1, + redirect: 0.2, + serverError: 0.3, + success: 0.4, + }, + routeTemplate: '/resources/:resourceId', + }], + service: { id: 'resources', name: 'Resources API' }, + }, + meta: { + complete: false, + generatedAt: '2026-07-20T00:00:00.000Z', + source: ['cloudwatch-logs'], + warnings: [{ + code: 'QUERY_INCOMPLETE', + message: 'The endpoint query was incomplete', + }], + window: '1h', + }, + }, + }) + + render( + + + , + ) + + expect(screen.queryByText('987,654')) + .toBeNull() + expect(screen.queryByText('876,543')) + .toBeNull() + expect(screen.queryByText('765,432')) + .toBeNull() + expect(screen.getAllByText('—').length) + .toBeGreaterThan(0) + }) + + it('renders exact RDS event and console fields plus derived incomplete states', () => { + mockedDatabaseStatus.mockReturnValue({ + ...commonResource, + data: { + data: { + database: { + connections: { + average: 10, + latest: 8, + maximum: 15, + sampledAt: '2026-07-20T00:00:00.000Z', + }, + consoleUrl: 'https://us-east-1.console.aws.amazon.com/rds/home', + engine: 'postgres', + engineMessages: [], + events: [{ + categories: ['availability'], + sourceType: 'db-instance', + summary: 'Instance restarted safely', + timestamp: '2026-07-20T00:00:00.000Z', + }], + id: 'topcoder-services', + status: 'available', + storage: { + allocatedBytes: 1000, + freeBytes: 400, + meaning: 'rds_allocation_usage', + sampledAt: '2026-07-20T00:00:00.000Z', + usedBytes: 600, + usedRatio: 0.6, + }, + }, + }, + meta: { + complete: false, + generatedAt: '2026-07-20T00:00:00.000Z', + source: ['rds', 'cloudwatch-metrics'], + warnings: [{ + code: 'RDS_ENGINE_TELEMETRY_UNAVAILABLE', + message: 'Engine telemetry is unavailable', + }, { + code: 'DATABASE_SIZE_INTERPRETATION_PENDING', + message: 'Logical database size is not approved', + }], + window: '1h', + }, + }, + }) + + render() + + expect(screen.getByText('Instance restarted safely')) + .toBeTruthy() + expect(screen.getByText('availability')) + .toBeTruthy() + expect(screen.getByText(/Engine-log coverage is incomplete/)) + .toBeTruthy() + expect(screen.getByText('Incomplete', { selector: 'div' })) + .toBeTruthy() + expect(screen.getByRole('link', { name: /Open RDS in AWS/ }) + .getAttribute('href')) + .toContain('console.aws.amazon.com') + }) +}) diff --git a/src/apps/status/src/status-app.routes.spec.tsx b/src/apps/status/src/status-app.routes.spec.tsx new file mode 100644 index 000000000..e71f06e8b --- /dev/null +++ b/src/apps/status/src/status-app.routes.spec.tsx @@ -0,0 +1,60 @@ +/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */ +import { UserRole } from '~/libs/core' + +import { getStatusRootRoute } from './config/routes.config' +import { statusRoutes } from './status-app.routes' + +jest.mock('~/config', () => ({ + AppSubdomain: { status: 'status' }, + EnvironmentConfig: { SUBDOMAIN: 'platform-ui' }, + ToolTitle: { status: 'Status' }, +}), { virtual: true }) + +jest.mock('~/libs/core', () => ({ + lazyLoad: () => (): JSX.Element =>
, + Rewrite: (): JSX.Element =>
, + UserRole: { administrator: 'administrator' }, +}), { virtual: true }) + +describe('Status application routes', () => { + it('protects the root and every child with the exact administrator role', () => { + const [root] = statusRoutes + + expect(root.authRequired) + .toBe(true) + expect(root.rolesRequired) + .toEqual([UserRole.administrator]) + expect(root.children) + .toHaveLength(7) + root.children?.forEach(child => { + expect(child.authRequired) + .toBe(true) + expect(child.rolesRequired) + .toEqual([UserRole.administrator]) + }) + }) + + it('registers the redirect, tabs, and routed API drill-downs', () => { + const childPaths = statusRoutes[0].children?.map(route => route.route) + + expect(statusRoutes[0].children?.[0].element?.props.to) + .toBe('ecs') + expect(childPaths) + .toEqual([ + '', + 'ecs', + 'api', + 'api/:serviceId', + 'api/:serviceId/endpoints/:endpointId', + 'sendgrid', + 'database', + ]) + }) + + it('resolves combined and dedicated host roots', () => { + expect(getStatusRootRoute('platform-ui')) + .toBe('/status') + expect(getStatusRootRoute('status')) + .toBe('') + }) +}) diff --git a/src/apps/status/src/status-app.routes.tsx b/src/apps/status/src/status-app.routes.tsx new file mode 100644 index 000000000..68f19f13d --- /dev/null +++ b/src/apps/status/src/status-app.routes.tsx @@ -0,0 +1,103 @@ +/** + * Platform routes for the administrator-only Status application. + */ +import { AppSubdomain, ToolTitle } from '~/config' +import { + lazyLoad, + LazyLoadedComponent, + PlatformRoute, + Rewrite, + UserRole, +} from '~/libs/core' + +import { + apiRouteId, + databaseRouteId, + ecsRouteId, + rootRoute, + sendgridRouteId, +} from './config/routes.config' + +const StatusApp: LazyLoadedComponent = lazyLoad(() => import('./StatusApp')) +const EcsStatusPage: LazyLoadedComponent = lazyLoad( + () => import('./pages/ecs/EcsStatusPage'), + 'EcsStatusPage', +) +const ApiStatusPage: LazyLoadedComponent = lazyLoad( + () => import('./pages/api/ApiStatusPage'), + 'ApiStatusPage', +) +const ApiEndpointsPage: LazyLoadedComponent = lazyLoad( + () => import('./pages/api/ApiEndpointsPage'), + 'ApiEndpointsPage', +) +const ApiFailuresPage: LazyLoadedComponent = lazyLoad( + () => import('./pages/api/ApiFailuresPage'), + 'ApiFailuresPage', +) +const SendgridStatusPage: LazyLoadedComponent = lazyLoad( + () => import('./pages/sendgrid/SendgridStatusPage'), + 'SendgridStatusPage', +) +const DatabaseStatusPage: LazyLoadedComponent = lazyLoad( + () => import('./pages/database/DatabaseStatusPage'), + 'DatabaseStatusPage', +) + +export const toolTitle: string = ToolTitle.status + +export const statusRoutes: ReadonlyArray = [ + { + authRequired: true, + children: [ + { + authRequired: true, + element: , + rolesRequired: [UserRole.administrator], + route: '', + }, + { + authRequired: true, + element: , + rolesRequired: [UserRole.administrator], + route: ecsRouteId, + }, + { + authRequired: true, + element: , + rolesRequired: [UserRole.administrator], + route: apiRouteId, + }, + { + authRequired: true, + element: , + rolesRequired: [UserRole.administrator], + route: `${apiRouteId}/:serviceId`, + }, + { + authRequired: true, + element: , + rolesRequired: [UserRole.administrator], + route: `${apiRouteId}/:serviceId/endpoints/:endpointId`, + }, + { + authRequired: true, + element: , + rolesRequired: [UserRole.administrator], + route: sendgridRouteId, + }, + { + authRequired: true, + element: , + rolesRequired: [UserRole.administrator], + route: databaseRouteId, + }, + ], + domain: AppSubdomain.status, + element: , + id: toolTitle, + rolesRequired: [UserRole.administrator], + route: rootRoute, + title: toolTitle, + }, +] diff --git a/src/apps/work/src/lib/components/ShowcasePostPreview/ShowcasePostPreview.module.scss b/src/apps/work/src/lib/components/ShowcasePostPreview/ShowcasePostPreview.module.scss new file mode 100644 index 000000000..5311dee53 --- /dev/null +++ b/src/apps/work/src/lib/components/ShowcasePostPreview/ShowcasePostPreview.module.scss @@ -0,0 +1,446 @@ +@import '@libs/ui/styles/includes'; + +.wrap { + display: flex; + flex-direction: column; + gap: 24px; + + .challengeTitle, + .challengeViewLink, + .projectLink { + text-decoration: none !important; + + &:hover, + &:focus { + text-decoration: none !important; + } + } +} + +.header { + border-bottom: 1px solid #E8E8E8; + padding-bottom: 24px; +} + +.tags { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.tag { + display: flex; + align-items: center; + gap: 4px; + padding: 3px $sp-1; + border-radius: 2px; + font-size: 11px; + font-weight: 600; + line-height: 10px; + font-family: "Nunito Sans", sans-serif; + background: $tc-white; + color: #0A0A0A; + border: 1px solid #a8a8a8; +} + +.title { + color: $black-100; + font-family: "Barlow Condensed"; + font-size: 34px; + font-weight: 600; + line-height: 32px; + text-transform: uppercase; + margin-top: 8px; +} + +.subTitle { + margin-top: $sp-4; + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 12px; +} + +.subTitleItem { + display: flex; + align-items: center; + gap: 4px; + color: $turq-160; + + span { + color: $black-100; + font-family: Roboto; + font-size: 14px; + font-weight: 500; + line-height: 22px; + } +} + +.bodyWrap { + display: flex; + gap: $sp-12; + align-items: flex-start; +} + +.body { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 32px; +} + +.sidebar { + width: 320px; + max-width: 40%; + display: flex; + flex-direction: column; + gap: $sp-6; + flex-shrink: 0; +} + +.htmlContent { + all: revert-layer; + color: $black-100; + font-family: Roboto; + font-size: 16px; + line-height: 24px; + overflow-wrap: anywhere; + + * { + all: revert-layer; + } + + > :first-child { + margin-top: 0; + } + + > :last-child { + margin-bottom: 0; + } + + p, + ul, + ol, + blockquote, + table, + pre { + margin: 0 0 16px; + } + + ul, + ol { + padding-left: 20px; + } + + a { + color: $link-blue-dark; + font-weight: 700; + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } +} + +.section { + display: flex; + flex-direction: column; + gap: 12px; +} + +.sectionTitle { + color: $black-100; + font-family: Barlow; + font-size: 18px; + font-weight: 600; + line-height: 22px; + text-transform: uppercase; + margin: 0; +} + +.emptyMessage { + margin: 0; + color: $black-80; + font-family: Roboto; + font-size: 14px; + line-height: 22px; +} + +.mediaList { + display: flex; + flex-wrap: wrap; + gap: 12px; + margin: 0; + padding: 0; + list-style: none; +} + +.mediaItem { + width: 164px; + height: 96px; + border-radius: 4px; + overflow: hidden; + border: 1px solid $black-10; + display: flex; + align-items: center; + justify-content: center; + background: $black-5; +} + +.mediaImage { + width: 100%; + height: 100%; + object-fit: cover; +} + +.mediaLink { + padding: 8px; + color: $link-blue-dark; + font-size: 12px; + font-weight: 600; + text-align: center; + word-break: break-word; +} + +.challengeList { + display: flex; + flex-direction: column; + margin: 0; + padding: 0; + list-style: none; +} + +.challengeItem { + display: flex; + justify-content: space-between; + align-items: center; + gap: 16px; + padding: 12px 0; + border-top: 1px solid #e8e8e8; + border-bottom: 1px solid #e8e8e8; +} + +.challengeMain { + display: flex; + flex-direction: column; + gap: 8px; + min-width: 0; +} + +.challengeTrack { + display: inline-flex; + width: max-content; + padding: 3px 4px; + border-radius: 2px; + border: 1px solid #a8a8a8; + font-family: "Nunito Sans", sans-serif; + font-size: 11px; + font-weight: 600; + line-height: 10px; + color: #0A0A0A; + background: $tc-white; +} + +.challengeTitle { + color: $black-100; + font-family: Roboto; + font-size: 16px; + font-weight: 500; + line-height: 24px; + text-decoration: none; + + &:hover { + color: $link-blue-dark; + text-decoration: none; + } +} + +.challengeMeta { + display: flex; + align-items: center; + gap: 24px; + flex-shrink: 0; +} + +.challengeStats { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + color: $black-80; + font-family: Roboto; + font-size: 14px; + font-weight: 400; + line-height: 22px; +} + +.challengeViewLink { + display: inline-flex; + align-items: center; + gap: 4px; + color: $link-blue-dark; + font-family: Roboto; + font-size: 14px; + font-weight: 700; + text-decoration: none; + + &:hover { + text-decoration: none; + } +} + +.panel { + border-radius: 8px; + border: 1px solid $black-20; + display: flex; + padding: $sp-4; + flex-direction: column; + align-items: flex-start; + gap: $sp-2; + align-self: stretch; +} + +.panelTitle { + color: $black-100; + font-family: Roboto; + font-size: 12px; + font-weight: 700; + line-height: 16px; + letter-spacing: 1px; + text-transform: uppercase; + margin: 0; +} + +.projectName { + margin: 0; + color: $black-100; + font-family: Roboto; + font-size: 14px; + font-weight: 500; + line-height: 22px; +} + +.projectLink { + color: #0D61BF; + font-family: Roboto; + font-size: 14px; + font-weight: 500; + line-height: 22px; + text-decoration: none; +} + +.projectUrl { + color: #888; + font-family: Roboto; + font-size: 12px; + font-weight: 400; + line-height: normal; + word-break: break-all; +} + +.statsList { + display: flex; + list-style: none; + align-items: flex-start; + gap: 10px; + width: 100%; + margin: 0; + padding: 0; + + li { + flex: 1; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 4px; + color: $black-100; + + strong { + font-family: "Barlow Condensed"; + font-size: 32px; + font-weight: 500; + line-height: 34px; + text-transform: uppercase; + } + + span { + font-family: Roboto; + font-size: 12px; + font-weight: 400; + line-height: normal; + } + } +} + +.skillsSummary { + margin: 0; + color: $black-100; + font-family: Roboto; + font-size: 14px; + font-weight: 400; + line-height: 22px; + + strong { + font-weight: 700; + } +} + +.skillsList { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + gap: 8px; + width: 100%; + margin: 0; + padding: 0; + list-style: none; + + li { + display: flex; + padding: 6px 12px; + align-items: center; + border-radius: 24px; + border: 1px solid $black-20; + background: $tc-white; + color: #333; + font-family: Roboto; + font-size: 14px; + font-weight: 500; + line-height: normal; + letter-spacing: -0.2px; + white-space: nowrap; + } +} + +@media (max-width: 900px) { + .bodyWrap { + flex-direction: column; + } + + .sidebar { + width: 100%; + max-width: 100%; + order: -1; + } + + .challengeItem { + flex-direction: column; + align-items: flex-start; + gap: 8px; + } + + .challengeMeta { + flex-direction: column; + align-items: flex-start; + gap: 8px; + } +} + +@media (max-width: 560px) { + .title { + font-size: 22px; + line-height: 28px; + } +} diff --git a/src/apps/work/src/lib/components/ShowcasePostPreview/ShowcasePostPreview.tsx b/src/apps/work/src/lib/components/ShowcasePostPreview/ShowcasePostPreview.tsx new file mode 100644 index 000000000..dd0630a83 --- /dev/null +++ b/src/apps/work/src/lib/components/ShowcasePostPreview/ShowcasePostPreview.tsx @@ -0,0 +1,295 @@ +import { FC, useMemo } from 'react' + +import { EnvironmentConfig } from '~/config' +import { IconOutline } from '~/libs/ui' +import { renderRichTextToHtml } from '~/libs/shared/lib/utils/rich-text' +import { textFormatDateLocaleShortString } from '~/libs/shared/lib/utils/text-format' + +import styles from './ShowcasePostPreview.module.scss' + +export interface ShowcasePostPreviewChallenge { + id: string + name: string + url: string + track?: string + numOfSubmissions?: number + numOfRegistrants?: number +} + +export interface ShowcasePostPreviewData { + title: string + content: string + categories: Array<{ id: string; name: string }> + industries: Array<{ id: string; name: string }> + media: Array<{ url: string; type: string; alt?: string }> + challenges: ShowcasePostPreviewChallenge[] + projectTitle: string + projectUrl: string + publishedAt: number | string + challengeCount: number + registrantsCount: number + countriesCount: number + skills: Array<{ id: string; name: string }> +} + +export interface ShowcasePostPreviewProps { + data: ShowcasePostPreviewData +} + +function isImageMedia(type: string, url: string): boolean { + const value = `${type} ${url}`.toLowerCase() + return /\.(bmp|gif|jpe?g|png)(?:[?#]|$)/.test(value) + || value.includes('image/') +} + +/** + * Allows only http(s) URLs for media src/href to block javascript: and other XSS vectors. + */ +function getSafeHttpUrl(value: string | undefined): string | undefined { + if (!value) { + return undefined + } + + try { + const parsed = new URL(value) + if (parsed.protocol === 'http:' || parsed.protocol === 'https:') { + return parsed.toString() + } + } catch { + return undefined + } + + return undefined +} + +const ShowcasePostPreview: FC = props => { + const data: ShowcasePostPreviewData = props.data + const industries: string = useMemo( + () => data.industries.map(item => item.name) + .join(', '), + [data.industries], + ) + const projectUrl: string | undefined = getSafeHttpUrl(data.projectUrl) + + return ( +
+
+
+ {data.categories.map(category => ( + {category.name} + ))} +
+

{data.title || 'Untitled post'}

+
+ {industries && ( +
+ + {industries} +
+ )} +
+ + Published + + {textFormatDateLocaleShortString(new Date(data.publishedAt || Date.now()))} + +
+
+
+ +
+
+
+ +
+
Media assets
+ {data.media.length > 0 ? ( +
    + {data.media.map((item, index) => { + const key: string = `${item.url}-${index}` + const safeUrl: string | undefined = getSafeHttpUrl(item.url) + if (!safeUrl) { + return ( +
  • + + {item.alt || item.type || 'Unavailable file'} + +
  • + ) + } + + if (isImageMedia(item.type, item.url)) { + return ( +
  • + {item.alt +
  • + ) + } + + return ( +
  • + + {item.alt || item.type || 'Open file'} + +
  • + ) + })} +
+ ) : ( +

No media added yet.

+ )} +
+ +
+
Challenges
+ {data.challenges.length > 0 ? ( +
    + {data.challenges.map(challenge => { + const challengeUrl: string | undefined = getSafeHttpUrl( + challenge.url + || `${EnvironmentConfig.URLS.CHALLENGES_PAGE}/${challenge.id}`, + ) + + return ( +
  • +
    + {challenge.track && ( + + {challenge.track} + + )} + {challengeUrl ? ( + + {challenge.name} + + ) : ( + + {challenge.name} + + )} +
    +
    + {(typeof challenge.numOfSubmissions === 'number' + || typeof challenge.numOfRegistrants === 'number') && ( +
    + {typeof challenge.numOfSubmissions === 'number' && ( + + {challenge.numOfSubmissions} + {' '} + submissions + + )} + {typeof challenge.numOfRegistrants === 'number' && ( + + {challenge.numOfRegistrants} + {' '} + registrants + + )} +
    + )} + {challengeUrl && ( + + View + + + )} +
    +
  • + ) + })} +
+ ) : ( +

No challenges selected.

+ )} +
+
+ + +
+
+ ) +} + +export default ShowcasePostPreview diff --git a/src/apps/work/src/lib/components/ShowcasePostPreview/index.ts b/src/apps/work/src/lib/components/ShowcasePostPreview/index.ts new file mode 100644 index 000000000..4f3bc3163 --- /dev/null +++ b/src/apps/work/src/lib/components/ShowcasePostPreview/index.ts @@ -0,0 +1,6 @@ +export { default as ShowcasePostPreview } from './ShowcasePostPreview' +export type { + ShowcasePostPreviewChallenge, + ShowcasePostPreviewData, + ShowcasePostPreviewProps, +} from './ShowcasePostPreview' diff --git a/src/apps/work/src/lib/components/index.ts b/src/apps/work/src/lib/components/index.ts index 2fce0be36..579df8e7d 100644 --- a/src/apps/work/src/lib/components/index.ts +++ b/src/apps/work/src/lib/components/index.ts @@ -32,6 +32,7 @@ export * from './ProjectsShowcaseFilter' export * from './ProjectsTable' export * from './ResourceAddModal' export * from './ResourcesTable' +export * from './ShowcasePostPreview' export * from './TerminateAssignmentModal' export * from './SubmissionHistoryModal' export * from './SubmissionRunnerLogsModal' diff --git a/src/apps/work/src/lib/constants.ts b/src/apps/work/src/lib/constants.ts index f2dc69d8c..0191edc31 100644 --- a/src/apps/work/src/lib/constants.ts +++ b/src/apps/work/src/lib/constants.ts @@ -145,6 +145,10 @@ export const TC_AI_SKILLS_EXTRACTION_WORKFLOW_ID = process.env.REACT_APP_TC_AI_S || process.env.TC_AI_SKILLS_EXTRACTION_WORKFLOW_ID || 'skillExtractionWorkflow' +export const TC_AI_CONTEXT_WORKFLOW_ID = process.env.REACT_APP_TC_AI_CONTEXT_WORKFLOW_ID + || process.env.TC_AI_CONTEXT_WORKFLOW_ID + || 'challengeContextWorkflow' + export const TC_AI_AUTOWRITE_WORKFLOW_ID = process.env.REACT_APP_TC_AI_AUTOWRITE_WORKFLOW_ID || process.env.TC_AI_AUTOWRITE_WORKFLOW_ID || 'jdAutowriteWorkflow' diff --git a/src/apps/work/src/lib/hooks/index.ts b/src/apps/work/src/lib/hooks/index.ts index 72ee0e19d..7e0c60778 100644 --- a/src/apps/work/src/lib/hooks/index.ts +++ b/src/apps/work/src/lib/hooks/index.ts @@ -37,3 +37,4 @@ export * from './useFetchTerms' export * from './useFetchTimelineTemplates' export * from './useFetchUserProjects' export * from './useSearchSkills' +export * from './useFetchChallengeReviewContext' diff --git a/src/apps/work/src/lib/hooks/useAutosave.spec.tsx b/src/apps/work/src/lib/hooks/useAutosave.spec.tsx index 5855a4982..c1f8f939b 100644 --- a/src/apps/work/src/lib/hooks/useAutosave.spec.tsx +++ b/src/apps/work/src/lib/hooks/useAutosave.spec.tsx @@ -2,11 +2,13 @@ import { act, render, + screen, waitFor, } from '@testing-library/react' import { useAutosave, + UseAutosaveResult, } from './useAutosave' interface TestComponentProps { @@ -16,14 +18,19 @@ interface TestComponentProps { } const TestComponent = (props: TestComponentProps): JSX.Element => { - useAutosave>({ + const { saveStatus }: UseAutosaveResult = useAutosave>({ delay: 100, enabled: props.enabled, formValues: props.formValues, onSave: props.onSave, }) - return
Autosave Test
+ return ( +
+
Autosave Test
+
{saveStatus}
+
+ ) } async function advanceAutosaveDelay(): Promise { @@ -147,4 +154,47 @@ describe('useAutosave', () => { .toHaveBeenCalledTimes(2) }) }) + + it('resets saved status when form values change after a successful save', async () => { + const onSave = jest.fn, [Record]>() + .mockResolvedValue(undefined) + + const rendered = render( + , + ) + const rerender = rendered.rerender + + // Trigger the autosave effect by re-rendering after initial render. + rerender( + , + ) + + await advanceAutosaveDelay() + + await waitFor(() => { + expect(onSave) + .toHaveBeenCalledTimes(1) + }) + + expect(screen.getByTestId('save-status').textContent) + .toBe('saved') + + rerender( + , + ) + + await waitFor(() => { + expect(screen.getByTestId('save-status').textContent) + .toBe('idle') + }) + }) }) diff --git a/src/apps/work/src/lib/hooks/useAutosave.ts b/src/apps/work/src/lib/hooks/useAutosave.ts index a874b4d2f..cac3ac716 100644 --- a/src/apps/work/src/lib/hooks/useAutosave.ts +++ b/src/apps/work/src/lib/hooks/useAutosave.ts @@ -31,6 +31,7 @@ export function useAutosave( const [saveStatus, setSaveStatus] = useState('idle') const isInitialRender = useRef(true) const lastQueuedValuesRef = useRef() + const lastSavedValuesRef = useRef() const onSaveRef = useRef<(values: T) => Promise>(onSave) useEffect(() => { @@ -44,6 +45,7 @@ export function useAutosave( try { await onSaveRef.current(values) setLastSaved(new Date()) + lastSavedValuesRef.current = cloneDeep(values) setSaveStatus('saved') } catch { setSaveStatus('error') @@ -56,6 +58,7 @@ export function useAutosave( if (!enabled) { debouncedSave.cancel() lastQueuedValuesRef.current = undefined + isInitialRender.current = false return undefined } @@ -64,6 +67,14 @@ export function useAutosave( return undefined } + if ( + saveStatus === 'saved' + && lastSavedValuesRef.current !== undefined + && !isEqual(lastSavedValuesRef.current, formValues) + ) { + setSaveStatus('idle') + } + if ( lastQueuedValuesRef.current !== undefined && isEqual(lastQueuedValuesRef.current, formValues) @@ -75,7 +86,7 @@ export function useAutosave( debouncedSave(formValues) return undefined - }, [debouncedSave, enabled, formValues]) + }, [debouncedSave, enabled, formValues, saveStatus]) useEffect(() => () => { debouncedSave.cancel() diff --git a/src/apps/work/src/lib/hooks/useFetchChallengeReviewContext.ts b/src/apps/work/src/lib/hooks/useFetchChallengeReviewContext.ts new file mode 100644 index 000000000..8f4772234 --- /dev/null +++ b/src/apps/work/src/lib/hooks/useFetchChallengeReviewContext.ts @@ -0,0 +1,44 @@ +import useSWR, { SWRResponse } from 'swr' + +import { ChallengeReviewContext } from '../models' +import { fetchChallengeReviewContextByChallenge } from '../services' + +export interface UseFetchChallengeReviewContextResult { + context: ChallengeReviewContext | undefined + error: string | undefined + isError: boolean + isLoading: boolean + mutate: SWRResponse['mutate'] +} + +export function useFetchChallengeReviewContext(challengeId?: string): UseFetchChallengeReviewContextResult { + const swrKey = challengeId + ? ['work/challenge/review-context', challengeId] + : undefined + + const { + data, + error, + mutate, + isValidating, + }: SWRResponse + = useSWR( + swrKey, + () => fetchChallengeReviewContextByChallenge(challengeId as string), + { + dedupingInterval: 0, + errorRetryCount: 2, + shouldRetryOnError: true, + }, + ) + + return { + context: error + ? undefined + : data, + error: error?.message, + isError: !!error, + isLoading: !!challengeId && !data && !error && isValidating, + mutate, + } +} diff --git a/src/apps/work/src/lib/models/ChallengeReviewContext.model.ts b/src/apps/work/src/lib/models/ChallengeReviewContext.model.ts new file mode 100644 index 000000000..d3dd53c17 --- /dev/null +++ b/src/apps/work/src/lib/models/ChallengeReviewContext.model.ts @@ -0,0 +1,63 @@ +export type ChallengeReviewContextStatus = 'AI_GENERATED' | 'HUMAN_APPROVED' | 'HUMAN_REJECTED' + +export interface ReviewContextPrize { + value: number + currency: string + placement: number +} + +export interface ReviewContextSkill { + id: string + name: string +} + +export interface ReviewContextTimeline { + endDate: string + startDate: string + totalDurationDays: number + registrationEndDate: string + registrationStartDate: string +} + +export interface ReviewContextConstraint { + id: string + text: string +} + +export interface ReviewContextRequirement { + id: string + title: string + priority: string + constraints: ReviewContextConstraint[] + description: string +} + +export interface ChallengeReviewContextData { + title: string + prizes: ReviewContextPrize[] + skills: ReviewContextSkill[] + timeline: ReviewContextTimeline + tech_stack: string[] + challengeId: string + requirements: ReviewContextRequirement[] + descriptionRaw: string + descriptionFormat?: string + review_criteria?: Record + existing_codebase?: Record + challenge_metadata?: Record + requirement_groups?: Record[] + runtime_environment?: Record + submission_guidelines?: Record + [key: string]: unknown +} + +export interface ChallengeReviewContext { + id: string + challengeId: string + context: ChallengeReviewContextData + status: ChallengeReviewContextStatus + createdAt?: string + createdBy?: string | null + updatedAt?: string + updatedBy?: string | null +} diff --git a/src/apps/work/src/lib/models/index.ts b/src/apps/work/src/lib/models/index.ts index 676d64555..69213f847 100644 --- a/src/apps/work/src/lib/models/index.ts +++ b/src/apps/work/src/lib/models/index.ts @@ -3,6 +3,7 @@ export * from './AiReview.model' export * from './Engagement.model' export * from './ChallengeEditor.model' export * from './Challenge.model' +export * from './ChallengeReviewContext.model' export * from './ChallengeFilters.model' export * from './MarathonMatch.model' export * from './ChallengeType.model' diff --git a/src/apps/work/src/lib/services/challenge-review-context.service.ts b/src/apps/work/src/lib/services/challenge-review-context.service.ts new file mode 100644 index 000000000..8a60daaa8 --- /dev/null +++ b/src/apps/work/src/lib/services/challenge-review-context.service.ts @@ -0,0 +1,209 @@ +import { + xhrDeleteAsync, + xhrGetAsync, + xhrPostAsync, + xhrPutAsync, +} from '~/libs/core' +import { EnvironmentConfig } from '~/config' + +import { + ChallengeReviewContext, + ChallengeReviewContextData, + ChallengeReviewContextStatus, +} from '../models' + +const CHALLENGE_REVIEW_CONTEXT_API_URL = `${EnvironmentConfig.API.V6}/ai-review/context` + +function normalizeText(value: unknown): string | undefined { + if (value === undefined || value === null) { + return undefined + } + + const normalizedValue = String(value) + .trim() + + return normalizedValue || undefined +} + +function normalizeReviewContextStatus(value: unknown): ChallengeReviewContextStatus | undefined { + const normalizedValue = normalizeText(value) + + if (normalizedValue === 'AI_GENERATED' + || normalizedValue === 'HUMAN_APPROVED' + || normalizedValue === 'HUMAN_REJECTED') { + return normalizedValue + } + + return undefined +} + +function normalizeChallengeReviewContext( + input: unknown, +): ChallengeReviewContext | undefined { + if (typeof input !== 'object' || input === null) { + return undefined + } + + const typedInput = input as Record + const id = normalizeText(typedInput.id) + const challengeId = normalizeText(typedInput.challengeId) + const status = normalizeReviewContextStatus(typedInput.status) + const context = typeof typedInput.context === 'object' && typedInput.context + ? typedInput.context as ChallengeReviewContextData + : undefined + + if (!id || !challengeId || !status || !context) { + return undefined + } + + return { + challengeId, + context, + createdAt: normalizeText(typedInput.createdAt), + createdBy: typeof typedInput.createdBy === 'string' + ? typedInput.createdBy + : undefined, + id, + status, + updatedAt: normalizeText(typedInput.updatedAt), + updatedBy: typeof typedInput.updatedBy === 'string' + ? typedInput.updatedBy + : undefined, + } +} + +function normalizeError(error: unknown, fallbackMessage: string): Error { + const typedError = error as { + message?: string + response?: { + data?: { + message?: string + } + } + } + + return new Error( + typedError?.response?.data?.message + || typedError?.message + || fallbackMessage, + ) +} + +function serializeInput( + input: { + challengeId: string + context: Record + status: ChallengeReviewContextStatus + }, +): { + challengeId: string + context: Record + status: ChallengeReviewContextStatus +} { + return { + challengeId: normalizeText(input.challengeId) || input.challengeId, + context: input.context, + status: input.status, + } +} + +export async function fetchChallengeReviewContextByChallenge( + challengeId: string, +): Promise { + try { + const response = await xhrGetAsync( + `${CHALLENGE_REVIEW_CONTEXT_API_URL}/${encodeURIComponent(challengeId.trim())}`, + ) + + return normalizeChallengeReviewContext(response) + } catch (error) { + const typedError = error as { + response?: { + status?: number + } + status?: number + } + const status = typedError?.status || typedError?.response?.status + + if (status === 404) { + return undefined + } + + throw normalizeError(error, 'Failed to fetch review context') + } +} + +export interface CreateChallengeReviewContextInput { + challengeId: string + context: Record + status: ChallengeReviewContextStatus +} + +export async function createChallengeReviewContext( + input: CreateChallengeReviewContextInput, +): Promise { + try { + const response = await xhrPostAsync< + CreateChallengeReviewContextInput, + unknown + >( + CHALLENGE_REVIEW_CONTEXT_API_URL, + serializeInput(input), + ) + + const normalizedResult = normalizeChallengeReviewContext(response) + + if (!normalizedResult) { + throw new Error('Challenge review context response was invalid') + } + + return normalizedResult + } catch (error) { + throw normalizeError(error, 'Failed to create review context') + } +} + +export interface UpdateChallengeReviewContextInput { + context: Record + status?: ChallengeReviewContextStatus +} + +export async function updateChallengeReviewContext( + challengeId: string, + input: UpdateChallengeReviewContextInput, +): Promise { + try { + const response = await xhrPutAsync< + UpdateChallengeReviewContextInput, + unknown + >( + `${CHALLENGE_REVIEW_CONTEXT_API_URL}/${encodeURIComponent(challengeId.trim())}`, + { + context: input.context, + status: input.status, + }, + ) + + const normalizedResult = normalizeChallengeReviewContext(response) + + if (!normalizedResult) { + throw new Error('Challenge review context response was invalid') + } + + return normalizedResult + } catch (error) { + throw normalizeError(error, 'Failed to update review context') + } +} + +export async function deleteChallengeReviewContext( + challengeId: string, +): Promise { + try { + await xhrDeleteAsync( + `${CHALLENGE_REVIEW_CONTEXT_API_URL}/${encodeURIComponent(challengeId.trim())}`, + ) + } catch (error) { + throw normalizeError(error, 'Failed to delete review context') + } +} diff --git a/src/apps/work/src/lib/services/index.ts b/src/apps/work/src/lib/services/index.ts index c5ab10ede..f58b5bd44 100644 --- a/src/apps/work/src/lib/services/index.ts +++ b/src/apps/work/src/lib/services/index.ts @@ -1,6 +1,7 @@ export * from './attachments.service' export * from './ai-review-configs.service' export * from './ai-review-templates.service' +export * from './challenge-review-context.service' export * from './applications.service' export * from './billing-accounts.service' export * from './challenges.service' diff --git a/src/apps/work/src/lib/services/workflow-ai.service.ts b/src/apps/work/src/lib/services/workflow-ai.service.ts index 2d8af3759..4dc7d50d1 100644 --- a/src/apps/work/src/lib/services/workflow-ai.service.ts +++ b/src/apps/work/src/lib/services/workflow-ai.service.ts @@ -8,9 +8,11 @@ import { AI_WORKFLOW_POLL_TIMEOUT_MS, TC_AI_API_BASE_URL, TC_AI_AUTOWRITE_WORKFLOW_ID, + TC_AI_CONTEXT_WORKFLOW_ID, TC_AI_SKILLS_EXTRACTION_WORKFLOW_ID, } from '../constants' import { + ChallengeReviewContextData, Skill, } from '../models' @@ -136,13 +138,10 @@ async function createWorkflowRun(workflowId: string): Promise { async function startWorkflowRun( workflowId: string, runId: string, - description: string, - payloadKey: string = 'jobDescription', + payloadData: any, ): Promise { const payload: WorkflowStartPayload = { - inputData: { - [payloadKey]: description, - }, + inputData: payloadData, } await xhrPostAsync( @@ -178,7 +177,13 @@ async function pollWorkflowRunResult( throw new Error('Workflow request timed out') } - const runStatus = await fetchWorkflowRunStatus(workflowId, runId) + let runStatus: WorkflowRunStatusResponse + try { + runStatus = await fetchWorkflowRunStatus(workflowId, runId) + } catch { + runStatus = {} + } + const status = String(runStatus.status || '') .trim() .toLowerCase() @@ -230,7 +235,7 @@ export async function extractSkillsFromText( await startWorkflowRun( normalizedWorkflowId, runId, - normalizedDescription, + { jobDescription: normalizedDescription }, ) const result = await pollWorkflowRunResult(normalizedWorkflowId, runId, Date.now()) @@ -241,6 +246,76 @@ export async function extractSkillsFromText( } } +function parseWorkflowResultToObject(value: unknown): ChallengeReviewContextData { + if (typeof value === 'string') { + const trimmed = value.trim() + + if (!trimmed) { + throw new Error('Workflow result did not contain any data') + } + + try { + const parsed = JSON.parse(trimmed) + + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw new Error('Workflow result JSON must be an object') + } + + return parsed as ChallengeReviewContextData + } catch { + throw new Error('Workflow result was not valid JSON') + } + } + + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('Workflow result must be an object') + } + + const resultObject = value as Record + + const candidateContext = resultObject.context + const candidateResult = resultObject.result + const candidateOutput = resultObject.output + const candidateData = resultObject.data + + const preferredContext = [candidateContext, candidateResult, candidateOutput, candidateData] + .find(item => item && typeof item === 'object' && !Array.isArray(item)) as Record | undefined + + return (preferredContext || resultObject) as ChallengeReviewContextData +} + +export async function generateChallengeReviewContext( + challengeId: string, + workflowId?: string, +): Promise { + const normalizedWorkflowId = String( + workflowId || TC_AI_CONTEXT_WORKFLOW_ID, + ) + .trim() + + if (!normalizedWorkflowId) { + throw new Error('Workflow ID is required to generate review context') + } + + try { + const runId = await createWorkflowRun(normalizedWorkflowId) + + await startWorkflowRun( + normalizedWorkflowId, + runId, + { + challengeId, + }, + ) + + const result = await pollWorkflowRunResult(normalizedWorkflowId, runId, Date.now()) + + return parseWorkflowResultToObject(result) + } catch (error) { + throw normalizeError(error, 'Failed to generate review context') + } +} + /** * Rewrites an engagement description with the AI autowrite workflow. * @@ -275,8 +350,7 @@ export async function autowriteDescription( await startWorkflowRun( normalizedWorkflowId, runId, - normalizedDescription, - 'rawDescription', + { rawDescription: normalizedDescription }, ) const result = await pollWorkflowRunResult(normalizedWorkflowId, runId, Date.now()) diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md b/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md index 596a5c288..6cbd28f4b 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md @@ -71,7 +71,8 @@ The form uses `challengeBasicInfoSchema` from `src/apps/work/src/lib/schemas/cha - `DesignWorkTypeField`: shown for Design + Challenge, with the legacy work-type options (`Application Front-End Design`, `Print/Presentation`, `Web Design`, `Widget or Mobile Screen Design`, `Wireframes`). The selected value is stored in challenge tags. - `FunChallengeField`: shown for `Marathon Match` type and remains editable after creation so the form can switch between fun-challenge and standard marathon-match fields. - `ReviewersField`: hidden for `Task` and `Marathon Match` challenges because manual reviewer assignment is handled elsewhere. On the human-review tab, each manual reviewer card keeps the legacy review-type dropdown, backfills missing legacy review-type values from the matching default reviewer or iterative-review phase fallback, and each manual reviewer phase selector hides registration/submission phases and any phase already assigned on another manual reviewer card while preserving the card's current selection. When default reviewer metadata is missing, stale, or already covered by existing rows, `Add reviewer` starts from the next unassigned selectable reviewer phase, preferring review phases before approval or screening phases, so single-round Design schedules add the Approver row instead of a registration/submission or duplicate reviewer row. Manual reviewer counts are capped before rendering member assignment controls so closed public opportunities cannot create an unbounded number of member selectors. Design challenge manual reviewers always keep the public review opportunity checkbox disabled and unchecked. -- `Submission Settings`: shown for Design `Challenge` and Design `First2Finish` types, and contains the final-deliverables, stock-art, and submission-visibility controls. +- `Submission Settings`: shown for Design `Challenge` and Design `First2Finish` types, and contains the final-deliverables, stock-art, and Design-specific submission-visibility controls. +- `RegisteredMemberDownloadField`: shown in Advanced Options for every created challenge type. The checkbox persists `allowAllRegistrantsToDownloadWinningSubmissions` as the string `true` or `false`, defaulting to disabled when the metadata is absent. It controls whether every registered member may download winning submissions after the challenge ends. For Design challenges it remains independent from `submissionsViewable`; the existing `Submissions are viewable after challenge ends` setting must be enabled before this expanded registrant access can apply. - `FinalDeliverablesField`: design-challenge file-type editor that persists the legacy `fileTypes` metadata payload used on challenge draft pages. - `MaximumSubmissionsField`: non-visual compatibility field that rewrites the legacy `submissionLimit` metadata to the unlimited-only payload so design challenges no longer expose submission-cap controls. It defers dirtying that automatic normalization until the editor finishes its initial resource hydration, including the first render after asynchronously loaded challenge details arrive, which preserves copilot restoration before autosave/manual-save starts treating the metadata rewrite as a user change. - `ChallengeDescriptionField`: public markdown spec editor. @@ -108,8 +109,9 @@ The form uses `challengeBasicInfoSchema` from `src/apps/work/src/lib/schemas/cha reapplies that refreshed same-id snapshot once it arrives. - Save create/update/delete: `createChallenge`, `patchChallenge`, `deleteChallenge`. - Manual saves for active scheduled challenges refetch the persisted challenge after `patchChallenge` - before resetting or navigating, so an API-rejected active-phase shortening is restored immediately - and the user sees a partial-save warning instead of a misleading success-only state. + before resetting or navigating. Rejected shortening is detected from the submitted and persisted + phase lengths, so an API-rejected edit is restored with a partial-save warning while a scheduler + timing adjustment that shifts an unchanged phase window does not show a false error. - Initial create refresh: after `createChallenge`, the form fetches full challenge details with `fetchChallenge` to avoid round-type regressions from sparse create responses and to surface the generated forum link for challenge types that provision a discussion on create. - Skills search: `searchSkills`. - Tracks fetch: `fetchChallengeTracks`. diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.spec.tsx b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.spec.tsx index 42e5d22fd..9f20c3dd4 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.spec.tsx +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.spec.tsx @@ -372,7 +372,7 @@ jest.mock('./ChallengeScheduleSection', () => ({ index === 0 ? { ...phase, - duration: 1440, + duration: 10080, phaseId: phase?.phaseId || 'submission-phase-id', scheduledEndDate: '2026-04-18T04:58:51.000Z', scheduledStartDate: phase?.scheduledStartDate || '2026-04-11T04:58:51.000Z', @@ -699,6 +699,9 @@ jest.mock('./StockArtsField', () => ({ jest.mock('./SubmissionVisibilityField', () => ({ SubmissionVisibilityField: () => <>Submission Visibility Field, })) +jest.mock('./RegisteredMemberDownloadField', () => ({ + RegisteredMemberDownloadField: () => <>Registered Member Download Field, +})) jest.mock('./SubmissionTypeField', () => ({ SubmissionTypeField: () => <>Submission Type Field, })) @@ -946,6 +949,20 @@ describe('ChallengeEditorForm', () => { .toBeNull() }) + it('renders the registered-member download setting for existing challenges', () => { + render( + + + , + ) + + const advancedOptionsSection = screen.getByRole('heading', { name: 'Advanced Options' }) + .closest('section') + + expect(advancedOptionsSection) + .toHaveTextContent('Registered Member Download Field') + }) + it('renders billing metadata inside prizes and billing when project billing is available', async () => { mockedUseFetchProjectBillingAccount.mockReturnValue({ billingAccount: { @@ -1955,6 +1972,8 @@ describe('ChallengeEditorForm', () => { const submissionSettingsSection = screen.getByRole('heading', { name: 'Submission Settings' }) .closest('section') + const advancedOptionsSection = screen.getByRole('heading', { name: 'Advanced Options' }) + .closest('section') expect(submissionSettingsSection) .toHaveTextContent('Final Deliverables Field') @@ -1964,6 +1983,10 @@ describe('ChallengeEditorForm', () => { .toHaveTextContent('Stock Arts Field') expect(submissionSettingsSection) .toHaveTextContent('Maximum Submissions Field') + expect(submissionSettingsSection) + .not.toHaveTextContent('Registered Member Download Field') + expect(advancedOptionsSection) + .toHaveTextContent('Registered Member Download Field') }) it('keeps submission-limit normalization pristine until initial resource hydration finishes', async () => { @@ -3237,7 +3260,7 @@ describe('ChallengeEditorForm', () => { useSchedulingAPI: true, }, phases: [{ - duration: 1440, + duration: 691200, isOpen: true, name: 'Submission', phaseId: 'submission-phase-id', @@ -3252,7 +3275,7 @@ describe('ChallengeEditorForm', () => { name: 'Active challenge updated', phases: [{ ...activeChallenge.phases?.[0], - duration: 1440, + duration: 604800, scheduledEndDate: '2026-04-18T04:58:51.000Z', }], } as Challenge @@ -3288,6 +3311,66 @@ describe('ChallengeEditorForm', () => { .not.toHaveBeenCalledWith('Challenge saved successfully') }) + it('accepts an immediate phase window shifted later with its duration unchanged', async () => { + const user = userEvent.setup() + const activeChallenge = { + ...validDraftChallenge, + legacy: { + reviewType: 'INTERNAL', + useSchedulingAPI: true, + }, + phases: [{ + duration: 172800, + isOpen: true, + name: 'Registration', + phaseId: 'registration-phase-id', + scheduledEndDate: '2026-07-10T09:35:02.870Z', + scheduledStartDate: '2026-07-08T09:35:02.870Z', + }], + startDate: '2026-07-08T09:35:02.870Z', + status: 'ACTIVE', + } as Challenge + const persistedImmediateSchedule = { + ...activeChallenge, + name: 'Active challenge updated', + phases: [{ + ...activeChallenge.phases?.[0], + actualStartDate: '2026-07-08T09:35:08.037Z', + scheduledEndDate: '2026-07-10T09:35:08.037Z', + scheduledStartDate: '2026-07-08T09:35:08.037Z', + }], + } as Challenge + + mockedPatchChallenge.mockResolvedValue(persistedImmediateSchedule) + mockedFetchChallenge.mockResolvedValue(persistedImmediateSchedule) + + render( + + + + , + ) + + await user.type(screen.getByLabelText('Challenge Name'), ' updated') + await user.click(screen.getByRole('button', { name: 'Update Challenge' })) + + await waitFor(() => { + expect(mockedFetchChallenge) + .toHaveBeenCalledWith('12345') + expect(screen.getByTestId('challenge-schedule-section')) + .toHaveAttribute('data-first-phase-end', '2026-07-10T09:35:08.037Z') + expect(screen.getByTestId('location-display')) + .toHaveTextContent('/projects/100578/challenges/12345/view') + }) + expect(mockedShowErrorToast) + .not.toHaveBeenCalledWith('Active phase shortening cannot be saved. Other challenge changes were saved.') + expect(mockedShowSuccessToast) + .toHaveBeenCalledWith('Challenge saved successfully') + }) + it('blocks saving when an assigned AI workflow has been disabled', async () => { const user = userEvent.setup() diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.tsx b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.tsx index 8bd013de2..b63c13742 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.tsx +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.tsx @@ -164,6 +164,9 @@ import { import { RateChallengeField, } from './RateChallengeField' +import { + RegisteredMemberDownloadField, +} from './RegisteredMemberDownloadField' import { ReviewCostField, } from './ReviewCostField' @@ -1577,6 +1580,22 @@ function getPhaseDateTime(value: Date | string | undefined): number | undefined : parsedDate.getTime() } +/** + * Resolves the canonical phase duration used by the schedule form. + * + * @param phase challenge phase containing a duration value. + * @returns the positive finite duration, or `undefined` when it is unavailable. + */ +function getPhaseDurationValue( + phase: ChallengePhase | undefined, +): number | undefined { + const duration = Number(phase?.duration) + + return Number.isFinite(duration) && duration > 0 + ? duration + : undefined +} + /** * Resolves a stable phase identity for comparing submitted and persisted schedules. * @@ -1602,11 +1621,11 @@ function isOpenPhase(phase: ChallengePhase | undefined): boolean { } /** - * Detects an active phase end date that the API did not shorten. + * Detects an active phase schedule that the API did not shorten. * * @param submittedPhases schedule phases submitted with the save request. * @param persistedPhases schedule phases fetched after the save completed. - * @returns `true` when an open phase still ends later in persisted data than in submitted data. + * @returns `true` when an open phase remains longer in persisted data than in submitted data. */ function hasRejectedActivePhaseShortening( submittedPhases: ChallengeEditorFormData['phases'], @@ -1642,6 +1661,23 @@ function hasRejectedActivePhaseShortening( return false } + const submittedDuration = getPhaseDurationValue(submittedPhase) + const persistedDuration = getPhaseDurationValue(persistedPhase) + + if (submittedDuration !== undefined && persistedDuration !== undefined) { + return submittedDuration < persistedDuration + } + + const submittedStartTime = getPhaseDateTime(submittedPhase.scheduledStartDate) + const persistedStartTime = getPhaseDateTime(persistedPhase.scheduledStartDate) + if ( + submittedStartTime === undefined + || persistedStartTime === undefined + || submittedStartTime !== persistedStartTime + ) { + return false + } + const submittedEndTime = getPhaseDateTime(submittedPhase.scheduledEndDate) const persistedEndTime = getPhaseDateTime(persistedPhase.scheduledEndDate) @@ -4036,6 +4072,7 @@ export const ChallengeEditorForm: FC = ( : undefined} + void +} + +jest.mock('../../../../../lib/components/form', () => ({ + FormCheckboxField: function MockFormCheckboxField(props: MockFormCheckboxFieldProps) { + const React: typeof import('react') = jest.requireActual('react') + const reactHookForm: typeof import('react-hook-form') = jest.requireActual('react-hook-form') + const formContext = reactHookForm.useFormContext() + const controller = reactHookForm.useController({ + control: formContext.control, + name: props.name, + }) + + return React.createElement( + 'label', + { + 'data-checkbox-only-hit-area': props.checkboxOnlyHitArea === true + ? 'true' + : 'false', + }, + React.createElement('input', { + checked: controller.field.value === true, + onChange: (event: { target: { checked: boolean } }) => { + controller.field.onChange(event.target.checked) + props.onChange?.(event.target.checked) + }, + type: 'checkbox', + }), + props.label, + ) + }, +})) + +interface TestHarnessProps { + defaultMetadata?: ChallengeMetadata[] +} + +const MetadataWatcher: FC = () => { + const metadata = useWatch({ + name: 'metadata', + }) + + return {JSON.stringify(metadata || [])} +} + +const TestHarness: FC = (props: TestHarnessProps) => { + const formMethods = useForm({ + defaultValues: { + description: 'Public challenge specification', + metadata: props.defaultMetadata, + name: 'Challenge', + skills: [], + tags: [], + trackId: 'track-id', + typeId: 'type-id', + }, + }) + + return ( + + + + + ) +} + +const SETTING_LABEL = 'Allow all registered members to download winning submissions after challenge ends' +const SETTING_NAME = 'allowAllRegistrantsToDownloadWinningSubmissions' + +describe('RegisteredMemberDownloadField', () => { + it('defaults to disabled when the metadata entry is absent', async () => { + render() + + const checkbox = screen.getByRole('checkbox', { name: SETTING_LABEL }) + + await waitFor(() => { + expect(checkbox) + .not.toBeChecked() + }) + expect(screen.getByTestId('metadata-value').textContent) + .toBe('[]') + }) + + it('persists exact string booleans while preserving unrelated metadata', async () => { + const user = userEvent.setup() + + render( + , + ) + + const checkbox = screen.getByRole('checkbox', { name: SETTING_LABEL }) + + await user.click(checkbox) + + expect(checkbox) + .toBeChecked() + expect(screen.getByTestId('metadata-value').textContent) + .toBe(JSON.stringify([ + { + name: 'existingMetadata', + value: 'keep-me', + }, + { + name: SETTING_NAME, + value: 'true', + }, + ])) + + await user.click(checkbox) + + expect(checkbox) + .not.toBeChecked() + expect(screen.getByTestId('metadata-value').textContent) + .toBe(JSON.stringify([ + { + name: 'existingMetadata', + value: 'keep-me', + }, + { + name: SETTING_NAME, + value: 'false', + }, + ])) + }) + + it('restores enabled metadata without changing the separate Design visibility gate', async () => { + const metadata = [ + { + name: 'submissionsViewable', + value: 'false', + }, + { + name: SETTING_NAME, + value: 'true', + }, + ] + + render() + + await waitFor(() => { + expect(screen.getByRole('checkbox', { name: SETTING_LABEL })) + .toBeChecked() + }) + expect(screen.getByTestId('metadata-value').textContent) + .toBe(JSON.stringify(metadata)) + }) +}) diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/RegisteredMemberDownloadField/RegisteredMemberDownloadField.tsx b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/RegisteredMemberDownloadField/RegisteredMemberDownloadField.tsx new file mode 100644 index 000000000..a4fa3da03 --- /dev/null +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/RegisteredMemberDownloadField/RegisteredMemberDownloadField.tsx @@ -0,0 +1,106 @@ +import { + FC, + useCallback, + useEffect, +} from 'react' +import { + useFormContext, + useWatch, +} from 'react-hook-form' + +import { FormCheckboxField } from '../../../../../lib/components/form' +import { + ChallengeEditorFormData, + ChallengeMetadata, +} from '../../../../../lib/models' +import { + booleanToMetadata, + metadataToBoolean, +} from '../../../../../lib/utils/metadata.utils' + +const REGISTERED_MEMBER_DOWNLOAD_METADATA_FIELD = 'allowAllRegistrantsToDownloadWinningSubmissions' +const REGISTERED_MEMBER_DOWNLOAD_TOGGLE_FIELD = 'allowAllRegistrantsToDownloadWinningSubmissionsToggle' + +interface RegisteredMemberDownloadFormData extends ChallengeEditorFormData { + allowAllRegistrantsToDownloadWinningSubmissionsToggle?: boolean +} + +/** + * Renders the challenge setting that expands winning-submission downloads to every registrant. + * + * The setting defaults to disabled when its metadata entry is absent and persists an exact string + * boolean when changed. It intentionally leaves Design's separate `submissionsViewable` metadata + * untouched so that visibility gate can continue to take precedence. + * + * @returns The registered-member winning-submission download checkbox. + * @throws Does not throw. + */ +export const RegisteredMemberDownloadField: FC = () => { + const formContext = useFormContext() + const metadata = useWatch({ + control: formContext.control, + name: 'metadata', + }) as ChallengeMetadata[] | undefined + const registeredMemberDownloadToggle = useWatch({ + control: formContext.control, + name: REGISTERED_MEMBER_DOWNLOAD_TOGGLE_FIELD, + }) + + const isRegisteredMemberDownloadAllowed = metadataToBoolean( + metadata, + REGISTERED_MEMBER_DOWNLOAD_METADATA_FIELD, + ) + + useEffect(() => { + if (registeredMemberDownloadToggle !== undefined) { + return + } + + formContext.setValue( + REGISTERED_MEMBER_DOWNLOAD_TOGGLE_FIELD, + isRegisteredMemberDownloadAllowed, + { + shouldDirty: false, + shouldValidate: false, + }, + ) + }, [ + formContext, + isRegisteredMemberDownloadAllowed, + registeredMemberDownloadToggle, + ]) + + const handleRegisteredMemberDownloadChange = useCallback((checked: boolean): void => { + if (checked === isRegisteredMemberDownloadAllowed) { + return + } + + formContext.setValue( + 'metadata', + booleanToMetadata( + metadata, + REGISTERED_MEMBER_DOWNLOAD_METADATA_FIELD, + checked, + ), + { + shouldDirty: true, + shouldValidate: true, + }, + ) + }, [ + formContext, + isRegisteredMemberDownloadAllowed, + metadata, + ]) + + return ( + + ) +} + +export default RegisteredMemberDownloadField diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/RegisteredMemberDownloadField/index.ts b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/RegisteredMemberDownloadField/index.ts new file mode 100644 index 000000000..5e568d3b6 --- /dev/null +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/RegisteredMemberDownloadField/index.ts @@ -0,0 +1 @@ +export * from './RegisteredMemberDownloadField' diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/ReviewConfigurationSummary.module.scss b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/ReviewConfigurationSummary.module.scss index dff0874c2..e9bd64144 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/ReviewConfigurationSummary.module.scss +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/ReviewConfigurationSummary.module.scss @@ -205,6 +205,135 @@ text-align: center; } +.reviewContextSection { + display: flex; + flex-direction: column; +} + +.reviewContextList { + display: flex; + flex-direction: column; + gap: 16px; +} + +.reviewContextCard { + background: #fafafa; + border: 1px solid #e6e6e6; + border-radius: 8px; + overflow: hidden; +} + +.reviewContextCardHeader { + align-items: center; + background: #f4f4f4; + border: none; + color: inherit; + display: flex; + gap: 10px; + padding: 14px 16px; + text-align: left; + width: 100%; +} + +.reviewContextCardHeader:hover { + background: #ededed; +} + +.reviewContextCardToggle { + color: #5f6368; + font-size: 12px; + line-height: 1; + min-width: 18px; +} + +.reviewContextCardBody { + padding: 0 16px 16px; +} + +.requirementId { + color: #5f6368; + font-family: 'Inter', sans-serif; + font-size: 13px; + font-weight: 600; +} + +.priorityBadge { + border-radius: 999px; + color: #fff; + display: inline-flex; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.02em; + line-height: 1; + padding: 5px 10px; + text-transform: uppercase; +} + +.priorityHigh { + background: #d32f2f; +} + +.priorityMedium { + background: #f57c00; +} + +.priorityLow { + background: #9e9e9e; +} + +.reviewContextTitle { + color: #242424; + font-size: 15px; + font-weight: 600; +} + +.reviewContextDescription { + color: #444; + font-size: 14px; + line-height: 1.5; + margin: 12px 0; +} + +.reviewContextConstraints { + list-style: disc outside; + margin: 0; + padding-left: 24px; +} + +.reviewContextConstraintItem { + margin-bottom: 8px; + font-size: 13px; +} + +.constraintId { + color: #5f6368; + font-family: 'Inter', sans-serif; + font-weight: 600; + margin-right: 8px; +} + +.constraintText { + color: #333; +} + +.reviewContextErrorState { + align-items: center; + display: flex; + gap: 12px; + justify-content: space-between; +} + +.retryButton { + background: #2a62d5; + border: 1px solid #234fa3; + border-radius: 4px; + color: white; + cursor: pointer; + font-size: 13px; + font-weight: 600; + padding: 8px 12px; +} + .flowSection { display: flex; flex-direction: column; diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/ReviewConfigurationSummary.spec.tsx b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/ReviewConfigurationSummary.spec.tsx index f6684e7c0..7b1ebf6e1 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/ReviewConfigurationSummary.spec.tsx +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/ReviewConfigurationSummary.spec.tsx @@ -1,10 +1,12 @@ /* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */ import { + fireEvent, render, screen, } from '@testing-library/react' import { + useFetchChallengeReviewContext, useFetchResourceRoles, useFetchResources, } from '../../../../../lib/hooks' @@ -19,6 +21,7 @@ import styles from './ReviewConfigurationSummary.module.scss' import { ReviewConfigurationSummary } from './ReviewConfigurationSummary' jest.mock('../../../../../lib/hooks', () => ({ + useFetchChallengeReviewContext: jest.fn(), useFetchResourceRoles: jest.fn(), useFetchResources: jest.fn(), })) @@ -36,6 +39,7 @@ const mockedFetchAiReviewConfigByChallenge = fetchAiReviewConfigByChallenge as j const mockedFetchScorecards = fetchScorecards as jest.Mock const mockedSearchProfilesByUserIds = searchProfilesByUserIds as jest.Mock const mockedFetchWorkflows = fetchWorkflows as jest.Mock +const mockedUseFetchChallengeReviewContext = useFetchChallengeReviewContext as jest.Mock const mockedUseFetchResourceRoles = useFetchResourceRoles as jest.Mock const mockedUseFetchResources = useFetchResources as jest.Mock @@ -88,6 +92,13 @@ describe('ReviewConfigurationSummary', () => { id: 'scorecard-1', name: 'Development Review Scorecard', }]) + mockedUseFetchChallengeReviewContext.mockReturnValue({ + context: undefined, + error: undefined, + isError: false, + isLoading: false, + mutate: jest.fn(), + }) mockedSearchProfilesByUserIds.mockResolvedValue([]) mockedFetchWorkflows.mockResolvedValue([{ id: 'workflow-1', @@ -154,6 +165,138 @@ describe('ReviewConfigurationSummary', () => { }) }) + it('renders review context requirements when review context is available', async () => { + mockedUseFetchChallengeReviewContext.mockReturnValue({ + context: { + challengeId: 'challenge-1', + context: { + challengeId: 'challenge-1', + descriptionRaw: '', + prizes: [], + requirements: [ + { + constraints: [ + { + id: 'CONSTR_01_1', + text: 'Must pass ESLint with zero errors', + }, + ], + description: 'All submissions must follow established coding standards.', + id: 'REQ_01', + priority: 'high', + title: 'Code Quality Standards', + }, + ], + skills: [], + tech_stack: [], + timeline: { + endDate: '2026-12-31', + registrationEndDate: '2025-12-31', + registrationStartDate: '2025-01-01', + startDate: '2026-01-01', + totalDurationDays: 365, + }, + title: 'Review context title', + }, + id: 'review-context-1', + status: 'AI_GENERATED', + }, + error: undefined, + isError: false, + isLoading: false, + mutate: jest.fn(), + }) + + render( + , + ) + + expect(await screen.findByRole('heading', { + level: 5, + name: 'Review Context Requirements (1)', + })).not.toBeNull() + expect(screen.getByText('[REQ_01]')).not.toBeNull() + expect(screen.getByText('HIGH')).not.toBeNull() + expect(screen.getByText('Code Quality Standards')).not.toBeNull() + expect(screen.getByText('All submissions must follow established coding standards.')).not.toBeNull() + expect(screen.getByText('Must pass ESLint with zero errors')).not.toBeNull() + }) + + it('shows empty state when review context has no requirements', async () => { + mockedUseFetchChallengeReviewContext.mockReturnValue({ + context: { + challengeId: 'challenge-1', + context: { + challengeId: 'challenge-1', + descriptionRaw: '', + prizes: [], + requirements: [], + skills: [], + tech_stack: [], + timeline: { + endDate: '2026-12-31', + registrationEndDate: '2025-12-31', + registrationStartDate: '2025-01-01', + startDate: '2026-01-01', + totalDurationDays: 365, + }, + title: 'Review context title', + }, + id: 'review-context-2', + status: 'AI_GENERATED', + }, + error: undefined, + isError: false, + isLoading: false, + mutate: jest.fn(), + }) + + render( + , + ) + + expect(await screen.findByText('No review context requirements defined.')).not.toBeNull() + }) + + it('shows retry button when review context API fails', async () => { + const mockedMutate = jest.fn() + .mockResolvedValue(undefined) + mockedUseFetchChallengeReviewContext.mockReturnValue({ + context: undefined, + error: 'Failed to load review context.', + isError: true, + isLoading: false, + mutate: mockedMutate, + }) + + render( + , + ) + + expect(await screen.findByText('Failed to load review context.')).not.toBeNull() + + const retryButton = screen.getByRole('button', { name: 'Retry' }) + fireEvent.click(retryButton) + + expect(mockedMutate) + .toHaveBeenCalled() + }) + it('loads referenced human-review scorecard names from later scorecard catalog pages', async () => { mockedFetchAiReviewConfigByChallenge.mockResolvedValue(undefined) mockedFetchScorecards.mockImplementation(({ page }: { page?: number }) => Promise.resolve( @@ -447,6 +590,20 @@ describe('ReviewConfigurationSummary', () => { id: 'scorecard-approval', name: 'Approval Scorecard', }]) + mockedUseFetchChallengeReviewContext.mockReturnValue({ + context: undefined, + error: undefined, + isError: false, + isLoading: false, + mutate: jest.fn(), + }) + mockedUseFetchChallengeReviewContext.mockReturnValue({ + context: undefined, + error: undefined, + isError: false, + isLoading: false, + mutate: jest.fn(), + }) render( = ( workflowMap, ], ) + const referencedScorecardIds = useMemo( () => getReferencedScorecardIds(humanReviewers, workflowsToDisplay, workflowMap), [ @@ -1071,6 +1073,8 @@ export const ReviewConfigurationSummary: FC = ( ) : undefined} + + {humanReviewers.length ? (
diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/ReviewContextSection.tsx b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/ReviewContextSection.tsx new file mode 100644 index 000000000..d6aa6d48f --- /dev/null +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/ReviewContextSection.tsx @@ -0,0 +1,194 @@ +/* eslint-disable max-len */ + +import { + FC, + useCallback, + useEffect, + useMemo, + useState, +} from 'react' +import classNames from 'classnames' + +import { useFetchChallengeReviewContext } from '../../../../../lib/hooks' + +import { normalizeReviewerText } from './reviewers-field.utils' +import styles from './ReviewConfigurationSummary.module.scss' + +interface ReviewContextSectionProps { + challengeId?: string +} + +export const ReviewContextSection: FC = props => { + const reviewContextResult = useFetchChallengeReviewContext(props.challengeId) + const reviewContextRequirements = useMemo(() => { + const requirements = reviewContextResult.context?.context?.requirements + + if (!Array.isArray(requirements)) { + return [] + } + + return [...requirements].sort((a, b) => a.id.localeCompare(b.id)) + }, [reviewContextResult.context]) + const reviewContextLoading = reviewContextResult.isLoading + const reviewContextError = reviewContextResult.error + const hasReviewContextRequirements = reviewContextRequirements.length > 0 + const [expandedReviewContextIds, setExpandedReviewContextIds] = useState([]) + + useEffect(() => { + if (reviewContextLoading || reviewContextError) { + return + } + + setExpandedReviewContextIds( + reviewContextRequirements.length > 0 + ? [reviewContextRequirements[0].id] + : [], + ) + }, [reviewContextError, reviewContextLoading, reviewContextRequirements]) + + const handleToggleRequirement = useCallback((requirementId: string): void => { + setExpandedReviewContextIds(prev => { + if (prev.includes(requirementId)) { + return prev.filter(id => id !== requirementId) + } + + return [...prev, requirementId] + }) + }, []) + + const handleRetryReviewContext = useCallback(async (): Promise => { + if (!reviewContextResult.mutate) { + return + } + + await reviewContextResult.mutate() + }, [reviewContextResult]) + + if (!props.challengeId) { + return <> + } + + return ( +
+
+ +
+ Review Context Requirements + {' '} + ( + {reviewContextRequirements.length} + ) +
+
+
+ {reviewContextLoading + ? ( +
+ Loading review context requirements... +
+ ) + : reviewContextError + ? ( +
+
+ {reviewContextError} +
+ +
+ ) + : hasReviewContextRequirements + ? ( +
+ {reviewContextRequirements.map(requirement => { + const priority = normalizeReviewerText(requirement.priority) || 'medium' + const priorityClass = priority === 'high' + ? styles.priorityHigh + : priority === 'low' + ? styles.priorityLow + : styles.priorityMedium + const isExpanded = expandedReviewContextIds.includes(requirement.id) + + return ( +
+ + +
+ ) + })} +
+ ) + : ( +
+ No review context requirements defined. +
+ )} +
+
+ ) +} diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/ReviewContextTab/ReviewContextEditor.module.scss b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/ReviewContextTab/ReviewContextEditor.module.scss new file mode 100644 index 000000000..2d7419a5f --- /dev/null +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/ReviewContextTab/ReviewContextEditor.module.scss @@ -0,0 +1,156 @@ +@import '@libs/ui/styles/includes'; + +.wrap { + display: flex; + flex-direction: column; + gap: 24px; +} + +.toolbar { + align-items: center; + display: flex; + justify-content: space-between; + gap: 16px; +} + +.statusBlock { + display: flex; + flex-direction: column; + gap: 4px; +} + +.statusText { + color: #28303f; + font-weight: 600; +} + +.saveError, +.validationMessage { + color: #b00020; + font-size: 13px; +} + +.emptyState { + background: #f7f9fc; + border: 1px dashed #cbd4e7; + border-radius: 8px; + padding: 24px; + text-align: center; +} + +.requirements { + display: flex; + flex-direction: column; + gap: 20px; +} + +.requirementCard { + background: #ffffff; + border: 1px solid #dfe4ee; + border-radius: 12px; + padding: 20px; + display: flex; + flex-direction: column; + gap: 20px; +} + +.requirementHeader { + align-items: center; + display: flex; + justify-content: space-between; + gap: 12px; +} + +.requirementHeaderText { + color: #1f2937; + font-weight: 700; +} + +.fieldRow { + display: flex; + flex-direction: column; + gap: 16px; +} + +.twoColumn { + display: grid; + grid-template-columns: 1fr 240px; + gap: 16px; +} + +.constraintsSection { + border-top: 1px solid #e5e9f2; + padding-top: 16px; + display: flex; + flex-direction: column; + gap: 12px; +} + +.constraintsHeader { + align-items: center; + display: flex; + justify-content: space-between; + gap: 12px; + font-weight: 600; +} + +.constraintList { + display: flex; + flex-direction: column; + gap: 12px; +} + +.constraintItem { + align-items: center; + background: #f7f9fc; + border: 1px solid #dfe4ee; + border-radius: 8px; + display: flex; + justify-content: space-between; + padding: 12px 16px; + gap: 12px; +} + +.constraintItemInvalid { + border-color: #b00020; +} + +.constraintText { + color: #28303f; + flex: 1; +} + +.fieldError { + color: #b00020; + font-size: 13px; +} + +.infoBanner { + background: #f1f5f9; + border: 1px solid #d1d9e6; + border-radius: 8px; + color: #334155; + padding: 16px; +} + +.constraintDraft { + display: flex; + flex-direction: column; + gap: 8px; +} + +.constraintDraftInput { + border: 1px solid #dfe4ee; + border-radius: 8px; + padding: 10px 12px; + width: 100%; +} + +.constraintDraftInputError { + border-color: #b00020; +} + +.constraintDraftHint { + color: #6b7280; + font-size: 12px; +} diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/ReviewContextTab/ReviewContextEditor.spec.tsx b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/ReviewContextTab/ReviewContextEditor.spec.tsx new file mode 100644 index 000000000..4897d68f6 --- /dev/null +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/ReviewContextTab/ReviewContextEditor.spec.tsx @@ -0,0 +1,158 @@ +/* eslint-disable import/no-extraneous-dependencies */ +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { updateChallengeReviewContext } from '~/apps/work/src/lib/services' +import { ChallengeReviewContextData } from '~/apps/work/src/lib/models' + +import ReviewContextEditor, { + validateReviewContext, +} from './ReviewContextEditor' + +jest.mock('~/apps/work/src/lib/services', () => ({ + updateChallengeReviewContext: jest.fn(), +})) +jest.mock('~/apps/work/src/lib', () => ({ + showErrorToast: jest.fn(), +})) +jest.mock('./ReviewContextRawEditor', () => function ReviewContextRawEditor(): JSX.Element { + return
Raw editor
+}) +jest.mock('~/libs/ui', () => ({ + Button: (props: { label: string; onClick: () => void }): JSX.Element => ( + + ), + InputSelect: (props: { value: string }): JSX.Element => ( + + ), + InputText: (props: { value: string }): JSX.Element => ( + + ), + InputTextarea: (props: { value: string }): JSX.Element => ( +