diff --git a/src/apps/admin/src/challenge-management/ManageSubmissionPage/ManageSubmissionPage.tsx b/src/apps/admin/src/challenge-management/ManageSubmissionPage/ManageSubmissionPage.tsx index bbc6a4c25..41542a7e8 100644 --- a/src/apps/admin/src/challenge-management/ManageSubmissionPage/ManageSubmissionPage.tsx +++ b/src/apps/admin/src/challenge-management/ManageSubmissionPage/ManageSubmissionPage.tsx @@ -18,6 +18,8 @@ import { useManageBusEventProps, useManageChallengeSubmissions, useManageChallengeSubmissionsProps, + useManageSubmissionReprocess, + useManageSubmissionReprocessProps, } from '../../lib/hooks' import { ActionLoading, @@ -26,7 +28,7 @@ import { TableLoading, TableNoRecord, } from '../../lib' -import { checkIsMM } from '../../lib/utils' +import { checkIsMM, getSubmissionReprocessTopic } from '../../lib/utils' import styles from './ManageSubmissionPage.module.scss' @@ -46,6 +48,10 @@ export const ManageSubmissionPage: FC = (props: Props) => { challengeInfo, }: useFetchChallengeProps = useFetchChallenge(challengeId) const isMM = useMemo(() => checkIsMM(challengeInfo), [challengeInfo]) + const submissionReprocessTopic = useMemo( + () => getSubmissionReprocessTopic(challengeInfo), + [challengeInfo], + ) const { isLoading: isLoadingSubmission, @@ -71,6 +77,12 @@ export const ManageSubmissionPage: FC = (props: Props) => { isLoadingBool: isDoingAvScanBool, doPostBusEvent: doPostBusEventAvScan, }: useManageAVScanProps = useManageAVScan() + const { + isLoading: isReprocessingSubmission, + isLoadingBool: isReprocessingSubmissionBool, + doReprocessSubmission, + }: useManageSubmissionReprocessProps + = useManageSubmissionReprocess(submissionReprocessTopic) const isLoading = isLoadingSubmission || isLoadingChallenge @@ -111,13 +123,21 @@ export const ManageSubmissionPage: FC = (props: Props) => { showSubmissionHistory={showSubmissionHistory} setShowSubmissionHistory={setShowSubmissionHistory} isMM={isMM} + isReprocessingSubmission={ + isReprocessingSubmission + } + doReprocessSubmission={doReprocessSubmission} + canReprocessSubmission={Boolean( + submissionReprocessTopic, + )} /> {(isDoingAvScanBool || isDownloadingSubmissionBool || isRemovingSubmissionBool || isRunningTestBool - || isRemovingReviewSummationsBool) && ( + || isRemovingReviewSummationsBool + || isReprocessingSubmissionBool) && ( )} diff --git a/src/apps/admin/src/config/busEvent.config.ts b/src/apps/admin/src/config/busEvent.config.ts index 85888b5dc..e7eb52567 100644 --- a/src/apps/admin/src/config/busEvent.config.ts +++ b/src/apps/admin/src/config/busEvent.config.ts @@ -7,6 +7,8 @@ import { RequestBusAPI, RequestBusAPIAVScan, RequestBusAPIAVScanPayload, + RequestBusAPIReprocess, + SubmissionReprocessPayload, } from '../lib/models' /** @@ -47,3 +49,26 @@ export const CREATE_BUS_EVENT_AV_RESCAN = ( .toISOString(), topic: 'avscan.action.scan', }) + +export const SUBMISSION_REPROCESS_TOPICS = { + FIRST2FINISH: 'first2finish.submission.received', + TOPGEAR_TASK: 'topgear.submission.received', +} + +/** + * Create data for submission reprocess bus event + * @param topic kafka topic + * @param payload submission data + * @returns data for bus event + */ +export const CREATE_BUS_EVENT_REPROCESS_SUBMISSION = ( + topic: string, + payload: SubmissionReprocessPayload, +): RequestBusAPIReprocess => ({ + 'mime-type': 'application/json', + originator: 'review-api-v6', + payload, + timestamp: new Date() + .toISOString(), + topic, +}) diff --git a/src/apps/admin/src/lib/components/SubmissionTable/SubmissionTable.tsx b/src/apps/admin/src/lib/components/SubmissionTable/SubmissionTable.tsx index 5217a13b4..467309749 100644 --- a/src/apps/admin/src/lib/components/SubmissionTable/SubmissionTable.tsx +++ b/src/apps/admin/src/lib/components/SubmissionTable/SubmissionTable.tsx @@ -36,6 +36,9 @@ interface Props { downloadSubmission: (submissionId: string) => void isDoingAvScan: IsRemovingType doPostBusEventAvScan: (submission: Submission) => void + isReprocessingSubmission: IsRemovingType + doReprocessSubmission: (submission: Submission) => void + canReprocessSubmission?: boolean } export const SubmissionTable: FC = (props: Props) => { @@ -229,6 +232,15 @@ export const SubmissionTable: FC = (props: Props) => { doPostBusEventAvScan={function doPostBusEventAvScan() { props.doPostBusEventAvScan(data) }} + canReprocessSubmission={ + props.canReprocessSubmission + } + isReprocessingSubmission={ + props.isReprocessingSubmission + } + doReprocessSubmission={function doReprocessSubmission() { + props.doReprocessSubmission(data) + }} isDownloading={props.isDownloading} downloadSubmission={function downloadSubmission() { props.downloadSubmission(data.id) @@ -258,6 +270,9 @@ export const SubmissionTable: FC = (props: Props) => { props.downloadSubmission, props.isDoingAvScan, props.doPostBusEventAvScan, + props.isReprocessingSubmission, + props.doReprocessSubmission, + props.canReprocessSubmission, ], ) diff --git a/src/apps/admin/src/lib/components/SubmissionTable/SubmissionTableActionsNonMM.tsx b/src/apps/admin/src/lib/components/SubmissionTable/SubmissionTableActionsNonMM.tsx index 42ffc4f22..4998515a4 100644 --- a/src/apps/admin/src/lib/components/SubmissionTable/SubmissionTableActionsNonMM.tsx +++ b/src/apps/admin/src/lib/components/SubmissionTable/SubmissionTableActionsNonMM.tsx @@ -15,6 +15,9 @@ interface Props { downloadSubmission: () => void isDoingAvScan: IsRemovingType doPostBusEventAvScan: () => void + canReprocessSubmission?: boolean + isReprocessingSubmission: IsRemovingType + doReprocessSubmission: () => void } export const SubmissionTableActionsNonMM: FC = (props: Props) => ( @@ -39,6 +42,17 @@ export const SubmissionTableActionsNonMM: FC = (props: Props) => ( AV Rescan )} + {props.canReprocessSubmission && ( + + )} ) diff --git a/src/apps/admin/src/lib/hooks/index.ts b/src/apps/admin/src/lib/hooks/index.ts index 20849e29b..2d6dd1c34 100644 --- a/src/apps/admin/src/lib/hooks/index.ts +++ b/src/apps/admin/src/lib/hooks/index.ts @@ -36,3 +36,4 @@ export * from './useAutoScrollTopWhenInit' export * from './useFetchChallenge' export * from './useDownloadSubmission' export * from './useManageAVScan' +export * from './useManageSubmissionReprocess' diff --git a/src/apps/admin/src/lib/hooks/useManageSubmissionReprocess.ts b/src/apps/admin/src/lib/hooks/useManageSubmissionReprocess.ts new file mode 100644 index 000000000..60568a33e --- /dev/null +++ b/src/apps/admin/src/lib/hooks/useManageSubmissionReprocess.ts @@ -0,0 +1,78 @@ +/** + * Manage submission reprocess event + */ +import { useCallback, useMemo, useState } from 'react' +import { toast } from 'react-toastify' +import _ from 'lodash' + +import { + CREATE_BUS_EVENT_REPROCESS_SUBMISSION, +} from '../../config/busEvent.config' +import { createSubmissionReprocessPayload, reqToBusAPI } from '../services' +import { handleError } from '../utils' +import { IsRemovingType, Submission } from '../models' + +export interface useManageSubmissionReprocessProps { + isLoading: IsRemovingType + isLoadingBool: boolean + doReprocessSubmission: (submission: Submission) => Promise +} + +/** + * Manage submission reprocess + */ +export function useManageSubmissionReprocess( + topic?: string, +): useManageSubmissionReprocessProps { + const [isLoading, setIsLoading] = useState({}) + const isLoadingBool = useMemo( + () => _.some(isLoading, value => value === true), + [isLoading], + ) + + const doReprocessSubmission = useCallback( + async (submission: Submission) => { + if (!topic) { + toast.error( + 'Submission reprocess is only available for specific challenge types.', + { + toastId: 'Reprocess submission', + }, + ) + return + } + + setIsLoading(previous => ({ + ...previous, + [submission.id]: true, + })) + + try { + const payload + = await createSubmissionReprocessPayload(submission) + const data = CREATE_BUS_EVENT_REPROCESS_SUBMISSION( + topic, + payload, + ) + await reqToBusAPI(data) + toast.success('Reprocess submission request sent', { + toastId: 'Reprocess submission', + }) + } catch (error) { + handleError(error as Error) + } finally { + setIsLoading(previous => ({ + ...previous, + [submission.id]: false, + })) + } + }, + [topic], + ) + + return { + doReprocessSubmission, + isLoading, + isLoadingBool, + } +} diff --git a/src/apps/admin/src/lib/models/CommonRequestBusAPI.type.ts b/src/apps/admin/src/lib/models/CommonRequestBusAPI.type.ts index 905bb7b70..a63590e55 100644 --- a/src/apps/admin/src/lib/models/CommonRequestBusAPI.type.ts +++ b/src/apps/admin/src/lib/models/CommonRequestBusAPI.type.ts @@ -1,7 +1,10 @@ import { RequestBusAPI } from './RequestBusAPI.model' import { RequestBusAPIAVScan } from './RequestBusAPIAVScan.model' +import { RequestBusAPIReprocess } from './RequestBusAPIReprocess.model' /** * Common type for bus api request */ -export type CommonRequestBusAPI = RequestBusAPI | RequestBusAPIAVScan +export type CommonRequestBusAPI = RequestBusAPI +| RequestBusAPIAVScan +| RequestBusAPIReprocess diff --git a/src/apps/admin/src/lib/models/RequestBusAPIReprocess.model.ts b/src/apps/admin/src/lib/models/RequestBusAPIReprocess.model.ts new file mode 100644 index 000000000..85bdf02f4 --- /dev/null +++ b/src/apps/admin/src/lib/models/RequestBusAPIReprocess.model.ts @@ -0,0 +1,19 @@ +/** + * Request to reprocess submission bus api + */ +export interface SubmissionReprocessPayload { + submissionId: string + challengeId: string + submissionUrl: string + memberHandle: string + memberId: string + submittedDate: string +} + +export interface RequestBusAPIReprocess { + topic: string + originator: string + timestamp: string + 'mime-type': string + payload: SubmissionReprocessPayload +} diff --git a/src/apps/admin/src/lib/models/index.ts b/src/apps/admin/src/lib/models/index.ts index 8529d2e2f..c20287261 100644 --- a/src/apps/admin/src/lib/models/index.ts +++ b/src/apps/admin/src/lib/models/index.ts @@ -35,6 +35,7 @@ export * from './RoleMemberInfo.model' export * from './Submission.model' export * from './RequestBusAPI.model' export * from './RequestBusAPIAVScan.model' +export * from './RequestBusAPIReprocess.model' export * from './MemberSubmission.model' export * from './SubmissionReviewSummation.model' export * from './SSOUserLogin.model' diff --git a/src/apps/admin/src/lib/services/submissions.service.ts b/src/apps/admin/src/lib/services/submissions.service.ts index 69b3ce76d..390f794e0 100644 --- a/src/apps/admin/src/lib/services/submissions.service.ts +++ b/src/apps/admin/src/lib/services/submissions.service.ts @@ -10,6 +10,7 @@ import { MemberSubmission, RequestBusAPIAVScanPayload, Submission, + SubmissionReprocessPayload, ValidateS3URIResult, } from '../models' import { validateS3URI } from '../utils' @@ -125,3 +126,62 @@ export const createAvScanSubmissionPayload = async ( url, } } + +/** + * Create submission reprocess payload + * @param submissionInfo submission info + * @returns resolves to the reprocess payload + */ +export const createSubmissionReprocessPayload = async ( + submissionInfo: Submission, +): Promise => { + const submissionId = submissionInfo.id + const challengeId = submissionInfo.challengeId + const submissionUrl = submissionInfo.url + const memberId = submissionInfo.memberId + const memberHandle = submissionInfo.submitterHandle ?? submissionInfo.createdBy + const submittedDateValue = submissionInfo.submittedDate + + if (!submissionId) { + throw new Error('Submission id is not valid') + } + + const normalizedChallengeId = challengeId + ? challengeId.toString() + : '' + if (!normalizedChallengeId) { + throw new Error('Challenge id is not valid') + } + + if (!submissionUrl) { + throw new Error('Submission url is not valid') + } + + const normalizedMemberId = memberId ? memberId.toString() : '' + if (!normalizedMemberId) { + throw new Error('Member id is not valid') + } + + if (!memberHandle) { + throw new Error('Member handle is not valid') + } + + const submittedDate = submittedDateValue + ? new Date(submittedDateValue) + : undefined + const submittedDateIso = submittedDate && !Number.isNaN(submittedDate.valueOf()) + ? submittedDate.toISOString() + : '' + if (!submittedDateIso) { + throw new Error('Submitted date is not valid') + } + + return { + challengeId: normalizedChallengeId, + memberHandle, + memberId: normalizedMemberId, + submissionId, + submissionUrl, + submittedDate: submittedDateIso, + } +} diff --git a/src/apps/admin/src/lib/utils/api.ts b/src/apps/admin/src/lib/utils/api.ts index b92d97848..0735f7757 100644 --- a/src/apps/admin/src/lib/utils/api.ts +++ b/src/apps/admin/src/lib/utils/api.ts @@ -51,34 +51,38 @@ export const handleError = (error: any): void => { export const createChallengeQueryString = ( filterCriteria: ChallengeFilterCriteria, ): string => { - let filter = '' - filter = `page=${filterCriteria.page}&perPage=${filterCriteria.perPage}` + const params: Record = { + page: filterCriteria.page, + perPage: filterCriteria.perPage, + sortBy: 'createdAt', + sortOrder: 'desc', + } if (filterCriteria.legacyId) { - filter += `&legacyId=${filterCriteria.legacyId}` + params.legacyId = filterCriteria.legacyId } if (filterCriteria.type) { - filter += `&types[]=${filterCriteria.type}` + params.types = [filterCriteria.type] } if (filterCriteria.track) { - filter += `&tracks[]=${filterCriteria.track}` + params.tracks = [filterCriteria.track] } if (filterCriteria.challengeId) { - filter += `&id=${filterCriteria.challengeId}` + params.id = filterCriteria.challengeId } if (filterCriteria.name) { - filter += `&name=${filterCriteria.name}` + params.name = filterCriteria.name } - if (filterCriteria.status) filter += `&status=${filterCriteria.status}` - - filter += '&sortBy=createdAt&sortOrder=desc' + if (filterCriteria.status) { + params.status = filterCriteria.status + } - return filter + return qs.stringify(params, { arrayFormat: 'brackets', skipNulls: true }) } export const createReviewQueryString = ( diff --git a/src/apps/admin/src/lib/utils/challenge.ts b/src/apps/admin/src/lib/utils/challenge.ts index 5ceb1eaff..864a9f965 100644 --- a/src/apps/admin/src/lib/utils/challenge.ts +++ b/src/apps/admin/src/lib/utils/challenge.ts @@ -4,6 +4,7 @@ import _ from 'lodash' +import { SUBMISSION_REPROCESS_TOPICS } from '../../config/busEvent.config' import { Challenge, MemberSubmission } from '../models' /** @@ -18,6 +19,30 @@ export function checkIsMM(challenge?: Challenge): boolean { return tags.includes('Marathon Match') || isMMType } +/** + * Resolve submission reprocess topic based on challenge type + * @param challenge challenge info + * @returns kafka topic for submission reprocess + */ +export function getSubmissionReprocessTopic( + challenge?: Challenge, +): string | undefined { + const normalizedType = challenge?.type?.name + ? challenge.type.name.replace(/\s+/g, '') + .toLowerCase() + : '' + + if (normalizedType === 'first2finish') { + return SUBMISSION_REPROCESS_TOPICS.FIRST2FINISH + } + + if (normalizedType === 'topgeartask') { + return SUBMISSION_REPROCESS_TOPICS.TOPGEAR_TASK + } + + return undefined +} + /** * Process each submission rank of MM challenge * @param submissions the array of submissions