diff --git a/client/src/graphql/generated.ts b/client/src/graphql/generated.ts index 58217bef..1f8a8b2e 100644 --- a/client/src/graphql/generated.ts +++ b/client/src/graphql/generated.ts @@ -1373,6 +1373,32 @@ export type GQLGooglePlaceLocationInfo = { readonly id: Scalars['ID']['output']; }; +export type GQLHandleTime = { + readonly __typename: 'HandleTime'; + readonly handleTimeSeconds?: Maybe; + readonly queueId?: Maybe; + readonly reviewerId?: Maybe; +}; + +export type GQLHandleTimeFilterByInput = { + readonly endDate: Scalars['DateTime']['input']; + readonly queueIds: ReadonlyArray; + readonly reviewerIds: ReadonlyArray; + readonly startDate: Scalars['DateTime']['input']; +}; + +export const GQLHandleTimeGroupByColumns = { + QueueId: 'QUEUE_ID', + ReviewerId: 'REVIEWER_ID', +} as const; + +export type GQLHandleTimeGroupByColumns = + (typeof GQLHandleTimeGroupByColumns)[keyof typeof GQLHandleTimeGroupByColumns]; +export type GQLHandleTimeInput = { + readonly filterBy: GQLHandleTimeFilterByInput; + readonly groupBy: ReadonlyArray; +}; + export type GQLHashBank = { readonly __typename: 'HashBank'; readonly description?: Maybe; @@ -2117,12 +2143,14 @@ export type GQLManualReviewChartSettingsInput = { export type GQLManualReviewDecision = { readonly __typename: 'ManualReviewDecision'; + readonly assignedAt?: Maybe; readonly createdAt: Scalars['DateTime']['output']; readonly decisionReason?: Maybe; readonly decisions: ReadonlyArray; readonly id: Scalars['String']['output']; readonly itemId?: Maybe; readonly itemTypeId?: Maybe; + readonly jobCreatedAt?: Maybe; readonly jobId: Scalars['String']['output']; readonly queueId: Scalars['String']['output']; readonly relatedActions: ReadonlyArray; @@ -3448,6 +3476,7 @@ export type GQLQuery = { readonly getExistingJobsForItem: ReadonlyArray; readonly getFullReportingRuleResultForItem: GQLGetFullReportingRuleResultForItemResponse; readonly getFullRuleResultForItem: GQLGetFullResultForItemResponse; + readonly getHandleTime?: Maybe>; readonly getJobCreationCounts: ReadonlyArray; readonly getRecentDecisions: ReadonlyArray; readonly getResolvedJobCounts: ReadonlyArray; @@ -3547,6 +3576,10 @@ export type GQLQueryGetFullRuleResultForItemArgs = { input: GQLGetFullResultForItemInput; }; +export type GQLQueryGetHandleTimeArgs = { + input: GQLHandleTimeInput; +}; + export type GQLQueryGetJobCreationCountsArgs = { input: GQLGetJobCreationCountInput; }; @@ -9653,6 +9686,8 @@ export type GQLGetDecidedJobFromJobIdQuery = { readonly jobId: string; readonly decisionReason?: string | null; readonly createdAt: Date | string; + readonly assignedAt?: Date | string | null; + readonly jobCreatedAt?: Date | string | null; readonly decisions: ReadonlyArray< | { readonly __typename: 'AcceptAppealDecisionComponent'; @@ -9779,6 +9814,20 @@ export type GQLGetAverageTimeToReviewQuery = { }> | null; }; +export type GQLGetAverageHandleTimeSummaryQueryVariables = Exact<{ + input: GQLHandleTimeInput; +}>; + +export type GQLGetAverageHandleTimeSummaryQuery = { + readonly __typename: 'Query'; + readonly getHandleTime?: ReadonlyArray<{ + readonly __typename: 'HandleTime'; + readonly handleTimeSeconds?: number | null; + readonly reviewerId?: string | null; + readonly queueId?: string | null; + }> | null; +}; + export type GQLGetDecisionsTableQueryVariables = Exact<{ input: GQLGetDecisionCountsTableInput; }>; @@ -10787,6 +10836,8 @@ export type GQLGetRecentDecisionsQuery = { readonly itemId?: string | null; readonly itemTypeId?: string | null; readonly createdAt: Date | string; + readonly assignedAt?: Date | string | null; + readonly jobCreatedAt?: Date | string | null; readonly decisionReason?: string | null; readonly decisions: ReadonlyArray< | { @@ -18542,6 +18593,20 @@ export type GQLReorderRoutingRulesMutation = { }; }; +export type GQLGetAverageHandleTimeQueryVariables = Exact<{ + input: GQLHandleTimeInput; +}>; + +export type GQLGetAverageHandleTimeQuery = { + readonly __typename: 'Query'; + readonly getHandleTime?: ReadonlyArray<{ + readonly __typename: 'HandleTime'; + readonly handleTimeSeconds?: number | null; + readonly reviewerId?: string | null; + readonly queueId?: string | null; + }> | null; +}; + export type GQLManualReviewChartConfigurationSettingsQueryVariables = Exact<{ [key: string]: never; }>; @@ -32170,6 +32235,8 @@ export const GQLGetDecidedJobFromJobIdDocument = gql` } } createdAt + assignedAt + jobCreatedAt } } } @@ -32478,6 +32545,114 @@ export type GQLGetAverageTimeToReviewQueryResult = Apollo.QueryResult< GQLGetAverageTimeToReviewQuery, GQLGetAverageTimeToReviewQueryVariables >; +export const GQLGetAverageHandleTimeSummaryDocument = gql` + query getAverageHandleTimeSummary($input: HandleTimeInput!) { + getHandleTime(input: $input) { + handleTimeSeconds + reviewerId + queueId + } + } +`; + +/** + * __useGQLGetAverageHandleTimeSummaryQuery__ + * + * To run a query within a React component, call `useGQLGetAverageHandleTimeSummaryQuery` and pass it any options that fit your needs. + * When your component renders, `useGQLGetAverageHandleTimeSummaryQuery` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useGQLGetAverageHandleTimeSummaryQuery({ + * variables: { + * input: // value for 'input' + * }, + * }); + */ +export function useGQLGetAverageHandleTimeSummaryQuery( + baseOptions: Apollo.QueryHookOptions< + GQLGetAverageHandleTimeSummaryQuery, + GQLGetAverageHandleTimeSummaryQueryVariables + > & + ( + | { + variables: GQLGetAverageHandleTimeSummaryQueryVariables; + skip?: boolean; + } + | { skip: boolean } + ), +) { + const options = { ...defaultOptions, ...baseOptions }; + return Apollo.useQuery< + GQLGetAverageHandleTimeSummaryQuery, + GQLGetAverageHandleTimeSummaryQueryVariables + >(GQLGetAverageHandleTimeSummaryDocument, options); +} +export function useGQLGetAverageHandleTimeSummaryLazyQuery( + baseOptions?: Apollo.LazyQueryHookOptions< + GQLGetAverageHandleTimeSummaryQuery, + GQLGetAverageHandleTimeSummaryQueryVariables + >, +) { + const options = { ...defaultOptions, ...baseOptions }; + return Apollo.useLazyQuery< + GQLGetAverageHandleTimeSummaryQuery, + GQLGetAverageHandleTimeSummaryQueryVariables + >(GQLGetAverageHandleTimeSummaryDocument, options); +} +// @ts-ignore +export function useGQLGetAverageHandleTimeSummarySuspenseQuery( + baseOptions?: Apollo.SuspenseQueryHookOptions< + GQLGetAverageHandleTimeSummaryQuery, + GQLGetAverageHandleTimeSummaryQueryVariables + >, +): Apollo.UseSuspenseQueryResult< + GQLGetAverageHandleTimeSummaryQuery, + GQLGetAverageHandleTimeSummaryQueryVariables +>; +export function useGQLGetAverageHandleTimeSummarySuspenseQuery( + baseOptions?: + | Apollo.SkipToken + | Apollo.SuspenseQueryHookOptions< + GQLGetAverageHandleTimeSummaryQuery, + GQLGetAverageHandleTimeSummaryQueryVariables + >, +): Apollo.UseSuspenseQueryResult< + GQLGetAverageHandleTimeSummaryQuery | undefined, + GQLGetAverageHandleTimeSummaryQueryVariables +>; +export function useGQLGetAverageHandleTimeSummarySuspenseQuery( + baseOptions?: + | Apollo.SkipToken + | Apollo.SuspenseQueryHookOptions< + GQLGetAverageHandleTimeSummaryQuery, + GQLGetAverageHandleTimeSummaryQueryVariables + >, +) { + const options = + baseOptions === Apollo.skipToken + ? baseOptions + : { ...defaultOptions, ...baseOptions }; + return Apollo.useSuspenseQuery< + GQLGetAverageHandleTimeSummaryQuery, + GQLGetAverageHandleTimeSummaryQueryVariables + >(GQLGetAverageHandleTimeSummaryDocument, options); +} +export type GQLGetAverageHandleTimeSummaryQueryHookResult = ReturnType< + typeof useGQLGetAverageHandleTimeSummaryQuery +>; +export type GQLGetAverageHandleTimeSummaryLazyQueryHookResult = ReturnType< + typeof useGQLGetAverageHandleTimeSummaryLazyQuery +>; +export type GQLGetAverageHandleTimeSummarySuspenseQueryHookResult = ReturnType< + typeof useGQLGetAverageHandleTimeSummarySuspenseQuery +>; +export type GQLGetAverageHandleTimeSummaryQueryResult = Apollo.QueryResult< + GQLGetAverageHandleTimeSummaryQuery, + GQLGetAverageHandleTimeSummaryQueryVariables +>; export const GQLGetDecisionsTableDocument = gql` query getDecisionsTable($input: GetDecisionCountsTableInput!) { getDecisionsTable(input: $input) { @@ -34025,6 +34200,8 @@ export const GQLGetRecentDecisionsDocument = gql` } } createdAt + assignedAt + jobCreatedAt decisionReason } } @@ -37317,6 +37494,111 @@ export type GQLReorderRoutingRulesMutationOptions = Apollo.BaseMutationOptions< GQLReorderRoutingRulesMutation, GQLReorderRoutingRulesMutationVariables >; +export const GQLGetAverageHandleTimeDocument = gql` + query getAverageHandleTime($input: HandleTimeInput!) { + getHandleTime(input: $input) { + handleTimeSeconds + reviewerId + queueId + } + } +`; + +/** + * __useGQLGetAverageHandleTimeQuery__ + * + * To run a query within a React component, call `useGQLGetAverageHandleTimeQuery` and pass it any options that fit your needs. + * When your component renders, `useGQLGetAverageHandleTimeQuery` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useGQLGetAverageHandleTimeQuery({ + * variables: { + * input: // value for 'input' + * }, + * }); + */ +export function useGQLGetAverageHandleTimeQuery( + baseOptions: Apollo.QueryHookOptions< + GQLGetAverageHandleTimeQuery, + GQLGetAverageHandleTimeQueryVariables + > & + ( + | { variables: GQLGetAverageHandleTimeQueryVariables; skip?: boolean } + | { skip: boolean } + ), +) { + const options = { ...defaultOptions, ...baseOptions }; + return Apollo.useQuery< + GQLGetAverageHandleTimeQuery, + GQLGetAverageHandleTimeQueryVariables + >(GQLGetAverageHandleTimeDocument, options); +} +export function useGQLGetAverageHandleTimeLazyQuery( + baseOptions?: Apollo.LazyQueryHookOptions< + GQLGetAverageHandleTimeQuery, + GQLGetAverageHandleTimeQueryVariables + >, +) { + const options = { ...defaultOptions, ...baseOptions }; + return Apollo.useLazyQuery< + GQLGetAverageHandleTimeQuery, + GQLGetAverageHandleTimeQueryVariables + >(GQLGetAverageHandleTimeDocument, options); +} +// @ts-ignore +export function useGQLGetAverageHandleTimeSuspenseQuery( + baseOptions?: Apollo.SuspenseQueryHookOptions< + GQLGetAverageHandleTimeQuery, + GQLGetAverageHandleTimeQueryVariables + >, +): Apollo.UseSuspenseQueryResult< + GQLGetAverageHandleTimeQuery, + GQLGetAverageHandleTimeQueryVariables +>; +export function useGQLGetAverageHandleTimeSuspenseQuery( + baseOptions?: + | Apollo.SkipToken + | Apollo.SuspenseQueryHookOptions< + GQLGetAverageHandleTimeQuery, + GQLGetAverageHandleTimeQueryVariables + >, +): Apollo.UseSuspenseQueryResult< + GQLGetAverageHandleTimeQuery | undefined, + GQLGetAverageHandleTimeQueryVariables +>; +export function useGQLGetAverageHandleTimeSuspenseQuery( + baseOptions?: + | Apollo.SkipToken + | Apollo.SuspenseQueryHookOptions< + GQLGetAverageHandleTimeQuery, + GQLGetAverageHandleTimeQueryVariables + >, +) { + const options = + baseOptions === Apollo.skipToken + ? baseOptions + : { ...defaultOptions, ...baseOptions }; + return Apollo.useSuspenseQuery< + GQLGetAverageHandleTimeQuery, + GQLGetAverageHandleTimeQueryVariables + >(GQLGetAverageHandleTimeDocument, options); +} +export type GQLGetAverageHandleTimeQueryHookResult = ReturnType< + typeof useGQLGetAverageHandleTimeQuery +>; +export type GQLGetAverageHandleTimeLazyQueryHookResult = ReturnType< + typeof useGQLGetAverageHandleTimeLazyQuery +>; +export type GQLGetAverageHandleTimeSuspenseQueryHookResult = ReturnType< + typeof useGQLGetAverageHandleTimeSuspenseQuery +>; +export type GQLGetAverageHandleTimeQueryResult = Apollo.QueryResult< + GQLGetAverageHandleTimeQuery, + GQLGetAverageHandleTimeQueryVariables +>; export const GQLManualReviewChartConfigurationSettingsDocument = gql` query ManualReviewChartConfigurationSettings { me { @@ -45374,6 +45656,7 @@ export const namedOperations = { getDecidedJobFromJobId: 'getDecidedJobFromJobId', ManualReviewMetrics: 'ManualReviewMetrics', getAverageTimeToReview: 'getAverageTimeToReview', + getAverageHandleTimeSummary: 'getAverageHandleTimeSummary', getDecisionsTable: 'getDecisionsTable', QueueFormData: 'QueueFormData', ManualReviewQueue: 'ManualReviewQueue', @@ -45406,6 +45689,7 @@ export const namedOperations = { getUserItems: 'getUserItems', ManualReviewHasAppealsEnabled: 'ManualReviewHasAppealsEnabled', ManualReviewQueueRoutingRules: 'ManualReviewQueueRoutingRules', + getAverageHandleTime: 'getAverageHandleTime', ManualReviewChartConfigurationSettings: 'ManualReviewChartConfigurationSettings', ManualReviewDecisionInsightsOrgInfo: 'ManualReviewDecisionInsightsOrgInfo', diff --git a/client/src/webpages/dashboard/items/ItemActionHistory.tsx b/client/src/webpages/dashboard/items/ItemActionHistory.tsx index 6d31e5c3..50a13228 100644 --- a/client/src/webpages/dashboard/items/ItemActionHistory.tsx +++ b/client/src/webpages/dashboard/items/ItemActionHistory.tsx @@ -103,6 +103,8 @@ gql` } } createdAt + assignedAt + jobCreatedAt } } } diff --git a/client/src/webpages/dashboard/mrt/ManualReviewAnalyticsDashboard.tsx b/client/src/webpages/dashboard/mrt/ManualReviewAnalyticsDashboard.tsx index 222627a8..22a3c820 100644 --- a/client/src/webpages/dashboard/mrt/ManualReviewAnalyticsDashboard.tsx +++ b/client/src/webpages/dashboard/mrt/ManualReviewAnalyticsDashboard.tsx @@ -2,7 +2,7 @@ import { DateRangePicker } from '@/coop-ui/DateRangePicker'; import ManualReviewCustomCharts from '@/webpages/dashboard/mrt/visualization/ManualReviewCustomCharts'; import ManualReviewDefaultCharts from '@/webpages/dashboard/mrt/visualization/ManualReviewDefaultCharts'; import { gql } from '@apollo/client'; -import { startOfHour, subDays } from 'date-fns'; +import { endOfDay, startOfDay, subDays } from 'date-fns'; import sum from 'lodash/sum'; import { useMemo, useState } from 'react'; import { Helmet } from 'react-helmet-async'; @@ -11,6 +11,7 @@ import DashboardHeader from '../components/DashboardHeader'; import TabBar from '../components/TabBar'; import { + useGQLGetAverageHandleTimeSummaryQuery, useGQLGetAverageTimeToReviewQuery, useGQLManualReviewMetricsQuery, } from '../../../graphql/generated'; @@ -34,12 +35,22 @@ gql` queueId } } + query getAverageHandleTimeSummary($input: HandleTimeInput!) { + getHandleTime(input: $input) { + handleTimeSeconds + reviewerId + queueId + } + } `; export default function ManualReviewAnalyticsDashboard() { - const [timeWindow, setTimeWindow] = useState({ - start: startOfHour(subDays(new Date(), 7)), - end: startOfHour(new Date()), + const [timeWindow, setTimeWindow] = useState(() => { + const today = new Date(); + return { + start: startOfDay(subDays(today, 6)), + end: endOfDay(today), + }; }); const [activeTab, setActiveTab] = @@ -55,7 +66,7 @@ export default function ManualReviewAnalyticsDashboard() { ) => { return arr?.filter((elemWithDate) => { const time = new Date(elemWithDate.date).getTime(); - return time > window.start.getTime() && time < window.end.getTime(); + return time >= window.start.getTime() && time <= window.end.getTime(); }); }; @@ -65,7 +76,9 @@ export default function ManualReviewAnalyticsDashboard() { timeWindow.start.getTime() - (timeWindow.end.getTime() - timeWindow.start.getTime()), ), - end: new Date(timeWindow.start), + // Inclusive filters on both client and server; end one ms before current + // window so period-over-period buckets do not overlap. + end: new Date(timeWindow.start.getTime() - 1), }), [timeWindow], ); @@ -121,6 +134,48 @@ export default function ManualReviewAnalyticsDashboard() { const previousPeriodTimeToAction = previousTimeToActionData?.getTimeToAction?.[0].timeToAction ?? 0; + const { + loading: handleTimeLoading, + data: handleTimeData, + error: handleTimeError, + } = useGQLGetAverageHandleTimeSummaryQuery({ + variables: { + input: { + groupBy: [], + filterBy: { + startDate: timeWindow.start, + endDate: timeWindow.end, + queueIds: [], + reviewerIds: [], + }, + }, + }, + }); + const { + loading: previousHandleTimeLoading, + data: previousHandleTimeData, + error: previousHandleTimeError, + } = useGQLGetAverageHandleTimeSummaryQuery({ + variables: { + input: { + groupBy: [], + filterBy: { + startDate: previousTimeWindow.start, + endDate: previousTimeWindow.end, + queueIds: [], + reviewerIds: [], + }, + }, + }, + }); + + const currentPeriodHandleTime = + handleTimeData?.getHandleTime?.[0]?.handleTimeSeconds ?? undefined; + const previousPeriodHandleTime = + previousHandleTimeData?.getHandleTime?.[0]?.handleTimeSeconds ?? undefined; + const handleTimeQueryFailed = + handleTimeError != null || previousHandleTimeError != null; + return (
@@ -158,10 +213,21 @@ export default function ManualReviewAnalyticsDashboard() { + Claimed{' '} + {parseDatetimeToReadableStringInCurrentTimeZone( + selectedDecision.assignedAt, + )} +
+ ) : null}
diff --git a/client/src/webpages/dashboard/mrt/ManualReviewRecentDecisions.tsx b/client/src/webpages/dashboard/mrt/ManualReviewRecentDecisions.tsx index 6d2a1695..f7c4b543 100644 --- a/client/src/webpages/dashboard/mrt/ManualReviewRecentDecisions.tsx +++ b/client/src/webpages/dashboard/mrt/ManualReviewRecentDecisions.tsx @@ -117,6 +117,8 @@ gql` } } createdAt + assignedAt + jobCreatedAt decisionReason } } @@ -144,6 +146,7 @@ type RecentDecision = // Column visibility configuration type ColumnId = | 'decisionTime' + | 'claimedAt' | 'decisions' | 'policies' | 'reviewer' @@ -154,6 +157,7 @@ const COLUMN_VISIBILITY_STORAGE_KEY = 'mrt-recent-decisions-column-visibility'; const defaultColumnVisibility: Record = { decisionTime: true, + claimedAt: true, decisions: true, decisionReason: true, policies: true, @@ -163,6 +167,7 @@ const defaultColumnVisibility: Record = { const columnLabels: Record = { decisionTime: 'Decision Time', + claimedAt: 'Claimed At', decisions: 'Decisions', decisionReason: 'Decision Reason', policies: 'Policies', @@ -371,6 +376,14 @@ export default function ManualReviewRecentDecisions() { sortType: stringSort, } : undefined, + columnVisibility.claimedAt + ? { + Header: 'Claimed At', + accessor: 'claimedAt', + sortDescFirst: true, + sortType: stringSort, + } + : undefined, columnVisibility.decisions ? { Header: 'Decisions', @@ -564,6 +577,8 @@ export default function ManualReviewRecentDecisions() { reviewer: getReviewerName(decisionData.reviewerId), queue: getQueueName(decisionData.queueId), decisionTime: decisionData.createdAt, + claimedAt: decisionData.assignedAt ?? null, + jobCreatedAt: decisionData.jobCreatedAt ?? null, originalDecisionData: decisionData, decisionReason: decisionData.decisionReason, }; @@ -615,6 +630,15 @@ export default function ManualReviewRecentDecisions() { )}
), + claimedAt: value.claimedAt ? ( +
+ {parseDatetimeToReadableStringInCurrentTimeZone( + new Date(value.claimedAt), + )} +
+ ) : ( +
+ ), decisionReason: value.decisionReason ? (
@@ -698,6 +722,32 @@ export default function ManualReviewRecentDecisions() { createdAt: parseDatetimeToReadableStringInUTC( new Date(decision.createdAt), ), + assignedAt: decision.assignedAt + ? parseDatetimeToReadableStringInUTC( + new Date(decision.assignedAt), + ) + : '', + jobCreatedAt: decision.jobCreatedAt + ? parseDatetimeToReadableStringInUTC( + new Date(decision.jobCreatedAt), + ) + : '', + handleTimeSeconds: + decision.assignedAt != null + ? Math.round( + (new Date(decision.createdAt).getTime() - + new Date(decision.assignedAt).getTime()) / + 1000, + ) + : '', + waitTimeSeconds: + decision.assignedAt != null && decision.jobCreatedAt != null + ? Math.round( + (new Date(decision.assignedAt).getTime() - + new Date(decision.jobCreatedAt).getTime()) / + 1000, + ) + : '', policies, decisionReason: decision.decisionReason ?? '', }; @@ -708,7 +758,11 @@ export default function ManualReviewRecentDecisions() { 'Policies', 'Reviewer', 'Queue', + 'Job Created At', + 'Claimed At', 'Decision Time', + 'Wait Time (sec)', + 'Handle Time (sec)', 'Decision Reason', 'Link', ]; @@ -719,7 +773,11 @@ export default function ManualReviewRecentDecisions() { JSON.stringify(item.policies), // Convert array/object to JSON string if necessary item.reviewer, item.queue, + item.jobCreatedAt, + item.assignedAt, item.createdAt, + item.waitTimeSeconds, + item.handleTimeSeconds, item.decisionReason, `${HOST_URL}/dashboard/manual_review/recent?jobId=${item.jobId}`, ]); diff --git a/client/src/webpages/dashboard/mrt/visualization/HandleTimeByModeratorChart.tsx b/client/src/webpages/dashboard/mrt/visualization/HandleTimeByModeratorChart.tsx new file mode 100644 index 00000000..35e4f417 --- /dev/null +++ b/client/src/webpages/dashboard/mrt/visualization/HandleTimeByModeratorChart.tsx @@ -0,0 +1,350 @@ +import { + DeleteOutlined, + EditOutlined, + EllipsisOutlined, + InfoCircleOutlined, +} from '@ant-design/icons'; +import { gql } from '@apollo/client'; +import { Tooltip as AntTooltip } from 'antd'; +import orderBy from 'lodash/orderBy'; +import { ReactNode, useEffect, useRef, useState } from 'react'; +import { + Bar, + BarChart, + CartesianGrid, + Cell, + Legend, + ResponsiveContainer, + Tooltip, + TooltipProps, + XAxis, + YAxis, +} from 'recharts'; +import { Payload } from 'recharts/types/component/DefaultLegendContent'; + +import ComponentLoading from '../../../../components/common/ComponentLoading'; + +import { + useGQLGetAverageHandleTimeLazyQuery, + useGQLManualReviewDecisionInsightsOrgInfoQuery, +} from '../../../../graphql/generated'; +import { safePick } from '../../../../utils/misc'; +import { chartColors } from '../../rules/dashboard/visualization/chartColors'; +import { TimeWindow } from '../../rules/dashboard/visualization/RulesDashboardInsights'; + +gql` + query getAverageHandleTime($input: HandleTimeInput!) { + getHandleTime(input: $input) { + handleTimeSeconds + reviewerId + queueId + } + } +`; + +interface HandleTimeByModeratorChartProps { + timeWindow: TimeWindow; + title?: string; + hideBorder?: boolean; + hideOptions?: boolean; + infoText?: string; + narrowMode?: boolean; + onEdit?: () => void; + onDelete?: () => void; +} + +export default function HandleTimeByModeratorChart({ + timeWindow, + title, + hideBorder = false, + hideOptions = false, + infoText, + narrowMode = false, + onEdit, + onDelete, +}: HandleTimeByModeratorChartProps) { + const [optionsVisible, setOptionsVisible] = useState(false); + const optionsRef = useRef(null); + + const [ + getHandleTime, + { + loading: handleTimeLoading, + error: handleTimeError, + data: handleTimeData, + }, + ] = useGQLGetAverageHandleTimeLazyQuery(); + + const [handleTime, loading, error] = [ + handleTimeData?.getHandleTime, + handleTimeLoading, + handleTimeError, + ]; + + useEffect(() => { + getHandleTime({ + variables: { + input: { + groupBy: ['REVIEWER_ID'], + filterBy: { + startDate: timeWindow.start, + endDate: timeWindow.end, + queueIds: [], + reviewerIds: [], + }, + }, + }, + }); + }, [getHandleTime, timeWindow]); + + const { data: orgQueryData } = + useGQLManualReviewDecisionInsightsOrgInfoQuery(); + + useEffect(() => { + const handleOutsideClick = (event: MouseEvent) => { + if ( + optionsRef.current && + !optionsRef.current.contains(event.target as Node) + ) { + setOptionsVisible(false); + } + }; + + if (optionsVisible) { + document.addEventListener('click', handleOutsideClick); + } + + return () => { + document.removeEventListener('click', handleOutsideClick); + }; + }, [optionsVisible]); + + const getReviewerNameFromId = (reviewerId: string | null | undefined) => { + if (!reviewerId) { + return 'Unknown'; + } + const user = orgQueryData?.myOrg?.users.find((it) => it.id === reviewerId); + if (!user) { + return `Unknown (${reviewerId.slice(0, 8)})`; + } + const name = `${user.firstName} ${user.lastName}`.trim(); + return name || `Unknown (${reviewerId.slice(0, 8)})`; + }; + + const formattedData = handleTime + ?.filter( + (it): it is typeof it & { handleTimeSeconds: number } => + it.handleTimeSeconds != null, + ) + .map((it) => ({ + handleTimeMinutes: Number((it.handleTimeSeconds / 60).toFixed(2)), + reviewer: getReviewerNameFromId(it.reviewerId), + })); + + const renderLegend = ({ payload }: { payload?: Payload[] }) => ( +
+ {payload + ?.filter((entry) => entry.type !== 'none') + .map((entry, index) => ( +
+
+ {entry.value} +
+ ))} +
+ ); + + if (error) { + return
Error fetching metrics for chart
; + } + + const customTooltip = ({ + active, + payload, + label, + }: TooltipProps) => { + if (active && payload?.length) { + const data = orderBy( + payload + .filter((it) => it.type !== 'none') + .map((it) => safePick(it, ['name', 'value'])), + 'value', + 'desc', + ); + return ( +
+
+ {label} +
+ + + {data.map((it, i) => + it.value && it.value > 0 ? ( + + + + + ) : null, + )} + +
+ {it.value?.toLocaleString()} + + {it.name} +
+
+ ); + } + return null; + }; + + const emptyChart = ( +
+
+ No data available for the selected time period. +
+
+ ); + + const optionButton = ( + optionTitle: string, + icon: ReactNode, + onClick?: () => void, + ) => ( + + ); + + const optionsMenu = ( +
+ + {optionsVisible && ( +
+ {onEdit && optionButton('Edit', , onEdit)} + {onDelete && optionButton('Delete', , onDelete)} +
+ )} +
+ ); + + return ( +
+
+
+ {title && ( +
+
+ {title} + {infoText && ( + + + + )} +
+
+ )} +
+
+ {!hideOptions && optionsMenu} +
+
+ {!loading && formattedData?.length === 0 ? ( + emptyChart + ) : ( + + {loading ? ( + + ) : ( + + + + + ({ + value: it.reviewer, + }))} + content={renderLegend} + /> + + + {formattedData?.map((_, index) => ( + + ))} + + + )} + + )} +
+
+ ); +} diff --git a/client/src/webpages/dashboard/mrt/visualization/ManualReviewDashboardInsightsCard.tsx b/client/src/webpages/dashboard/mrt/visualization/ManualReviewDashboardInsightsCard.tsx index e5132412..7c53fb4c 100644 --- a/client/src/webpages/dashboard/mrt/visualization/ManualReviewDashboardInsightsCard.tsx +++ b/client/src/webpages/dashboard/mrt/visualization/ManualReviewDashboardInsightsCard.tsx @@ -24,6 +24,8 @@ interface ManualReviewDashboardInsightsCardBaseProps { interface ManualReviewDashboardInsightsCardWithChangeProps extends ManualReviewDashboardInsightsCardBaseProps { change: number | undefined; + /** When true, a negative `change` is an improvement (green). */ + lowerIsBetter?: boolean; } interface ManualReviewDashboardInsightsCardWithLinkProps extends ManualReviewDashboardInsightsCardBaseProps { @@ -126,7 +128,11 @@ const ManualReviewDashboardInsightsCard = ( className={`${ props.change === 0 ? 'text-slate-600 bg-slate-100' - : props.change < 0 + : ( + props.lowerIsBetter + ? props.change > 0 + : props.change < 0 + ) ? 'text-red-600 bg-red-100' : 'text-green-600 bg-green-100' } p-1 rounded text-sm font-semibold flex items-center`} diff --git a/client/src/webpages/dashboard/mrt/visualization/ManualReviewDefaultCharts.tsx b/client/src/webpages/dashboard/mrt/visualization/ManualReviewDefaultCharts.tsx index 1ae9fb45..2d8b41d7 100644 --- a/client/src/webpages/dashboard/mrt/visualization/ManualReviewDefaultCharts.tsx +++ b/client/src/webpages/dashboard/mrt/visualization/ManualReviewDefaultCharts.tsx @@ -9,6 +9,7 @@ import { TimeWindow, } from '../../rules/dashboard/visualization/RulesDashboardInsights'; import ManualReviewDecisionsTable from '../ManualReviewDecisionsTable'; +import HandleTimeByModeratorChart from './HandleTimeByModeratorChart'; import ManualReviewDashboardInsightsCard from './ManualReviewDashboardInsightsCard'; import ManualReviewDashboardInsightsChart from './ManualReviewDashboardInsightsChart'; import TimeToActionByQueueChart from './TimeToActionChart'; @@ -20,6 +21,9 @@ interface ManualReviewDefaultChartsProps { totalIngestedReportsInPreviousWindow: number | undefined; averageTimeToReviewInWindow: number | undefined; averageTimeToReviewInPreviousWindow: number | undefined; + averageHandleTimeInWindow: number | undefined; + averageHandleTimeInPreviousWindow: number | undefined; + handleTimeError?: boolean; currentlyOpenJobs: number; } @@ -30,6 +34,9 @@ export default function ManualReviewDefaultCharts({ totalIngestedReportsInPreviousWindow, averageTimeToReviewInWindow, averageTimeToReviewInPreviousWindow, + averageHandleTimeInWindow, + averageHandleTimeInPreviousWindow, + handleTimeError = false, currentlyOpenJobs, }: ManualReviewDefaultChartsProps) { const getPercentChange = (oldValue: number, newValue: number) => { @@ -42,7 +49,17 @@ export default function ManualReviewDefaultCharts({ return (
-
+ {handleTimeError ? ( +
+ Failed to load average handle time. Try refreshing the page or + adjusting the date range. +
+ ) : null} +
+ + } + loading={loading} + />
+
diff --git a/client/src/webpages/dashboard/mrt/visualization/TimeToActionChart.tsx b/client/src/webpages/dashboard/mrt/visualization/TimeToActionChart.tsx index 056a64c6..6b460b2e 100644 --- a/client/src/webpages/dashboard/mrt/visualization/TimeToActionChart.tsx +++ b/client/src/webpages/dashboard/mrt/visualization/TimeToActionChart.tsx @@ -275,7 +275,7 @@ export default function TimeToActionByQueueChart({ return (
diff --git a/db/src/scripts/api-server-pg/2026.08.11T02.10.54.add_mrt_job_claims_and_assigned_at.sql b/db/src/scripts/api-server-pg/2026.08.11T02.10.54.add_mrt_job_claims_and_assigned_at.sql new file mode 100644 index 00000000..513bb7e5 --- /dev/null +++ b/db/src/scripts/api-server-pg/2026.08.11T02.10.54.add_mrt_job_claims_and_assigned_at.sql @@ -0,0 +1,46 @@ +-- Record when a moderator claims (dequeues) an MRT job so we can measure +-- handle time: claimed_at → decided_at (issue #380). +-- +-- Coop is pull-based: "assigned" means last successful dequeue/claim. Skips +-- and BullMQ lock expiry can produce multiple claims; analytics use the +-- latest claim by the deciding reviewer. `manual_review_decisions.assigned_at` +-- denormalizes that claim at decision time for cheap handle-time queries. +-- AUTOMATIC_CLOSE and other decisions without a matching human claim leave +-- assigned_at NULL so they are excluded from handle-time averages. + +CREATE TABLE IF NOT EXISTS manual_review_tool.job_claims ( + org_id character varying(255) NOT NULL, + queue_id character varying(255) NOT NULL, + user_id character varying(255) NOT NULL, + job_id text NOT NULL, + claimed_at timestamp with time zone DEFAULT now() NOT NULL +); + +ALTER TABLE manual_review_tool.job_claims OWNER TO CURRENT_USER; + +DO $$ +BEGIN + ALTER TABLE ONLY manual_review_tool.job_claims + ADD CONSTRAINT job_claims_queue_id_fkey + FOREIGN KEY (queue_id) + REFERENCES manual_review_tool.manual_review_queues(id) + ON DELETE CASCADE; +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; + +CREATE INDEX IF NOT EXISTS idx_job_claims_org_job_claimed_at + ON manual_review_tool.job_claims (org_id, job_id, claimed_at DESC); + +CREATE INDEX IF NOT EXISTS idx_job_claims_org_job_user_claimed_at + ON manual_review_tool.job_claims (org_id, job_id, user_id, claimed_at DESC); + +CREATE INDEX IF NOT EXISTS idx_job_claims_org_claimed_at + ON manual_review_tool.job_claims (org_id, claimed_at); + +ALTER TABLE manual_review_tool.manual_review_decisions + ADD COLUMN IF NOT EXISTS assigned_at timestamp with time zone; + +CREATE INDEX IF NOT EXISTS idx_manual_review_decisions_org_created_assigned + ON manual_review_tool.manual_review_decisions (org_id, created_at) + WHERE assigned_at IS NOT NULL; diff --git a/server/graphql/generated.ts b/server/graphql/generated.ts index 12d6be11..95c11202 100644 --- a/server/graphql/generated.ts +++ b/server/graphql/generated.ts @@ -1441,6 +1441,32 @@ export type GQLGooglePlaceLocationInfo = { readonly id: Scalars['ID']['output']; }; +export type GQLHandleTime = { + readonly __typename?: 'HandleTime'; + readonly handleTimeSeconds?: Maybe; + readonly queueId?: Maybe; + readonly reviewerId?: Maybe; +}; + +export type GQLHandleTimeFilterByInput = { + readonly endDate: Scalars['DateTime']['input']; + readonly queueIds: ReadonlyArray; + readonly reviewerIds: ReadonlyArray; + readonly startDate: Scalars['DateTime']['input']; +}; + +export const GQLHandleTimeGroupByColumns = { + QueueId: 'QUEUE_ID', + ReviewerId: 'REVIEWER_ID', +} as const; + +export type GQLHandleTimeGroupByColumns = + (typeof GQLHandleTimeGroupByColumns)[keyof typeof GQLHandleTimeGroupByColumns]; +export type GQLHandleTimeInput = { + readonly filterBy: GQLHandleTimeFilterByInput; + readonly groupBy: ReadonlyArray; +}; + export type GQLHashBank = { readonly __typename?: 'HashBank'; readonly description?: Maybe; @@ -2185,12 +2211,14 @@ export type GQLManualReviewChartSettingsInput = { export type GQLManualReviewDecision = { readonly __typename?: 'ManualReviewDecision'; + readonly assignedAt?: Maybe; readonly createdAt: Scalars['DateTime']['output']; readonly decisionReason?: Maybe; readonly decisions: ReadonlyArray; readonly id: Scalars['String']['output']; readonly itemId?: Maybe; readonly itemTypeId?: Maybe; + readonly jobCreatedAt?: Maybe; readonly jobId: Scalars['String']['output']; readonly queueId: Scalars['String']['output']; readonly relatedActions: ReadonlyArray; @@ -3516,6 +3544,7 @@ export type GQLQuery = { readonly getExistingJobsForItem: ReadonlyArray; readonly getFullReportingRuleResultForItem: GQLGetFullReportingRuleResultForItemResponse; readonly getFullRuleResultForItem: GQLGetFullResultForItemResponse; + readonly getHandleTime?: Maybe>; readonly getJobCreationCounts: ReadonlyArray; readonly getRecentDecisions: ReadonlyArray; readonly getResolvedJobCounts: ReadonlyArray; @@ -3615,6 +3644,10 @@ export type GQLQueryGetFullRuleResultForItemArgs = { input: GQLGetFullResultForItemInput; }; +export type GQLQueryGetHandleTimeArgs = { + input: GQLHandleTimeInput; +}; + export type GQLQueryGetJobCreationCountsArgs = { input: GQLGetJobCreationCountInput; }; @@ -6001,6 +6034,10 @@ export type GQLResolversTypes = { GoogleContentSafetyApiIntegrationApiCredential: ResolverTypeWrapper; GoogleContentSafetyApiIntegrationApiCredentialInput: GQLGoogleContentSafetyApiIntegrationApiCredentialInput; GooglePlaceLocationInfo: ResolverTypeWrapper; + HandleTime: ResolverTypeWrapper; + HandleTimeFilterByInput: GQLHandleTimeFilterByInput; + HandleTimeGroupByColumns: GQLHandleTimeGroupByColumns; + HandleTimeInput: GQLHandleTimeInput; HashBank: ResolverTypeWrapper; ID: ResolverTypeWrapper; IgnoreDecisionComponent: ResolverTypeWrapper; @@ -6797,6 +6834,9 @@ export type GQLResolversParentTypes = { GoogleContentSafetyApiIntegrationApiCredential: GQLGoogleContentSafetyApiIntegrationApiCredential; GoogleContentSafetyApiIntegrationApiCredentialInput: GQLGoogleContentSafetyApiIntegrationApiCredentialInput; GooglePlaceLocationInfo: GQLGooglePlaceLocationInfo; + HandleTime: GQLHandleTime; + HandleTimeFilterByInput: GQLHandleTimeFilterByInput; + HandleTimeInput: GQLHandleTimeInput; HashBank: HashBank; ID: Scalars['ID']['output']; IgnoreDecisionComponent: GQLIgnoreDecisionComponent; @@ -9223,6 +9263,28 @@ export type GQLGooglePlaceLocationInfoResolvers< id?: Resolver; }; +export type GQLHandleTimeResolvers< + ContextType = Context, + ParentType extends GQLResolversParentTypes['HandleTime'] = + GQLResolversParentTypes['HandleTime'], +> = { + handleTimeSeconds?: Resolver< + Maybe, + ParentType, + ContextType + >; + queueId?: Resolver< + Maybe, + ParentType, + ContextType + >; + reviewerId?: Resolver< + Maybe, + ParentType, + ContextType + >; +}; + export type GQLHashBankResolvers< ContextType = Context, ParentType extends GQLResolversParentTypes['HashBank'] = @@ -10234,6 +10296,11 @@ export type GQLManualReviewDecisionResolvers< ParentType extends GQLResolversParentTypes['ManualReviewDecision'] = GQLResolversParentTypes['ManualReviewDecision'], > = { + assignedAt?: Resolver< + Maybe, + ParentType, + ContextType + >; createdAt?: Resolver; decisionReason?: Resolver< Maybe, @@ -10256,6 +10323,11 @@ export type GQLManualReviewDecisionResolvers< ParentType, ContextType >; + jobCreatedAt?: Resolver< + Maybe, + ParentType, + ContextType + >; jobId?: Resolver; queueId?: Resolver; relatedActions?: Resolver< @@ -12525,6 +12597,12 @@ export type GQLQueryResolvers< ContextType, RequireFields >; + getHandleTime?: Resolver< + Maybe>, + ParentType, + ContextType, + RequireFields + >; getJobCreationCounts?: Resolver< ReadonlyArray, ParentType, @@ -15163,6 +15241,7 @@ export type GQLResolvers = { GetJobCreationCountSettings?: GQLGetJobCreationCountSettingsResolvers; GoogleContentSafetyApiIntegrationApiCredential?: GQLGoogleContentSafetyApiIntegrationApiCredentialResolvers; GooglePlaceLocationInfo?: GQLGooglePlaceLocationInfoResolvers; + HandleTime?: GQLHandleTimeResolvers; HashBank?: GQLHashBankResolvers; IgnoreDecisionComponent?: GQLIgnoreDecisionComponentResolvers; IntegrationApiCredential?: GQLIntegrationApiCredentialResolvers; diff --git a/server/graphql/modules/manualReviewTool.ts b/server/graphql/modules/manualReviewTool.ts index 4d14efab..00104ded 100644 --- a/server/graphql/modules/manualReviewTool.ts +++ b/server/graphql/modules/manualReviewTool.ts @@ -35,7 +35,11 @@ import { type GQLUserManualReviewJobPayloadResolvers, } from '../generated.js'; import { formatItemSubmissionForGQL } from '../types.js'; -import { forbiddenError, unauthenticatedError } from '../utils/errors.js'; +import { + forbiddenError, + unauthenticatedError, + userInputError, +} from '../utils/errors.js'; import { gqlErrorResult, gqlSuccessResult } from '../utils/gqlResult.js'; import { oneOfInputToTaggedUnion } from '../utils/inputHelpers.js'; @@ -738,6 +742,29 @@ const typeDefs = /* GraphQL */ ` filterBy: TimeToActionFilterByInput! } + enum HandleTimeGroupByColumns { + QUEUE_ID + REVIEWER_ID + } + + input HandleTimeFilterByInput { + startDate: DateTime! + endDate: DateTime! + queueIds: [String!]! + reviewerIds: [String!]! + } + + type HandleTime { + handleTimeSeconds: Int + reviewerId: String + queueId: String + } + + input HandleTimeInput { + groupBy: [HandleTimeGroupByColumns!]! + filterBy: HandleTimeFilterByInput! + } + union ManualReviewChartSettings = | GetDecisionCountSettings | GetJobCreationCountSettings @@ -825,6 +852,8 @@ const typeDefs = /* GraphQL */ ` decisions: [ManualReviewDecisionComponent!]! relatedActions: [ManualReviewDecisionComponent!]! createdAt: DateTime! + assignedAt: DateTime + jobCreatedAt: DateTime decisionReason: String } @@ -968,6 +997,7 @@ const typeDefs = /* GraphQL */ ` itemTypeId: ID! ): [ManualReviewExistingJob!]! getTimeToAction(input: TimeToActionInput!): [TimeToAction!] + getHandleTime(input: HandleTimeInput!): [HandleTime!] getResolvedJobsForUser(timeZone: String!): Int! getSkippedJobsForUser(timeZone: String!): Int! } @@ -2012,6 +2042,34 @@ const Query: GQLQueryResolvers = { queueId: it.queue_id, })); }, + async getHandleTime(_: unknown, { input }, context) { + const user = context.getUser(); + if (user == null) { + throw unauthenticatedError('Authenticated user required'); + } + const startDate = new Date(input.filterBy.startDate); + const endDate = new Date(input.filterBy.endDate); + if (startDate.getTime() > endDate.getTime()) { + throw userInputError('startDate must not be after endDate'); + } + const result = await context.services.ManualReviewToolService.getHandleTime( + { + groupBy: input.groupBy.map((it) => it.toLowerCase()), + filterBy: { + ...input.filterBy, + startDate, + endDate, + }, + orgId: user.orgId, + }, + ); + return result.map((it) => ({ + handleTimeSeconds: + it.handle_time != null ? Math.round(it.handle_time) : null, + queueId: 'queue_id' in it ? it.queue_id : null, + reviewerId: 'reviewer_id' in it ? it.reviewer_id : null, + })); + }, async getTotalPendingJobsCount(_: unknown, __: unknown, context) { const user = context.getUser(); if (user == null) { diff --git a/server/services/manualReviewToolService/dbTypes.ts b/server/services/manualReviewToolService/dbTypes.ts index e5d73a2a..ae3d1e2f 100644 --- a/server/services/manualReviewToolService/dbTypes.ts +++ b/server/services/manualReviewToolService/dbTypes.ts @@ -101,6 +101,7 @@ export type ManualReviewToolServicePg = { reviewer_id: string | null; org_id: string; created_at: GeneratedAlways; + assigned_at: ColumnType; decision_components: ManualReviewDecisionComponent[]; related_actions: ManualReviewDecisionRelatedAction[]; enqueue_source_info: @@ -256,6 +257,13 @@ export type ManualReviewToolServicePg = { queue_id: string; ts: GeneratedAlways; }; + 'manual_review_tool.job_claims': { + org_id: string; + user_id: string; + job_id: string; + queue_id: string; + claimed_at: GeneratedAlways; + }; // This table is more general than the ManualReviewToolService, and // doesn't need to be exclusively managed by it - but we don't really have a // good place for pg internal tables, so for now it lives here diff --git a/server/services/manualReviewToolService/manualReviewToolService.test.ts b/server/services/manualReviewToolService/manualReviewToolService.test.ts index 289b6bdf..44d328d2 100644 --- a/server/services/manualReviewToolService/manualReviewToolService.test.ts +++ b/server/services/manualReviewToolService/manualReviewToolService.test.ts @@ -1,4 +1,4 @@ -/* eslint-disable max-lines */ +import { sql } from 'kysely'; import { uid } from 'uid'; import { v1 as uuidv1 } from 'uuid'; @@ -14,6 +14,7 @@ import { type NormalizedItemData, } from '../itemProcessingService/index.js'; import { type ItemSubmissionWithTypeIdentifier } from '../itemProcessingService/makeItemSubmissionWithTypeIdentifier.js'; +import { UserPermission } from '../userManagementService/index.js'; import { type ManualReviewToolService, type NcmecContentItemSubmission, @@ -361,10 +362,11 @@ describe('Manual Review Tool Service', () => { .selectFrom('manual_review_tool.manual_review_decisions') .where('id', '=', jobIdToGuid(dequeuedJob.job.id)) .where('org_id', '=', orgId) - .select(['reviewer_id']) + .select(['reviewer_id', 'assigned_at']) .executeTakeFirst(); expect(row?.reviewer_id).toBe(AUTOMATED_DECISION_REVIEWER_ID); + expect(row?.assigned_at).toBeNull(); }, ); }); @@ -963,4 +965,528 @@ describe('Manual Review Tool Service', () => { }, ); }); + + describe('job claims and assigned_at', () => { + testWithQueue( + 'records a claim on dequeue and copies latest claim onto the decision', + async ({ mrtService, org, queue, actionId, deps }) => { + const orgId = org.id; + const queueId = queue.id; + const reviewerId = uuidv1(); + const reviewerEmail = 'claim-test@example.com'; + const jobPayload = makeDummyMrtJobPayload(); + const itemId = jobPayload.payload.item.itemId; + const itemTypeId = jobPayload.payload.item.itemTypeIdentifier.id; + + await mrtService['queueOps']['addJob']({ + jobPayload, + orgId, + queueId, + enqueueSourceInfo: { kind: 'REPORT' }, + }); + + const dequeuedJob = await mrtService.dequeueNextJob({ + orgId, + queueId, + userId: reviewerId, + }); + if (!dequeuedJob) { + throw new Error('expected a dequeued job'); + } + + const claims = await deps.KyselyPg.selectFrom( + 'manual_review_tool.job_claims', + ) + .selectAll() + .where('org_id', '=', orgId) + .where('job_id', '=', dequeuedJob.job.id) + .execute(); + expect(claims).toHaveLength(1); + expect(claims[0].user_id).toBe(reviewerId); + + await mrtService.submitDecision({ + queueId, + reportHistory: [], + jobId: dequeuedJob.job.id, + lockToken: dequeuedJob.lockToken, + decisionComponents: [ + { + type: 'CUSTOM_ACTION', + actions: [{ id: actionId }], + policies: [], + itemIds: [itemId], + itemTypeId, + }, + ], + relatedActions: [], + reviewerId, + reviewerEmail, + orgId, + }); + + const decision = await deps.KyselyPg.selectFrom( + 'manual_review_tool.manual_review_decisions', + ) + .select(['assigned_at', 'created_at', 'reviewer_id']) + .where('org_id', '=', orgId) + .where( + sql`(job_payload->>'id')::text`, + '=', + dequeuedJob.job.id, + ) + .executeTakeFirstOrThrow(); + + expect(decision.reviewer_id).toBe(reviewerId); + expect(decision.assigned_at).toEqual(claims[0].claimed_at); + if (decision.assigned_at == null) { + throw new Error('expected assigned_at'); + } + expect(decision.created_at.getTime()).toBeGreaterThanOrEqual( + decision.assigned_at.getTime(), + ); + + const handleTime = await mrtService.getHandleTime({ + orgId, + groupBy: ['reviewer_id'], + filterBy: { + startDate: new Date(Date.now() - 60_000), + endDate: new Date(Date.now() + 60_000), + queueIds: [], + reviewerIds: [reviewerId], + }, + }); + expect(handleTime).toHaveLength(1); + expect(handleTime[0].reviewer_id).toBe(reviewerId); + const handleTimeSeconds = handleTime[0].handle_time; + if (handleTimeSeconds == null) { + throw new Error('expected handle_time'); + } + expect(handleTimeSeconds).toBeGreaterThanOrEqual(0); + + const recent = await mrtService.getRecentDecisions({ + orgId, + userPermissions: [UserPermission.VIEW_MRT], + input: { page: 0 }, + }); + const recentDecision = recent.find( + (it) => it.jobId === dequeuedJob.job.id, + ); + expect(recentDecision).toBeDefined(); + expect(recentDecision?.assignedAt).toEqual(claims[0].claimed_at); + expect(recentDecision?.jobCreatedAt?.getTime()).toBe( + new Date(dequeuedJob.job.createdAt).getTime(), + ); + }, + ); + + testWithQueue( + 'uses the latest claim after a job is released and reclaimed', + async ({ mrtService, org, queue, actionId, deps }) => { + const orgId = org.id; + const queueId = queue.id; + const firstReviewerId = uuidv1(); + const secondReviewerId = uuidv1(); + const reviewerEmail = 'reclaim-test@example.com'; + const jobPayload = makeDummyMrtJobPayload(); + const itemId = jobPayload.payload.item.itemId; + const itemTypeId = jobPayload.payload.item.itemTypeIdentifier.id; + + await mrtService['queueOps']['addJob']({ + jobPayload, + orgId, + queueId, + enqueueSourceInfo: { kind: 'REPORT' }, + }); + + const firstClaim = await mrtService.dequeueNextJob({ + orgId, + queueId, + userId: firstReviewerId, + }); + if (!firstClaim) { + throw new Error('expected first claim'); + } + + await mrtService.releaseJobLock({ + orgId, + queueId, + jobId: firstClaim.job.id, + lockToken: firstClaim.lockToken, + }); + + const secondClaim = await mrtService.dequeueNextJob({ + orgId, + queueId, + userId: secondReviewerId, + }); + if (!secondClaim) { + throw new Error('expected second claim'); + } + expect(secondClaim.job.id).toBe(firstClaim.job.id); + + const claims = await deps.KyselyPg.selectFrom( + 'manual_review_tool.job_claims', + ) + .selectAll() + .where('org_id', '=', orgId) + .where('job_id', '=', firstClaim.job.id) + .orderBy('claimed_at', 'asc') + .execute(); + expect(claims).toHaveLength(2); + expect(claims[0].user_id).toBe(firstReviewerId); + expect(claims[1].user_id).toBe(secondReviewerId); + + await mrtService.submitDecision({ + queueId, + reportHistory: [], + jobId: secondClaim.job.id, + lockToken: secondClaim.lockToken, + decisionComponents: [ + { + type: 'CUSTOM_ACTION', + actions: [{ id: actionId }], + policies: [], + itemIds: [itemId], + itemTypeId, + }, + ], + relatedActions: [], + reviewerId: secondReviewerId, + reviewerEmail, + orgId, + }); + + const decision = await deps.KyselyPg.selectFrom( + 'manual_review_tool.manual_review_decisions', + ) + .select(['assigned_at']) + .where('org_id', '=', orgId) + .where( + sql`(job_payload->>'id')::text`, + '=', + secondClaim.job.id, + ) + .executeTakeFirstOrThrow(); + + expect(decision.assigned_at).toEqual(claims[1].claimed_at); + }, + ); + + testWithQueue( + 'leaves assigned_at null on AUTOMATIC_CLOSE even after a human claim', + async ({ mrtService, org, queue, deps }) => { + const orgId = org.id; + const queueId = queue.id; + const claimerId = uuidv1(); + const jobPayload = makeDummyMrtJobPayload(); + + await mrtService['queueOps']['addJob']({ + jobPayload, + orgId, + queueId, + enqueueSourceInfo: { kind: 'REPORT' }, + }); + + const dequeuedJob = await mrtService.dequeueNextJob({ + orgId, + queueId, + userId: claimerId, + }); + if (!dequeuedJob) { + throw new Error('expected a dequeued job'); + } + + const claims = await deps.KyselyPg.selectFrom( + 'manual_review_tool.job_claims', + ) + .selectAll() + .where('org_id', '=', orgId) + .where('job_id', '=', dequeuedJob.job.id) + .execute(); + expect(claims).toHaveLength(1); + + await mrtService.submitDecision({ + queueId, + reportHistory: [], + jobId: dequeuedJob.job.id, + lockToken: dequeuedJob.lockToken, + relatedActions: [], + orgId, + automaticCloseDecision: { + type: 'AUTOMATIC_CLOSE', + reason: 'ITEM_DELETED_BEFORE_REVIEW', + }, + }); + + const decision = await deps.KyselyPg.selectFrom( + 'manual_review_tool.manual_review_decisions', + ) + .select(['assigned_at', 'reviewer_id']) + .where('org_id', '=', orgId) + .where( + sql`(job_payload->>'id')::text`, + '=', + dequeuedJob.job.id, + ) + .executeTakeFirstOrThrow(); + + expect(decision.reviewer_id).toBe(AUTOMATED_DECISION_REVIEWER_ID); + expect(decision.assigned_at).toBeNull(); + + const handleTime = await mrtService.getHandleTime({ + orgId, + groupBy: [], + filterBy: { + startDate: new Date(Date.now() - 60_000), + endDate: new Date(Date.now() + 60_000), + queueIds: [], + reviewerIds: [], + }, + }); + expect(handleTime).toHaveLength(1); + expect(handleTime[0].handle_time).toBeNull(); + }, + ); + + testWithQueue( + 'leaves assigned_at null for swept AUTOMATIC_CLOSE despite a prior claim', + async ({ mrtService, org, queue, deps }) => { + const orgId = org.id; + const queueId = queue.id; + const claimerId = uuidv1(); + const triggerReviewerId = uuidv1(); + const reviewerEmail = 'sweep-auto-close@example.com'; + const jobPayload = makeDummyMrtJobPayload(); + + await mrtService['queueOps']['addJob']({ + jobPayload, + orgId, + queueId, + enqueueSourceInfo: { kind: 'REPORT' }, + }); + + const claimed = await mrtService.dequeueNextJob({ + orgId, + queueId, + userId: claimerId, + }); + if (!claimed) { + throw new Error('expected a claimed job'); + } + + await mrtService.releaseJobLock({ + orgId, + queueId, + jobId: claimed.job.id, + lockToken: claimed.lockToken, + }); + + const outcome = await mrtService[ + 'jobDecisioning' + ].recordSweptJobDisposition({ + orgId, + queueId, + job: claimed.job, + disposition: 'AUTOMATIC_CLOSE', + triggerCustomActions: [], + reviewerId: triggerReviewerId, + reviewerEmail, + }); + expect(outcome).toBe('logged'); + + const decision = await deps.KyselyPg.selectFrom( + 'manual_review_tool.manual_review_decisions', + ) + .select(['assigned_at', 'reviewer_id']) + .where('org_id', '=', orgId) + .where(sql`(job_payload->>'id')::text`, '=', claimed.job.id) + .executeTakeFirstOrThrow(); + + expect(decision.reviewer_id).toBe(triggerReviewerId); + expect(decision.assigned_at).toBeNull(); + }, + ); + + testWithQueue( + 'leaves assigned_at null when the deciding reviewer never claimed the job', + async ({ mrtService, org, queue, actionId, deps }) => { + const orgId = org.id; + const queueId = queue.id; + const claimerId = uuidv1(); + const triggerReviewerId = uuidv1(); + const reviewerEmail = 'sweep-like-test@example.com'; + const jobPayload = makeDummyMrtJobPayload(); + const itemId = jobPayload.payload.item.itemId; + const itemTypeId = jobPayload.payload.item.itemTypeIdentifier.id; + + await mrtService['queueOps']['addJob']({ + jobPayload, + orgId, + queueId, + enqueueSourceInfo: { kind: 'REPORT' }, + }); + + const claimed = await mrtService.dequeueNextJob({ + orgId, + queueId, + userId: claimerId, + }); + if (!claimed) { + throw new Error('expected a claimed job'); + } + + await mrtService.releaseJobLock({ + orgId, + queueId, + jobId: claimed.job.id, + lockToken: claimed.lockToken, + }); + + const outcome = await mrtService[ + 'jobDecisioning' + ].recordSweptJobDisposition({ + orgId, + queueId, + job: claimed.job, + disposition: 'SAME_ACTION', + triggerCustomActions: [ + { + type: 'CUSTOM_ACTION', + actions: [{ id: actionId }], + policies: [], + itemIds: [itemId], + itemTypeId, + }, + ], + reviewerId: triggerReviewerId, + reviewerEmail, + }); + expect(outcome).toBe('logged'); + + const decision = await deps.KyselyPg.selectFrom( + 'manual_review_tool.manual_review_decisions', + ) + .select(['assigned_at', 'reviewer_id']) + .where('org_id', '=', orgId) + .where(sql`(job_payload->>'id')::text`, '=', claimed.job.id) + .executeTakeFirstOrThrow(); + + expect(decision.reviewer_id).toBe(triggerReviewerId); + expect(decision.assigned_at).toBeNull(); + }, + ); + + testWithQueue( + 'leaves assigned_at null on swept SAME_ACTION even if the trigger reviewer previously claimed the job', + async ({ mrtService, org, queue, actionId, deps }) => { + const orgId = org.id; + const queueId = queue.id; + const triggerReviewerId = uuidv1(); + const reviewerEmail = 'stale-claim-sweep@example.com'; + const jobPayload = makeDummyMrtJobPayload(); + const itemId = jobPayload.payload.item.itemId; + const itemTypeId = jobPayload.payload.item.itemTypeIdentifier.id; + + await mrtService['queueOps']['addJob']({ + jobPayload, + orgId, + queueId, + enqueueSourceInfo: { kind: 'REPORT' }, + }); + + const claimed = await mrtService.dequeueNextJob({ + orgId, + queueId, + userId: triggerReviewerId, + }); + if (!claimed) { + throw new Error('expected a claimed job'); + } + + await mrtService.releaseJobLock({ + orgId, + queueId, + jobId: claimed.job.id, + lockToken: claimed.lockToken, + }); + + const claims = await deps.KyselyPg.selectFrom( + 'manual_review_tool.job_claims', + ) + .selectAll() + .where('org_id', '=', orgId) + .where('job_id', '=', claimed.job.id) + .where('user_id', '=', triggerReviewerId) + .execute(); + expect(claims).toHaveLength(1); + + const outcome = await mrtService[ + 'jobDecisioning' + ].recordSweptJobDisposition({ + orgId, + queueId, + job: claimed.job, + disposition: 'SAME_ACTION', + triggerCustomActions: [ + { + type: 'CUSTOM_ACTION', + actions: [{ id: actionId }], + policies: [], + itemIds: [itemId], + itemTypeId, + }, + ], + reviewerId: triggerReviewerId, + reviewerEmail, + }); + expect(outcome).toBe('logged'); + + const decision = await deps.KyselyPg.selectFrom( + 'manual_review_tool.manual_review_decisions', + ) + .select(['assigned_at', 'reviewer_id']) + .where('org_id', '=', orgId) + .where(sql`(job_payload->>'id')::text`, '=', claimed.job.id) + .executeTakeFirstOrThrow(); + + expect(decision.reviewer_id).toBe(triggerReviewerId); + expect(decision.assigned_at).toBeNull(); + }, + ); + + testWithQueue( + 'still dequeues when claim logging fails', + async ({ mrtService, org, queue }) => { + const orgId = org.id; + const queueId = queue.id; + const firstReviewerId = uuidv1(); + const jobPayload = makeDummyMrtJobPayload(); + + await mrtService['queueOps']['addJob']({ + jobPayload, + orgId, + queueId, + enqueueSourceInfo: { kind: 'REPORT' }, + }); + + const releaseSpy = jest.spyOn(mrtService['queueOps'], 'releaseJobLock'); + const logClaimSpy = jest + .spyOn(mrtService['claimOps'], 'logClaim') + .mockRejectedValueOnce(new Error('claim insert failed')); + + const dequeued = await mrtService.dequeueNextJob({ + orgId, + queueId, + userId: firstReviewerId, + }); + + expect(dequeued).not.toBeNull(); + expect(dequeued?.lockToken).toBe(firstReviewerId); + expect(releaseSpy).not.toHaveBeenCalled(); + + logClaimSpy.mockRestore(); + releaseSpy.mockRestore(); + }, + ); + }); }); diff --git a/server/services/manualReviewToolService/manualReviewToolService.ts b/server/services/manualReviewToolService/manualReviewToolService.ts index d8a733ab..7b663d5e 100644 --- a/server/services/manualReviewToolService/manualReviewToolService.ts +++ b/server/services/manualReviewToolService/manualReviewToolService.ts @@ -37,10 +37,12 @@ import { type ManualReviewToolServicePg, } from './dbTypes.js'; import AppealsJobRouting from './modules/AppealsJobRouting.js'; +import ClaimOperations from './modules/ClaimOperations.js'; import CommentOperations from './modules/CommentOperations.js'; import DecisionAnalytics, { type DecisionCountsInput, type DecisionCountsTableInput, + type HandleTimeInput, type JobCountsInput, type JobCreationsInput, type RecentDecisionsFilterInput, @@ -300,6 +302,7 @@ export class ManualReviewToolService { private readonly manualReviewToolSettings: ManualReviewToolSettings; private readonly commentOps: CommentOperations; private readonly skipOps: SkipOperations; + private readonly claimOps: ClaimOperations; private readonly reporterInvalidation: ReporterInvalidation; private readonly userReportSweep: UserReportSweep; @@ -346,6 +349,7 @@ export class ManualReviewToolService { //routingRuleExecutionLogger, ); this.manualReviewToolSettings = new ManualReviewToolSettings(pgQuery); + this.claimOps = new ClaimOperations(pgQuery); this.jobDecisioning = new JobDecisioning( this.queueOps, pgQuery, @@ -354,6 +358,7 @@ export class ManualReviewToolService { moderationConfigService, this.tracer, this.manualReviewToolSettings, + this.claimOps, ); this.jobRendering = new JobRendering(pgQuery); this.decisionAnalytics = new DecisionAnalytics(pgQueryReadReplica); @@ -1155,6 +1160,10 @@ export class ManualReviewToolService { return this.decisionAnalytics.getTimeToAction(input); } + async getHandleTime(input: HandleTimeInput) { + return this.decisionAnalytics.getHandleTime(input); + } + async getDecisionCounts(input: DecisionCountsInput) { return this.decisionAnalytics.getDecisionCounts(input); } @@ -1229,6 +1238,14 @@ export class ManualReviewToolService { lockToken: userId, }); if (!shouldBeAutoActioned || !job) { + if (job) { + await this.#logClaimBestEffort({ + orgId, + queueId, + userId, + jobId: job.job.id, + }); + } return job; } @@ -1245,6 +1262,12 @@ export class ManualReviewToolService { }) .catch(() => null); if (!freshItemInfo) { + await this.#logClaimBestEffort({ + orgId, + queueId, + userId, + jobId: job.job.id, + }); return job; } @@ -1271,6 +1294,12 @@ export class ManualReviewToolService { // deleted, we should auto-close the job and move on to the next one. shouldBeAutoActioned = deletedFieldValue || isDeletedFieldRole; if (!shouldBeAutoActioned) { + await this.#logClaimBestEffort({ + orgId, + queueId, + userId, + jobId: job.job.id, + }); return job; } else { await this.submitDecision({ @@ -1300,6 +1329,41 @@ export class ManualReviewToolService { return null; } + /** + * Claim rows are analytics-only (`assigned_at` is nullable; handle-time + * queries skip nulls). Match `job_creations` logging: never block dequeue. + */ + async #logClaimBestEffort(opts: { + orgId: string; + queueId: string; + userId: string; + jobId: JobId; + }) { + const { orgId, queueId, userId, jobId } = opts; + try { + await this.claimOps.logClaim({ + orgId, + queueId, + jobId, + userId, + }); + } catch (error) { + this.tracer.addSpan( + { + resource: 'mrtService', + operation: 'logClaimBestEffort', + }, + (span) => { + span.setAttribute('job.id', jobId); + span.setAttribute('org.id', orgId); + span.setAttribute('queue.id', queueId); + this.tracer.logSpanFailed(span, error); + return null; + }, + ); + } + } + async deleteAllJobsFromQueue(opts: { orgId: string; queueId: string; diff --git a/server/services/manualReviewToolService/modules/ClaimOperations.ts b/server/services/manualReviewToolService/modules/ClaimOperations.ts new file mode 100644 index 00000000..afe27333 --- /dev/null +++ b/server/services/manualReviewToolService/modules/ClaimOperations.ts @@ -0,0 +1,60 @@ +import { type Kysely } from 'kysely'; + +import { makeNotFoundError } from '../../../utils/errors.js'; +import { isForeignKeyViolationError } from '../../../utils/kysely.js'; +import { type ManualReviewToolServicePg } from '../dbTypes.js'; +import { type JobId } from '../manualReviewToolService.js'; + +export default class ClaimOperations { + constructor(private readonly pgQuery: Kysely) {} + + async logClaim(opts: { + orgId: string; + jobId: JobId; + queueId: string; + userId: string; + }) { + const { orgId, jobId, queueId, userId } = opts; + try { + await this.pgQuery + .insertInto('manual_review_tool.job_claims') + .values({ + org_id: orgId, + job_id: jobId, + queue_id: queueId, + user_id: userId, + }) + .executeTakeFirst(); + } catch (e) { + if (isForeignKeyViolationError(e)) { + throw makeNotFoundError('Queue not found', { shouldErrorSpan: true }); + } + + throw e; + } + } + + async getLatestClaimedAt(opts: { + orgId: string; + jobId: JobId; + userId?: string; + }): Promise { + const { orgId, jobId, userId } = opts; + let query = this.pgQuery + .selectFrom('manual_review_tool.job_claims') + .select('claimed_at') + .where('org_id', '=', orgId) + .where('job_id', '=', jobId); + + if (userId != null) { + query = query.where('user_id', '=', userId); + } + + const row = await query + .orderBy('claimed_at', 'desc') + .limit(1) + .executeTakeFirst(); + + return row?.claimed_at ?? null; + } +} diff --git a/server/services/manualReviewToolService/modules/DecisionAnalytics.ts b/server/services/manualReviewToolService/modules/DecisionAnalytics.ts index bcf319d6..5a20a611 100644 --- a/server/services/manualReviewToolService/modules/DecisionAnalytics.ts +++ b/server/services/manualReviewToolService/modules/DecisionAnalytics.ts @@ -185,6 +185,47 @@ export default class DecisionAnalytics { .execute(); } + async getHandleTime(input: HandleTimeInput) { + const { orgId, groupBy, filterBy } = input; + const { ref } = this.pgQuery.dynamic; + return this.pgQuery + .selectFrom('manual_review_tool.manual_review_decisions as decisions') + .select(({ fn, val }) => + fn('date_part', [ + val('EPOCH'), + fn.avg(({ eb, ref }) => + eb('decisions.created_at', '-', ref('decisions.assigned_at')), + ), + ]).as('handle_time'), + ) + .$if(groupBy.includes('queue_id'), (qb) => + qb.select('decisions.queue_id as queue_id'), + ) + .$if(groupBy.includes('reviewer_id'), (qb) => + qb.select('decisions.reviewer_id as reviewer_id'), + ) + .where((eb) => { + return eb.and([ + eb('decisions.org_id', '=', orgId), + eb('decisions.assigned_at', 'is not', null), + eb('decisions.created_at', '>=', filterBy.startDate), + eb('decisions.created_at', '<=', filterBy.endDate), + ...(filterBy.queueIds.length > 0 + ? [eb('decisions.queue_id', 'in', filterBy.queueIds)] + : []), + ...(filterBy.reviewerIds.length > 0 + ? [eb('decisions.reviewer_id', 'in', filterBy.reviewerIds)] + : []), + ]); + }) + .$if(groupBy.length > 0, (qb) => + qb.groupBy([ + ...groupBy.map((it) => ref(`decisions.${it as string}`)).flat(), + ]), + ) + .execute(); + } + async getJobCreations(input: JobCreationsInput) { const { groupBy, filterBy, orgId, timeDivision, timeZone } = input; @@ -266,6 +307,8 @@ export default class DecisionAnalytics { 'item_type_id', ), 'decision_reason', + 'assigned_at', + sql`job_payload->>'createdAt'`.as('job_created_at'), sql`(job_payload->>'id')::text`.as('job_id'), ]) .where('org_id', '=', orgId) @@ -394,6 +437,10 @@ export default class DecisionAnalytics { type: 'RELATED_ACTION' as const, })), createdAt: decision.created_at, + assignedAt: decision.assigned_at, + jobCreatedAt: decision.job_created_at + ? new Date(decision.job_created_at) + : null, decisionReason: decision.decision_reason, jobId: decision.job_id, })); @@ -465,6 +512,8 @@ export default class DecisionAnalytics { 'related_actions', 'created_at', 'decision_reason', + 'assigned_at', + sql`job_payload->>'createdAt'`.as('job_created_at'), sql`((job_payload->'payload'::text)->'item'::text) -> 'itemId'::text`.as( 'item_id', ), @@ -519,6 +568,10 @@ export default class DecisionAnalytics { type: 'RELATED_ACTION' as const, })), createdAt: decisionWithPayload.created_at, + assignedAt: decisionWithPayload.assigned_at, + jobCreatedAt: decisionWithPayload.job_created_at + ? new Date(decisionWithPayload.job_created_at) + : null, decisionReason: decisionWithPayload.decision_reason, jobId: decisionWithPayload.job_id, }, @@ -547,6 +600,17 @@ export type TimeToActionInput = ReadonlyDeep<{ }; }>; +export type HandleTimeInput = ReadonlyDeep<{ + orgId: string; + groupBy: Array<'queue_id' | 'reviewer_id'>; + filterBy: { + queueIds: string[]; + reviewerIds: string[]; + startDate: Date; + endDate: Date; + }; +}>; + export type JobCreationsInput = ReadonlyDeep<{ orgId: string; groupBy: Array<'queue_id' | 'item_type_id' | 'policy_id' | 'source'>; diff --git a/server/services/manualReviewToolService/modules/JobDecisioning.ts b/server/services/manualReviewToolService/modules/JobDecisioning.ts index 65298085..ed965c76 100644 --- a/server/services/manualReviewToolService/modules/JobDecisioning.ts +++ b/server/services/manualReviewToolService/modules/JobDecisioning.ts @@ -28,6 +28,7 @@ import { type ManualReviewJobEnqueueSourceInfo, type ReportHistory, } from '../manualReviewToolService.js'; +import type ClaimOperations from './ClaimOperations.js'; import type ManualReviewToolSettings from './ManualReviewToolSettings.js'; import type QueueOperations from './QueueOperations.js'; import { jobIdToGuid } from './QueueOperations.js'; @@ -191,6 +192,7 @@ export default class JobDecisioning { private readonly moderationConfigService: Dependencies['ModerationConfigService'], private readonly tracer: Dependencies['Tracer'], private readonly manualReviewToolSettings: ManualReviewToolSettings, + private readonly claimOps: ClaimOperations, ) {} async submitDecision(opts: SubmitDecisionInput) { @@ -540,6 +542,7 @@ export default class JobDecisioning { relatedActions: [], enqueueSourceInfo: job.enqueueSourceInfo, decisionReason, + recordAssignedAt: false, }); } catch (error) { // A concurrent reviewer already decided this job; nothing left to do. @@ -620,6 +623,7 @@ export default class JobDecisioning { relatedActions: ManualReviewDecisionRelatedAction[]; enqueueSourceInfo?: ManualReviewJobEnqueueSourceInfo; decisionReason?: string; + recordAssignedAt?: boolean; }) { const { id, @@ -631,6 +635,7 @@ export default class JobDecisioning { relatedActions, enqueueSourceInfo, decisionReason, + recordAssignedAt = true, } = opts; const itemType = await this.moderationConfigService.getItemType({ @@ -677,6 +682,34 @@ export default class JobDecisioning { ); } + const isAutomaticClose = decisionComponents.some( + (component) => component.type === 'AUTOMATIC_CLOSE', + ); + const assignedAt = + recordAssignedAt && reviewerId != null && !isAutomaticClose + ? await this.claimOps + .getLatestClaimedAt({ + orgId, + jobId: job.id, + userId: reviewerId, + }) + .catch((error: unknown) => { + this.tracer.addSpan( + { + resource: 'mrtService', + operation: 'logDecision.getLatestClaimedAt', + }, + (span) => { + span.setAttribute('job.id', job.id); + span.setAttribute('org.id', orgId); + this.tracer.logSpanFailed(span, error); + return null; + }, + ); + return null; + }) + : null; + return this.pgQuery .insertInto('manual_review_tool.manual_review_decisions') .values({ @@ -693,6 +726,7 @@ export default class JobDecisioning { enqueue_source_info: enqueueSourceInfo, item_created_at: itemCreatedAt, decision_reason: decisionReason, + assigned_at: assignedAt, }) .execute(); }