diff --git a/backend/app/api/v1/routes_jobs.py b/backend/app/api/v1/routes_jobs.py index 66c1fb4..79566d8 100644 --- a/backend/app/api/v1/routes_jobs.py +++ b/backend/app/api/v1/routes_jobs.py @@ -859,7 +859,7 @@ async def complete_job( job_id: str, request: CompleteJobRequest, http_request: Request = None, - current_user: User = Depends(require_roles(UserRole.REVIEWER, UserRole.LINGUIST, UserRole.PRODUCTION, UserRole.ADMIN)), + current_user: User = Depends(require_roles(UserRole.REVIEWER, UserRole.LINGUIST, UserRole.PRODUCTION, UserRole.ADMIN, UserRole.PROJECT_MANAGER)), db: AsyncIOMotorDatabase = Depends(get_database), ): # Get job for validation @@ -1300,7 +1300,7 @@ async def update_job_vtt_content( lang_output = outputs.get(target_language, {}) # Validate and update captions VTT - if request.captions_vtt is not None: + if request.captions_vtt: # treat empty string same as None — nothing to update # Validate VTT format is_valid, errors = VTTEditor.validate_vtt(request.captions_vtt) if not is_valid: @@ -1322,7 +1322,7 @@ async def update_job_vtt_content( lang_output["captions_vtt_gcs"] = new_captions_uri # Validate and update audio description VTT - if request.audio_description_vtt is not None: + if request.audio_description_vtt: # treat empty string same as None — nothing to update # Validate VTT format is_valid, errors = VTTEditor.validate_vtt(request.audio_description_vtt) if not is_valid: diff --git a/backend/app/services/language_qc.py b/backend/app/services/language_qc.py index 098c742..9514a84 100644 --- a/backend/app/services/language_qc.py +++ b/backend/app/services/language_qc.py @@ -963,23 +963,35 @@ async def reset_all_for_return_to_qc(db: AsyncIOMotorDatabase, job_id: str) -> N # ── Internal ────────────────────────────────────────────────────────────────── def _assert_can_approve(job_doc: dict, lang: str, actor: User) -> None: - """Raise 403 if actor is not the assigned reviewer (or PROD/ADMIN).""" + """Raise 403 if actor cannot approve this language. + + Two-stage QC is enforced: linguist must submit before reviewer can approve. + PRODUCTION and ADMIN may override (explicit admin action, logged separately). + """ if actor.role in (UserRole.PRODUCTION, UserRole.ADMIN): return state = (job_doc.get("language_qc") or {}).get(lang, {}) - assigned_reviewer = state.get("assigned_reviewer_id") if isinstance(state, dict) else None + if not isinstance(state, dict): + state = {} + assigned_reviewer = state.get("assigned_reviewer_id") if assigned_reviewer is None: - # Fallback: allow assigned linguist to approve if no reviewer assigned (backward compat) - assigned_linguist = state.get("assigned_linguist_id") if isinstance(state, dict) else None - if assigned_linguist == str(actor.id): - return - raise HTTPException(status_code=403, detail=f"Language '{lang}' has no assigned reviewer") + raise HTTPException( + status_code=403, + detail=f"Language '{lang}' has no assigned reviewer — a reviewer must be assigned before approving", + ) if assigned_reviewer != str(actor.id): raise HTTPException(status_code=403, detail=f"You are not the assigned reviewer for language '{lang}'") + submitted_at = state.get("submitted_for_review_at") + if not submitted_at: + raise HTTPException( + status_code=403, + detail=f"Language '{lang}' has not been submitted for review by the linguist yet", + ) + # Keep old name for any remaining callers _assert_can_act = _assert_can_approve diff --git a/backend/app/tasks/notify.py b/backend/app/tasks/notify.py index f7ece5a..f32403b 100644 --- a/backend/app/tasks/notify.py +++ b/backend/app/tasks/notify.py @@ -106,9 +106,7 @@ class NotifyClientTask(Task): if lang_downloads: download_links[language] = lang_downloads - # Send completion email (temporarily disabled) - # TODO: Re-enable emails once authentication is configured - email_enabled = False # Set to True to re-enable emails + email_enabled = True if email_enabled: try: diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d444ced..206ba63 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -112,14 +112,14 @@ function AppContent() { } /> - + } /> - + @@ -175,7 +175,7 @@ function AppContent() { } /> - + diff --git a/frontend/src/components/Layout/Sidebar.tsx b/frontend/src/components/Layout/Sidebar.tsx index 29725be..879c3c9 100644 --- a/frontend/src/components/Layout/Sidebar.tsx +++ b/frontend/src/components/Layout/Sidebar.tsx @@ -1,6 +1,7 @@ import { Link, useLocation, useParams } from 'react-router-dom'; import { useAuthStore } from '../../lib/auth'; import { useMyMemberships } from '../../hooks/useClients'; +import { useJobs } from '../../hooks/useJob'; interface SidebarItem { label: string; @@ -20,6 +21,21 @@ export function Sidebar({ onMobileClose }: SidebarProps) { const params = useParams<{ orgSlug?: string }>(); const { data: memberships = [] } = useMyMemberships(); + const isQCRole = ['linguist', 'reviewer', 'production', 'admin'].includes(user?.role || ''); + const isPMOrAdmin = ['project_manager', 'admin'].includes(user?.role || ''); + + const { data: qcData } = useJobs( + { status: 'pending_qc', size: 1 }, + { enabled: isQCRole } + ); + const { data: finalData } = useJobs( + { status: 'pending_final_review', size: 1 }, + { enabled: isPMOrAdmin } + ); + + const qcBadge = isQCRole ? (qcData?.total || 0) : 0; + const finalBadge = isPMOrAdmin ? (finalData?.total || 0) : 0; + // Determine current org from route params or first membership const currentOrgSlug = params.orgSlug || @@ -40,13 +56,14 @@ export function Sidebar({ onMobileClose }: SidebarProps) { label: 'Upload Video', href: '/jobs/new', icon: '📤', - roles: ['client', 'production', 'admin', 'member', 'manager'], + roles: ['client', 'production', 'admin', 'project_manager'], }, { label: 'My QC Queue', href: '/qc/queue', icon: '📝', roles: ['linguist', 'reviewer', 'production', 'admin'], + badge: qcBadge || undefined, }, { label: 'QC Review', @@ -58,7 +75,8 @@ export function Sidebar({ onMobileClose }: SidebarProps) { label: 'Final Review', href: '/admin/final', icon: '✅', - roles: ['reviewer', 'linguist', 'production', 'admin'], + roles: ['reviewer', 'linguist', 'production', 'admin', 'project_manager'], + badge: finalBadge || undefined, }, { label: 'User Management', @@ -76,7 +94,7 @@ export function Sidebar({ onMobileClose }: SidebarProps) { label: 'Audit Log', href: '/admin/audit-log', icon: '📋', - roles: ['production', 'admin'], + roles: ['production', 'admin', 'project_manager'], }, ]; diff --git a/frontend/src/routes/Dashboard.tsx b/frontend/src/routes/Dashboard.tsx index 8db708d..789fbd4 100644 --- a/frontend/src/routes/Dashboard.tsx +++ b/frontend/src/routes/Dashboard.tsx @@ -14,12 +14,14 @@ export function Dashboard() { const jobs = jobsData?.jobs || []; - // Stats for display const stats = { total: jobs.length, pending: jobs.filter((j: Job) => ['created', 'ingesting', 'ai_processing'].includes(j.status)).length, inQC: jobs.filter((j: Job) => ['pending_qc', 'qc_feedback'].includes(j.status)).length, completed: jobs.filter((j: Job) => j.status === 'completed').length, + finalReview: jobs.filter((j: Job) => j.status === 'pending_final_review').length, + aiProcessing: jobs.filter((j: Job) => ['ingesting', 'ai_processing', 'translating', 'tts_generating', 'rendering_video'].includes(j.status)).length, + failed: jobs.filter((j: Job) => ['tts_failed', 'render_failed'].includes(j.status)).length, }; const renderRoleSpecificContent = () => { @@ -83,6 +85,108 @@ export function Dashboard() { ); + case 'project_manager': + return ( +
+ +
+
+ +
+

