From 08fcb4daa4f9adf5319dd5f196f56a6e2d725bab Mon Sep 17 00:00:00 2001 From: Vadym Samoilenko Date: Wed, 29 Apr 2026 19:42:57 +0100 Subject: [PATCH] feat(pr6): WS real-time updates, per-cue AD playback, upload guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit W-4: team assignment (linguist/reviewer) stored on job at creation, auto-assigned to all language QC states on first GET /language-qc (lazy init via auto_assign_defaults) L-3 WS: broadcast_to_job when reviewer opens VTT for editing; QCDetail shows "User X is editing [lang]" banner (auto-clears 5s) R-5: comment broadcast via broadcast_to_job on add_comment(); QCDetail invalidates comments query on language_qc_comment WS event L-15: QCDetail subscribes to language_qc_assigned WS event → refetches lang-qc data and shows toast R-7: VttEditor gets onCuePlay prop; AD editor in QCDetail wires handleAdCuePlay → switches to accessible video mode, seeks & plays T-15: beforeunload warning in NewJob while upload is in progress Co-Authored-By: Claude Sonnet 4.6 --- backend/app/api/v1/routes_jobs.py | 20 +++++ backend/app/api/v1/routes_language_qc.py | 2 + backend/app/services/language_qc.py | 78 ++++++++++++++++++- backend/app/services/websocket.py | 14 ++++ .../src/components/VttEditor/VttEditor.tsx | 13 +++- frontend/src/hooks/useJobStatusWebSocket.ts | 25 ++++-- frontend/src/hooks/useMultiUpload.ts | 4 + frontend/src/lib/api.ts | 6 ++ frontend/src/routes/admin/QCDetail.tsx | 61 ++++++++++++++- frontend/src/routes/jobs/NewJob.tsx | 64 ++++++++++++++- frontend/src/types/api.ts | 2 + 11 files changed, 276 insertions(+), 13 deletions(-) diff --git a/backend/app/api/v1/routes_jobs.py b/backend/app/api/v1/routes_jobs.py index 5f2f4ad..cd337b1 100644 --- a/backend/app/api/v1/routes_jobs.py +++ b/backend/app/api/v1/routes_jobs.py @@ -87,6 +87,8 @@ async def create_job( brand_context: str | None = Form(None), project_id: str | None = Form(None), deadline: str | None = Form(None), # ISO date string e.g. "2026-05-15" + initial_linguist_id: str | None = Form(None), + initial_reviewer_id: str | None = Form(None), request: Request = None, current_user: User = Depends(get_current_user), db: AsyncIOMotorDatabase = Depends(get_database), @@ -140,6 +142,8 @@ async def create_job( "brand_context": brand_context or None, "project_id": project_id or None, "deadline": datetime.fromisoformat(deadline) if deadline else None, + "initial_linguist_id": initial_linguist_id or None, + "initial_reviewer_id": initial_reviewer_id or None, "created_at": datetime.utcnow(), "updated_at": datetime.utcnow() } @@ -1274,6 +1278,22 @@ async def get_job_vtt_content( combined = (response.captions_vtt or "") + "|" + (response.audio_description_vtt or "") response.etag = hashlib.sha1(combined.encode()).hexdigest() + # L-3 WS: broadcast editing-started indicator to other viewers of this job + if current_user.role in (UserRole.LINGUIST, UserRole.REVIEWER, UserRole.PRODUCTION, UserRole.ADMIN): + try: + from ...services.websocket import connection_manager + await connection_manager.broadcast_to_job(job_id, { + "type": "language_editing_started", + "job_id": job_id, + "data": { + "lang": target_language, + "user_name": current_user.full_name or current_user.email, + "user_id": str(current_user.id), + }, + }) + except Exception: + pass + return response diff --git a/backend/app/api/v1/routes_language_qc.py b/backend/app/api/v1/routes_language_qc.py index 14532e2..1772970 100644 --- a/backend/app/api/v1/routes_language_qc.py +++ b/backend/app/api/v1/routes_language_qc.py @@ -109,6 +109,8 @@ async def get_language_qc( )), db: AsyncIOMotorDatabase = Depends(get_database), ): + # Lazy auto-assignment: apply project/job defaults on first open in PENDING_QC + await lqc.auto_assign_defaults(db, job_id) states = await lqc.get_all_states(db, job_id) return LanguageQCMapResponse(job_id=job_id, language_qc=states) diff --git a/backend/app/services/language_qc.py b/backend/app/services/language_qc.py index 91c10a7..114b775 100644 --- a/backend/app/services/language_qc.py +++ b/backend/app/services/language_qc.py @@ -94,6 +94,67 @@ def _deep_link(job_id: str, lang: str) -> str: return f"{base}/admin/qc/{job_id}#lang-{lang}" +# ── Auto-assignment ─────────────────────────────────────────────────────────── + +async def auto_assign_defaults(db: AsyncIOMotorDatabase, job_id: str) -> int: + """Apply job.initial_linguist_id / initial_reviewer_id to all unassigned languages. + + Called lazily when the language-QC map is first fetched in PENDING_QC state, + so PM assignments made at job-creation time take effect without touching Celery tasks. + Returns the number of languages updated. + """ + job_doc = await db[_JOBS].find_one({"_id": job_id}) + if not job_doc: + return 0 + + linguist_id: str | None = job_doc.get("initial_linguist_id") + reviewer_id: str | None = job_doc.get("initial_reviewer_id") + if not linguist_id and not reviewer_id: + return 0 + + languages: list[str] = (job_doc.get("requested_outputs") or {}).get("languages") or [] + if not languages: + return 0 + + linguist_doc = await db.users.find_one({"_id": linguist_id}) if linguist_id else None + reviewer_doc = await db.users.find_one({"_id": reviewer_id}) if reviewer_id else None + + now = datetime.utcnow() + updated = 0 + current_qc: dict = job_doc.get("language_qc") or {} + + for lang in languages: + lang_state: dict = current_qc.get(lang) or {} + already_assigned = bool(lang_state.get("assigned_linguist_id")) + if already_assigned: + continue + + patch: dict = {} + if linguist_doc: + patch.update({ + f"language_qc.{lang}.assigned_linguist_id": linguist_id, + f"language_qc.{lang}.assigned_linguist_email": linguist_doc["email"], + f"language_qc.{lang}.assigned_linguist_name": linguist_doc.get("full_name", ""), + f"language_qc.{lang}.assigned_at": now, + f"language_qc.{lang}.assigned_by_user_id": "system", + f"language_qc.{lang}.status": lang_state.get("status", LanguageQCStatus.PENDING.value), + }) + if reviewer_doc: + patch.update({ + f"language_qc.{lang}.assigned_reviewer_id": reviewer_id, + f"language_qc.{lang}.assigned_reviewer_email": reviewer_doc["email"], + f"language_qc.{lang}.assigned_reviewer_name": reviewer_doc.get("full_name", ""), + }) + + if patch: + await db[_JOBS].update_one({"_id": job_id}, {"$set": patch}) + updated += 1 + + if updated: + logger.info("auto_assign_defaults: assigned %d languages on job %s", updated, job_id) + return updated + + # ── Core mutations ──────────────────────────────────────────────────────────── async def get_state(db: AsyncIOMotorDatabase, job_id: str, lang: str) -> Optional[LanguageQCState]: @@ -826,7 +887,22 @@ async def add_comment( details={"lang": lang}, ) - # Fan-out to all other assignees + # WS broadcast — live comment indicator for everyone on this job + try: + await connection_manager.broadcast_to_job(job_id, { + "type": "language_qc_comment", + "job_id": job_id, + "lang": lang, + "data": { + "author_name": actor.full_name or actor.email, + "lang": lang, + "comment_id": comment.id, + }, + }) + except Exception: + pass + + # Fan-out email to all other assignees recipients = _qc_recipients(job_doc, current_state_raw if isinstance(current_state_raw, dict) else {}, exclude_user_id=actor.email) if recipients: try: diff --git a/backend/app/services/websocket.py b/backend/app/services/websocket.py index c08d572..8d4ba80 100644 --- a/backend/app/services/websocket.py +++ b/backend/app/services/websocket.py @@ -386,6 +386,20 @@ class ConnectionManager: logger.warning(f"WebSocket send failed: {e}") raise + async def broadcast_to_job(self, job_id: str, message: dict[str, Any]) -> None: + """Broadcast an arbitrary message to all WS clients subscribed to a job.""" + async with self.lock: + sockets = list(self.job_ws.get(job_id, set())) + if sockets: + await self._send_to_websockets(sockets, message) + + async def broadcast_to_user(self, user_id: str, message: dict[str, Any]) -> None: + """Broadcast an arbitrary message to all WS connections for a specific user.""" + async with self.lock: + sockets = list(self.user_ws.get(user_id, set())) + if sockets: + await self._send_to_websockets(sockets, message) + # Global connection manager instance connection_manager = ConnectionManager() diff --git a/frontend/src/components/VttEditor/VttEditor.tsx b/frontend/src/components/VttEditor/VttEditor.tsx index 7ee6a53..f279b3d 100644 --- a/frontend/src/components/VttEditor/VttEditor.tsx +++ b/frontend/src/components/VttEditor/VttEditor.tsx @@ -85,13 +85,14 @@ interface VttEditorProps { onCueSave?: (cueIndex: number, vttContent: string) => Promise; onCueInserted?: (insertedIndex: number, totalCues: number) => void; onCueDeleted?: (deletedIndex: number, totalCuesAfterDelete: number) => void; + onCuePlay?: (startTime: number) => void; title: string; readOnly?: boolean; glossaryTerms?: GlossaryTerm[]; language?: string; } -export function VttEditor({ vttContent, onChange, onCueSave, onCueInserted, onCueDeleted, title, readOnly = false, glossaryTerms = [], language = 'en' }: VttEditorProps) { +export function VttEditor({ vttContent, onChange, onCueSave, onCueInserted, onCueDeleted, onCuePlay, title, readOnly = false, glossaryTerms = [], language = 'en' }: VttEditorProps) { const [cues, setCues] = useState([]); const [errors, setErrors] = useState([]); const [editingCue, setEditingCue] = useState(null); @@ -315,6 +316,16 @@ export function VttEditor({ vttContent, onChange, onCueSave, onCueInserted, onCu Saving... )} + {/* Play-from-cue button (R-7) */} + {onCuePlay && hoveredCue === index && ( + + )} {/* Action Buttons */} diff --git a/frontend/src/hooks/useJobStatusWebSocket.ts b/frontend/src/hooks/useJobStatusWebSocket.ts index 34345a1..6d787e0 100644 --- a/frontend/src/hooks/useJobStatusWebSocket.ts +++ b/frontend/src/hooks/useJobStatusWebSocket.ts @@ -21,9 +21,11 @@ export interface JobStatusUpdate { } export interface WebSocketMessage { - type: 'connection_established' | 'job_status_update' | 'job_list_update'; - data?: JobStatusUpdate; + type: 'connection_established' | 'job_status_update' | 'job_list_update' + | 'language_qc_comment' | 'language_editing_started' | 'language_qc_assigned'; + data?: JobStatusUpdate & Record; job_id?: string; + lang?: string; scope?: string; timestamp?: string; } @@ -65,6 +67,11 @@ interface UseJobStatusWebSocketOptions { * Toast handler for connection status changes */ onConnectionChange?: (status: ConnectionStatus) => void; + + /** + * Raw message handler — called for every parsed WS message regardless of type + */ + onRawMessage?: (msg: WebSocketMessage) => void; } interface UseJobStatusWebSocketReturn { @@ -103,7 +110,8 @@ export function useJobStatusWebSocket( reconnectDelay = 1000, debug = false, onStatusUpdate, - onConnectionChange + onConnectionChange, + onRawMessage, } = options; const queryClient = useQueryClient(); @@ -228,18 +236,19 @@ export function useJobStatusWebSocket( log('Received message:', message); setLastMessage(message); - + onRawMessage?.(message); + if (message.type === 'job_status_update' || message.type === 'job_list_update') { if (message.data) { - setLastUpdate(message.data); - updateQueryCache(message.data); - handleStatusUpdate(message.data); + setLastUpdate(message.data as JobStatusUpdate); + updateQueryCache(message.data as JobStatusUpdate); + handleStatusUpdate(message.data as JobStatusUpdate); } } } catch (error) { console.error('[WebSocket] Failed to parse WebSocket message:', error, 'Raw data:', event.data); } - }, [log, updateQueryCache, handleStatusUpdate]); + }, [log, onRawMessage, updateQueryCache, handleStatusUpdate]); const handleOpen = useCallback(() => { console.log('[WebSocket] Connected successfully!'); diff --git a/frontend/src/hooks/useMultiUpload.ts b/frontend/src/hooks/useMultiUpload.ts index c7741de..6e89c6f 100644 --- a/frontend/src/hooks/useMultiUpload.ts +++ b/frontend/src/hooks/useMultiUpload.ts @@ -15,6 +15,8 @@ export interface SharedJobSettings { brandContext?: string; projectId?: string; deadline?: string; + initialLinguistId?: string; + initialReviewerId?: string; } interface UseMultiUploadOptions { @@ -112,6 +114,8 @@ export function useMultiUpload(options: UseMultiUploadOptions = {}): UseMultiUpl brand_context: settings.brandContext, project_id: settings.projectId, deadline: settings.deadline, + initial_linguist_id: settings.initialLinguistId, + initial_reviewer_id: settings.initialReviewerId, }, item.file, (progressEvent) => { diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 21282a9..87e0c15 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -202,6 +202,12 @@ class ApiClient { if (data.deadline) { formData.append('deadline', data.deadline); } + if (data.initial_linguist_id) { + formData.append('initial_linguist_id', data.initial_linguist_id); + } + if (data.initial_reviewer_id) { + formData.append('initial_reviewer_id', data.initial_reviewer_id); + } formData.append('file', file); const response = await this.client.post('/jobs', formData, { diff --git a/frontend/src/routes/admin/QCDetail.tsx b/frontend/src/routes/admin/QCDetail.tsx index 6e24f68..d13f8c8 100644 --- a/frontend/src/routes/admin/QCDetail.tsx +++ b/frontend/src/routes/admin/QCDetail.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef, useMemo } from 'react'; +import { useState, useEffect, useRef, useMemo, useCallback } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useParams, useNavigate } from 'react-router-dom'; import { useJob, useApproveEnglish, useRejectJob, useJobVttContent, useUpdateJobVtt, useJobDownloads, useAdjustVttTiming, useUpdateTTSPreferences, useUpdateJob } from '../../hooks/useJob'; @@ -20,6 +20,8 @@ import { useToastContext } from '../../contexts/ToastContext'; import { useAuthStore } from '../../lib/auth'; import { apiClient } from '../../lib/api'; import type { TTSPreferences, VideoSegmentMetadata, PausePointData, LanguageQCStatus, LanguageQCComment } from '../../types/api'; +import { useJobStatusWebSocket } from '../../hooks/useJobStatusWebSocket'; +import type { WebSocketMessage } from '../../hooks/useJobStatusWebSocket'; // ── Status display helpers ──────────────────────────────────────────────────── @@ -339,6 +341,35 @@ export function QCDetail() { // Refresh edit state when render completes (rendering_qc → pending_qc transition) const queryClient = useQueryClient(); + + // Editing indicator (L-3 WS): "User X is editing [lang]" banner, auto-clears after 5s + const [editingIndicator, setEditingIndicator] = useState<{ lang: string; userName: string } | null>(null); + const editingIndicatorTimerRef = useRef | null>(null); + + const handleRawWsMessage = useCallback((msg: WebSocketMessage) => { + if (msg.type === 'language_qc_comment' && msg.lang) { + queryClient.invalidateQueries({ queryKey: ['qc-comments', id, msg.lang] }); + } else if (msg.type === 'language_editing_started' && msg.data) { + const lang = (msg.data as { lang?: string }).lang ?? ''; + const userName = (msg.data as { user_name?: string }).user_name ?? 'Someone'; + if (editingIndicatorTimerRef.current) clearTimeout(editingIndicatorTimerRef.current); + setEditingIndicator({ lang, userName }); + editingIndicatorTimerRef.current = setTimeout(() => setEditingIndicator(null), 5000); + } else if (msg.type === 'language_qc_assigned') { + refetchLangQc(); + queryClient.invalidateQueries({ queryKey: ['language-qc', id] }); + toast.toastOnly.success('Language QC assignment updated'); + } + }, [id, queryClient, refetchLangQc, toast.toastOnly]); + + useJobStatusWebSocket(id, { onRawMessage: handleRawWsMessage }); + + useEffect(() => { + return () => { + if (editingIndicatorTimerRef.current) clearTimeout(editingIndicatorTimerRef.current); + }; + }, []); + useEffect(() => { if ( prevJobStatusRef.current === 'rendering_qc' && @@ -712,6 +743,16 @@ export function QCDetail() { } }; + const handleAdCuePlay = useCallback((startTime: number) => { + setVideoMode('accessible'); + setTimeout(() => { + if (videoRef.current) { + videoRef.current.currentTime = startTime; + videoRef.current.play().catch(() => { /* autoplay may be blocked */ }); + } + }, 80); + }, []); + const handlePausePointUpdate = async (cueIndex: number, adjustedMs: number) => { if (!id) return; @@ -869,6 +910,23 @@ export function QCDetail() { )} + {/* Editing indicator banner (L-3 WS) */} + {editingIndicator && ( +
+ + {editingIndicator.userName} is editing{' '} + {editingIndicator.lang.toUpperCase()} + + +
+ )} + {/* AI Confidence Score */} {job.ai?.confidence && (
@@ -1612,6 +1670,7 @@ export function QCDetail() { onCueSave={handleAdCueSave} onCueInserted={handleAdCueInserted} onCueDeleted={handleAdCueDeleted} + onCuePlay={accessibleVideoUrl ? handleAdCuePlay : undefined} title={`Audio Description (${selectedLanguage.toUpperCase()})`} readOnly={isProcessing} glossaryTerms={glossaryTerms} diff --git a/frontend/src/routes/jobs/NewJob.tsx b/frontend/src/routes/jobs/NewJob.tsx index c98c3b2..77ff4c4 100644 --- a/frontend/src/routes/jobs/NewJob.tsx +++ b/frontend/src/routes/jobs/NewJob.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { useNavigate, Link } from 'react-router-dom'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; @@ -57,6 +57,8 @@ export function NewJob() { const { data: reviewerUsersData } = useUsers({ role: 'reviewer', active_only: true }); const linguistUsers = linguistUsersData?.users ?? []; const reviewerUsers = reviewerUsersData?.users ?? []; + const [teamLinguistId, setTeamLinguistId] = useState(''); + const [teamReviewerId, setTeamReviewerId] = useState(''); const [showVoiceSettings, setShowVoiceSettings] = useState(false); const [ttsPreferences, setTtsPreferences] = useState({ provider: 'gemini', @@ -80,6 +82,17 @@ export function NewJob() { const isMultiMode = totalSelectedFiles >= 2; const isUploading = createJobMutation.isPending || multiUpload.isUploading; + // Warn before tab close/navigate away while upload is in progress (T-15) + useEffect(() => { + if (!isUploading) return; + const handler = (e: BeforeUnloadEvent) => { + e.preventDefault(); + e.returnValue = ''; + }; + window.addEventListener('beforeunload', handler); + return () => window.removeEventListener('beforeunload', handler); + }, [isUploading]); + const { register, handleSubmit, @@ -166,6 +179,8 @@ export function NewJob() { brand_context: brandContext.trim() || undefined, project_id: selectedProjectId || undefined, deadline: deadline || undefined, + initial_linguist_id: teamLinguistId || undefined, + initial_reviewer_id: teamReviewerId || undefined, }; try { @@ -257,6 +272,8 @@ export function NewJob() { brandContext: brandContext.trim() || undefined, projectId: selectedProjectId || undefined, deadline: deadline || undefined, + initialLinguistId: teamLinguistId || undefined, + initialReviewerId: teamReviewerId || undefined, }); }; @@ -337,6 +354,8 @@ export function NewJob() { }, brandContext: brandContext.trim() || undefined, projectId: selectedProjectId || undefined, + initialLinguistId: teamLinguistId || undefined, + initialReviewerId: teamReviewerId || undefined, }); }; @@ -716,6 +735,45 @@ export function NewJob() { disabled={isUploading} /> + {/* Team Assignment — auto-filled from project defaults, editable per-job */} + {selectedProjectId && ( +
+

Team Assignment + (applied to all languages when job enters QC) +

+
+
+ + +
+
+ + +
+
+
+ )} + {/* Translation Mode - Only shown when target languages are selected */} {languages.length > 0 && (
@@ -801,12 +859,14 @@ export function NewJob() { } else { setSelectedProjectId(value); setShowCreateProject(false); - // W-5: autofill job languages from project defaults + // W-5: autofill job languages + team from project defaults if (value) { const proj = projects.find(p => p.id === value); if (proj?.default_languages?.length) { setValue('languages', proj.default_languages); } + setTeamLinguistId(proj?.default_linguist_id ?? ''); + setTeamReviewerId(proj?.default_reviewer_id ?? ''); } } }} diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index eeb3fb4..01d2710 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -341,6 +341,8 @@ export interface JobCreateRequest { brand_context?: string; project_id?: string; deadline?: string; + initial_linguist_id?: string; + initial_reviewer_id?: string; } export interface JobListResponse {