feat(pr6): WS real-time updates, per-cue AD playback, upload guard

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 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-04-29 19:42:57 +01:00
parent bdfa0f82ab
commit 08fcb4daa4
11 changed files with 276 additions and 13 deletions

View file

@ -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

View file

@ -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)

View file

@ -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:

View file

@ -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()

View file

@ -85,13 +85,14 @@ interface VttEditorProps {
onCueSave?: (cueIndex: number, vttContent: string) => Promise<void>;
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<VTTCue[]>([]);
const [errors, setErrors] = useState<string[]>([]);
const [editingCue, setEditingCue] = useState<number | null>(null);
@ -315,6 +316,16 @@ export function VttEditor({ vttContent, onChange, onCueSave, onCueInserted, onCu
Saving...
</span>
)}
{/* Play-from-cue button (R-7) */}
{onCuePlay && hoveredCue === index && (
<button
onClick={() => onCuePlay(cue.startTime)}
className="ml-1 p-1 text-gray-400 hover:text-green-600 hover:bg-green-50 rounded transition-colors"
title="Play from this cue"
>
</button>
)}
</div>
{/* Action Buttons */}

View file

@ -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<string, unknown>;
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!');

View file

@ -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) => {

View file

@ -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, {

View file

@ -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<ReturnType<typeof setTimeout> | 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() {
</div>
)}
{/* Editing indicator banner (L-3 WS) */}
{editingIndicator && (
<div className="mb-4 flex items-center justify-between p-3 bg-yellow-50 border border-yellow-300 rounded-md text-yellow-800">
<span className="text-sm">
<span className="font-medium">{editingIndicator.userName}</span> is editing{' '}
<span className="font-mono">{editingIndicator.lang.toUpperCase()}</span>
</span>
<button
onClick={() => setEditingIndicator(null)}
className="ml-4 text-yellow-600 hover:text-yellow-800 font-bold"
aria-label="Dismiss"
>
</button>
</div>
)}
{/* AI Confidence Score */}
{job.ai?.confidence && (
<div className="mb-6 p-4 bg-blue-50 border border-blue-200 rounded-md">
@ -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}

View file

@ -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<TTSPreferences>({
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 && (
<div className="border border-gray-200 rounded-lg p-4 space-y-3">
<h3 className="text-sm font-semibold text-gray-700">Team Assignment
<span className="ml-2 text-xs font-normal text-gray-400">(applied to all languages when job enters QC)</span>
</h3>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Linguist</label>
<select
value={teamLinguistId}
onChange={e => setTeamLinguistId(e.target.value)}
className="w-full px-2 py-1.5 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
disabled={isUploading}
>
<option value=""> None </option>
{linguistUsers.map(u => (
<option key={u.id} value={u.id}>{u.full_name}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Reviewer</label>
<select
value={teamReviewerId}
onChange={e => setTeamReviewerId(e.target.value)}
className="w-full px-2 py-1.5 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
disabled={isUploading}
>
<option value=""> None </option>
{reviewerUsers.map(u => (
<option key={u.id} value={u.id}>{u.full_name}</option>
))}
</select>
</div>
</div>
</div>
)}
{/* Translation Mode - Only shown when target languages are selected */}
{languages.length > 0 && (
<div>
@ -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 ?? '');
}
}
}}

View file

@ -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 {