Final Review

+
+

{stats.finalReview}

+

awaiting your approval

+

Review now →

+ + + +
+
+ 🔍 +
+

In QC Review

+
+

{stats.inQC}

+

languages being reviewed

+

View QC queue →

+ + + +
+
+ 📤 +
+

New Job

+
+

+ Upload a new video and configure accessibility outputs +

+

Upload now →

+ +
+ ); + + case 'production': + return ( +
+
+
+
+ ⚙️ +
+

AI Pipeline

+
+

+ {stats.aiProcessing} jobs processing +

+

+ Monitor ingestion, transcription, translation, and rendering across all active jobs. +

+ + View pipeline → + +
+ +
0 ? 'from-red-500 to-red-700' : 'from-gray-400 to-gray-500'} rounded-2xl p-8 text-white`}> +
+
+ {stats.failed > 0 ? '🚨' : '✓'} +
+

Failures

+
+

+ {stats.failed} jobs need attention +

+

+ {stats.failed > 0 + ? 'Jobs failed during processing. Click to retry or investigate.' + : 'No failures. All systems running normally.'} +

+ {stats.failed > 0 && ( + + Investigate → + + )} +
+
+ ); + case 'reviewer': case 'linguist': case 'admin': diff --git a/frontend/src/routes/Login.tsx b/frontend/src/routes/Login.tsx index 826d533..38282fc 100644 --- a/frontend/src/routes/Login.tsx +++ b/frontend/src/routes/Login.tsx @@ -6,6 +6,11 @@ import { loginRequest } from '../lib/msalConfig'; import { apiClient } from '../lib/api'; import type { UserRole } from '../types/api'; +function getRoleRedirect(role: string): string { + if (role === 'linguist' || role === 'reviewer') return '/qc/queue'; + return '/'; +} + export function Login() { const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); @@ -22,9 +27,8 @@ export function Login() { try { await login(email, password); - console.log('Login successful'); - // Navigate to dashboard after successful login - navigate('/'); + const { user: loggedInUser } = useAuthStore.getState(); + navigate(getRoleRedirect(loggedInUser?.role || '')); } catch (err: unknown) { console.error('Login failed:', err); const error = err as { code?: string; response?: { data?: { message?: string } }; message?: string }; @@ -61,8 +65,7 @@ export function Login() { created_at: new Date().toISOString(), }); - console.log('Microsoft login successful'); - navigate('/'); + navigate(getRoleRedirect(loginResponse.role)); } catch (err: unknown) { console.error('Microsoft login failed:', err); const error = err as { code?: string; response?: { data?: { detail?: string; message?: string } }; message?: string }; diff --git a/frontend/src/routes/admin/QCDetail.tsx b/frontend/src/routes/admin/QCDetail.tsx index b257614..e2f20cb 100644 --- a/frontend/src/routes/admin/QCDetail.tsx +++ b/frontend/src/routes/admin/QCDetail.tsx @@ -386,14 +386,14 @@ export function QCDetail() { if (type === 'captions') { await updateVttMutation.mutateAsync({ id, - data: { captions_vtt: content, audio_description_vtt: adVtt, language: selectedLanguage }, + data: { captions_vtt: content, audio_description_vtt: adVtt || undefined, language: selectedLanguage }, }); setCaptionsVtt(content); toast.toastOnly.success('Captions VTT replaced from file'); } else { await updateVttMutation.mutateAsync({ id, - data: { captions_vtt: captionsVtt, audio_description_vtt: content, language: selectedLanguage }, + data: { captions_vtt: captionsVtt || undefined, audio_description_vtt: content, language: selectedLanguage }, }); setAdVtt(content); setAdVttUploaded(true); @@ -414,7 +414,7 @@ export function QCDetail() { id, data: { captions_vtt: vttContent, - audio_description_vtt: adVtt, + audio_description_vtt: adVtt || undefined, language: selectedLanguage } }); @@ -430,7 +430,7 @@ export function QCDetail() { await updateVttMutation.mutateAsync({ id, data: { - captions_vtt: captionsVtt, + captions_vtt: captionsVtt || undefined, audio_description_vtt: vttContent, language: selectedLanguage }