From 0c3102b77f37dd0c1b031be5830b644a46ed7bed Mon Sep 17 00:00:00 2001 From: michael Date: Sat, 14 Feb 2026 13:18:02 -0600 Subject: [PATCH] feat: add Return to QC action for jobs in resting statuses Allow production/admin users to move jobs back to pending_qc from completed, pending_final_review, rejected, qc_feedback, tts_failed, render_failed, approved_english, and approved_source statuses. Includes single-job endpoint, bulk endpoint, JobDetail inline form with required notes, bulk action in JobsList with confirmation modal, and a Review Notes card on the job overview tab. Co-Authored-By: Claude Opus 4.6 --- backend/app/api/v1/routes_jobs.py | 142 ++++++++++++++++++++++ backend/app/schemas/job.py | 15 +++ frontend/src/hooks/useJob.ts | 25 ++++ frontend/src/lib/api.ts | 12 ++ frontend/src/routes/jobs/JobDetail.tsx | 113 +++++++++++++++++- frontend/src/routes/jobs/JobsList.tsx | 157 ++++++++++++++++++++++++- frontend/src/types/api.ts | 11 ++ 7 files changed, 470 insertions(+), 5 deletions(-) diff --git a/backend/app/api/v1/routes_jobs.py b/backend/app/api/v1/routes_jobs.py index 5f8e9e0..d068ef7 100644 --- a/backend/app/api/v1/routes_jobs.py +++ b/backend/app/api/v1/routes_jobs.py @@ -22,12 +22,15 @@ from ...schemas.job import ( BulkDeleteRequest, BulkDeleteResponse, BulkDownloadRequest, + BulkReturnToQCRequest, + BulkReturnToQCResponse, CompleteJobRequest, JobDeleteResponse, JobDownloadsResponse, JobListResponse, JobResponse, RejectJobRequest, + ReturnToQCRequest, UpdateTTSPreferencesRequest, VttContentResponse, VttTimingAdjustRequest, @@ -321,6 +324,70 @@ async def bulk_approve_jobs( } +@router.post("/bulk/return-to-qc", response_model=BulkReturnToQCResponse) +async def bulk_return_to_qc( + request: BulkReturnToQCRequest, + current_user: User = Depends(require_roles(UserRole.PRODUCTION, UserRole.ADMIN)), + db: AsyncIOMotorDatabase = Depends(get_database), +): + """Bulk return multiple jobs to QC review status.""" + unique_job_ids = list(dict.fromkeys(request.job_ids)) + if len(unique_job_ids) != len(request.job_ids): + logger.warning(f"Removed {len(request.job_ids) - len(unique_job_ids)} duplicate job IDs from bulk return-to-qc request") + + logger.info(f"Bulk return-to-qc for {len(unique_job_ids)} jobs requested by {current_user.email}") + + returned_count = 0 + errors = [] + + for job_id in unique_job_ids: + try: + job_doc = await db.jobs.find_one({"_id": job_id}) + if not job_doc: + errors.append(f"Job {job_id}: not found") + continue + + if job_doc["status"] not in RETURN_TO_QC_ELIGIBLE_STATUSES: + errors.append(f"Job {job_id}: cannot return to QC from status '{job_doc['status']}'") + continue + + result = await db.jobs.find_one_and_update( + {"_id": job_id}, + { + "$set": { + "status": JobStatus.PENDING_QC.value, + "error": None, + "updated_at": datetime.utcnow() + }, + "$push": { + "review.history": { + "at": datetime.utcnow(), + "status": JobStatus.PENDING_QC.value, + "by": str(current_user.id), + "notes": request.notes + } + } + }, + return_document=True + ) + + if result: + returned_count += 1 + logger.info(f"Job {job_id} returned to QC") + else: + errors.append(f"Job {job_id}: update failed (may have been modified concurrently)") + + except Exception as e: + errors.append(f"Job {job_id}: {str(e)}") + logger.error(f"Failed to return job {job_id} to QC: {e}") + + return { + "returned_count": returned_count, + "total_requested": len(unique_job_ids), + "errors": errors + } + + # Downloadable job statuses DOWNLOADABLE_STATUSES = [ JobStatus.COMPLETED.value, @@ -835,6 +902,81 @@ async def reject_final_review( ) +# Statuses eligible for "Return to QC" action +RETURN_TO_QC_ELIGIBLE_STATUSES = [ + JobStatus.COMPLETED.value, + JobStatus.PENDING_FINAL_REVIEW.value, + JobStatus.REJECTED.value, + JobStatus.QC_FEEDBACK.value, + JobStatus.TTS_FAILED.value, + JobStatus.RENDER_FAILED.value, + JobStatus.APPROVED_ENGLISH.value, + JobStatus.APPROVED_SOURCE.value, +] + + +@router.post("/{job_id}/actions/return_to_qc", response_model=JobResponse) +async def return_to_qc( + job_id: str, + request: ReturnToQCRequest, + current_user: User = Depends(require_roles(UserRole.PRODUCTION, UserRole.ADMIN)), + db: AsyncIOMotorDatabase = Depends(get_database), +): + """Return a job to QC review status for re-editing.""" + job_doc = await db.jobs.find_one({"_id": job_id}) + if not job_doc: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Job not found" + ) + + if job_doc["status"] not in RETURN_TO_QC_ELIGIBLE_STATUSES: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Job cannot be returned to QC from status '{job_doc['status']}'" + ) + + result = await db.jobs.find_one_and_update( + {"_id": job_id}, + { + "$set": { + "status": JobStatus.PENDING_QC.value, + "error": None, + "updated_at": datetime.utcnow() + }, + "$push": { + "review.history": { + "at": datetime.utcnow(), + "status": JobStatus.PENDING_QC.value, + "by": str(current_user.id), + "notes": request.notes + } + } + }, + return_document=True + ) + + if not result: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Job not found or status changed" + ) + + logger.info(f"Job {job_id} returned to QC by {current_user.email}: {request.notes}") + + return JobResponse( + id=str(result["_id"]), + title=result["title"], + status=result["status"], + source=result["source"], + requested_outputs=RequestedOutputs(**result["requested_outputs"]), + review=result.get("review", {"notes": "", "history": []}), + outputs=result.get("outputs"), + created_at=result["created_at"].isoformat(), + updated_at=result["updated_at"].isoformat() + ) + + @router.get("/{job_id}/downloads", response_model=JobDownloadsResponse) async def get_job_downloads( job_id: str, diff --git a/backend/app/schemas/job.py b/backend/app/schemas/job.py index d71fb70..bc6f7b0 100644 --- a/backend/app/schemas/job.py +++ b/backend/app/schemas/job.py @@ -127,6 +127,21 @@ class BulkApproveResponse(BaseModel): errors: list[str] +class ReturnToQCRequest(BaseModel): + notes: str + + +class BulkReturnToQCRequest(BaseModel): + job_ids: list[str] + notes: str + + +class BulkReturnToQCResponse(BaseModel): + returned_count: int + total_requested: int + errors: list[str] + + class BulkDownloadRequest(BaseModel): """Request to download multiple jobs as a single zip file""" job_ids: list[str] diff --git a/frontend/src/hooks/useJob.ts b/frontend/src/hooks/useJob.ts index 2ca29fe..d27e844 100644 --- a/frontend/src/hooks/useJob.ts +++ b/frontend/src/hooks/useJob.ts @@ -6,6 +6,7 @@ import type { VttUpdateRequest, BulkDeleteRequest, BulkApproveRequest, + BulkReturnToQCRequest, TTSPreferences, AccessibleVideoMethod } from '../types/api'; @@ -274,4 +275,28 @@ export function useRetryTts() { queryClient.invalidateQueries({ queryKey: ['jobs'] }); }, }); +} + +export function useReturnToQC() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ id, notes }: { id: string; notes: string }) => + apiClient.returnToQC(id, { notes }), + onSuccess: (_, { id }) => { + queryClient.invalidateQueries({ queryKey: ['jobs', id] }); + queryClient.invalidateQueries({ queryKey: ['jobs'] }); + }, + }); +} + +export function useBulkReturnToQC() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (data: BulkReturnToQCRequest) => apiClient.bulkReturnToQC(data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['jobs'] }); + }, + }); } \ No newline at end of file diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 7e51649..b935817 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -16,6 +16,8 @@ import type { BulkDeleteResponse, BulkApproveRequest, BulkApproveResponse, + BulkReturnToQCRequest, + BulkReturnToQCResponse, JobDeleteResponse, User, UserListResponse, @@ -289,6 +291,16 @@ class ApiClient { return response.data; } + async returnToQC(id: string, data: { notes: string }): Promise { + const response = await this.client.post(`/jobs/${id}/actions/return_to_qc`, data); + return response.data; + } + + async bulkReturnToQC(data: BulkReturnToQCRequest): Promise { + const response = await this.client.post('/jobs/bulk/return-to-qc', data); + return response.data; + } + // User Management endpoints async listUsers(filters?: { page?: number; diff --git a/frontend/src/routes/jobs/JobDetail.tsx b/frontend/src/routes/jobs/JobDetail.tsx index 08d6c19..0b31053 100644 --- a/frontend/src/routes/jobs/JobDetail.tsx +++ b/frontend/src/routes/jobs/JobDetail.tsx @@ -1,12 +1,18 @@ import { useState } from 'react'; import { useParams, Link } from 'react-router-dom'; import { formatDistanceToNow } from 'date-fns'; -import { useJob, useJobDownloads, useJobVttContent, useRetryTts } from '../../hooks/useJob'; +import { useJob, useJobDownloads, useJobVttContent, useRetryTts, useReturnToQC } from '../../hooks/useJob'; +import { useAuthStore } from '../../lib/auth'; import { StatusBadge } from '../../components/StatusBadge'; import { VideoWithCaptions } from '../../components/VideoWithCaptions'; import { useGlobalWebSocket } from '../../contexts/GlobalWebSocketContext'; import { useToastContext } from '../../contexts/ToastContext'; +const RETURN_TO_QC_ELIGIBLE_STATUSES = [ + 'completed', 'pending_final_review', 'rejected', 'qc_feedback', + 'tts_failed', 'render_failed', 'approved_english', 'approved_source', +]; + const ProgressIndicator = ({ status }: { status: string }) => { // New flow: processing happens before QC, approval goes directly to final review @@ -116,6 +122,28 @@ export function JobDetail() { const retryTtsMutation = useRetryTts(); const toast = useToastContext(); + // Return to QC + const { user } = useAuthStore(); + const returnToQCMutation = useReturnToQC(); + const [showReturnToQC, setShowReturnToQC] = useState(false); + const [returnToQCNotes, setReturnToQCNotes] = useState(''); + + const canReturnToQC = user && + (user.role === 'production' || user.role === 'admin') && + job?.status && RETURN_TO_QC_ELIGIBLE_STATUSES.includes(job.status); + + const handleReturnToQC = async () => { + if (!id || !returnToQCNotes.trim()) return; + try { + await returnToQCMutation.mutateAsync({ id, notes: returnToQCNotes.trim() }); + toast.toastOnly.success('Job returned to QC review'); + setShowReturnToQC(false); + setReturnToQCNotes(''); + } catch (err) { + toast.toastOnly.error('Failed to return job to QC'); + } + }; + const handleRetryTts = async () => { if (!id) return; try { @@ -211,6 +239,35 @@ export function JobDetail() { + {/* Review Notes */} + {(() => { + const notesEntries = job.review?.history?.filter(entry => entry.notes && entry.notes.trim()) || []; + return ( +
+

Review Notes

+ {notesEntries.length > 0 ? ( +
+ {notesEntries.map((entry, index) => ( +
+
+ + {entry.status.replace(/_/g, ' ')} + + + {formatDistanceToNow(new Date(entry.at))} ago + +
+

{entry.notes}

+
+ ))} +
+ ) : ( +

No review notes yet

+ )} +
+ ); + })()} + {/* Download Section */} {job.status === 'completed' && (
@@ -443,6 +500,60 @@ export function JobDetail() {
)} + + {/* Return to QC */} + {canReturnToQC && ( +
+ {!showReturnToQC ? ( +
+
+

Return to QC

+

+ Move this job back to QC review for further editing. +

+
+ +
+ ) : ( +
+

Return to QC

+

+ This will move the job back to QC review status. Please provide a reason. +

+