feat(workflow): PR-2 workflow blockers — PM/Production dashboards, two-stage QC, role routing

Changes:
- Dashboard: add project_manager case (final review / QC counts / new job widgets)
  and production case (AI pipeline / failures widgets)
- Sidebar: add project_manager to Final Review and Audit Log nav items;
  live badge counts for QC Queue (pending_qc) and Final Review (pending_final_review)
- App.tsx: add project_manager to Final Review and Audit Log RoleGates (W-10, PM-18)
- Login: role-based redirect after login — linguist/reviewer → /qc/queue, others → /
- language_qc._assert_can_approve: enforce two-stage QC; remove linguist self-approve
  fallback; require reviewer assignment + submitted_for_review_at (W-6)
- routes_jobs.complete_job: allow project_manager to complete jobs (W-9)
- notify.py: re-enable email notifications (W-7)
- Fix 400 on cue save: treat empty-string audio_description_vtt/captions_vtt as absent
  both in backend (truthy check) and frontend (|| undefined) — root cause was adVtt
  initialising to '' when job has no AD track

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-04-29 18:18:24 +01:00
parent a168af1aa7
commit c7a6f13b10
8 changed files with 164 additions and 29 deletions

View file

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

View file

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

View file

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

View file

@ -112,14 +112,14 @@ function AppContent() {
} />
<Route path="/admin/final" element={
<AuthenticatedRoute>
<RoleGate allowedRoles={['reviewer', 'linguist', 'production', 'admin']}>
<RoleGate allowedRoles={['reviewer', 'linguist', 'production', 'admin', 'project_manager']}>
<FinalList />
</RoleGate>
</AuthenticatedRoute>
} />
<Route path="/admin/final/:id" element={
<AuthenticatedRoute>
<RoleGate allowedRoles={['reviewer', 'linguist', 'production', 'admin']}>
<RoleGate allowedRoles={['reviewer', 'linguist', 'production', 'admin', 'project_manager']}>
<FinalDetail />
</RoleGate>
</AuthenticatedRoute>
@ -175,7 +175,7 @@ function AppContent() {
} />
<Route path="/admin/audit-log" element={
<AuthenticatedRoute>
<RoleGate allowedRoles={['production', 'admin']}>
<RoleGate allowedRoles={['production', 'admin', 'project_manager']}>
<AuditLog />
</RoleGate>
</AuthenticatedRoute>

View file

@ -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'],
},
];

View file

@ -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() {
</div>
);
case 'project_manager':
return (
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<Link
to="/admin/final"
className="group bg-gradient-to-br from-indigo-500 to-purple-600 rounded-2xl p-6 text-white hover:shadow-xl transition-all duration-200 transform hover:-translate-y-0.5"
>
<div className="flex items-center mb-3">
<div className="w-10 h-10 bg-white/20 rounded-lg flex items-center justify-center mr-3">
<span className="text-xl"></span>
</div>
<h3 className="text-lg font-bold">Final Review</h3>
</div>
<p className="text-3xl font-bold mb-1">{stats.finalReview}</p>
<p className="text-indigo-100 text-sm mb-4">awaiting your approval</p>
<p className="text-sm font-semibold text-white/80 group-hover:text-white">Review now </p>
</Link>
<Link
to="/admin/qc"
className="group bg-gradient-to-br from-amber-400 to-orange-500 rounded-2xl p-6 text-white hover:shadow-xl transition-all duration-200 transform hover:-translate-y-0.5"
>
<div className="flex items-center mb-3">
<div className="w-10 h-10 bg-white/20 rounded-lg flex items-center justify-center mr-3">
<span className="text-xl">🔍</span>
</div>
<h3 className="text-lg font-bold">In QC Review</h3>
</div>
<p className="text-3xl font-bold mb-1">{stats.inQC}</p>
<p className="text-amber-100 text-sm mb-4">languages being reviewed</p>
<p className="text-sm font-semibold text-white/80 group-hover:text-white">View QC queue </p>
</Link>
<Link
to="/jobs/new"
className="group bg-gradient-to-br from-green-400 to-emerald-600 rounded-2xl p-6 text-white hover:shadow-xl transition-all duration-200 transform hover:-translate-y-0.5"
>
<div className="flex items-center mb-3">
<div className="w-10 h-10 bg-white/20 rounded-lg flex items-center justify-center mr-3">
<span className="text-xl">📤</span>
</div>
<h3 className="text-lg font-bold">New Job</h3>
</div>
<p className="text-green-100 text-sm mb-4">
Upload a new video and configure accessibility outputs
</p>
<p className="text-sm font-semibold text-white/80 group-hover:text-white">Upload now </p>
</Link>
</div>
);
case 'production':
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<div className="bg-gradient-to-br from-blue-500 via-blue-600 to-indigo-700 rounded-2xl p-8 text-white">
<div className="flex items-center mb-4">
<div className="w-12 h-12 bg-white/20 rounded-lg flex items-center justify-center mr-4">
<span className="text-2xl"></span>
</div>
<h2 className="text-2xl font-bold">AI Pipeline</h2>
</div>
<p className="text-blue-100 mb-2 text-lg font-semibold">
{stats.aiProcessing} jobs processing
</p>
<p className="text-blue-100 mb-6 leading-relaxed">
Monitor ingestion, transcription, translation, and rendering across all active jobs.
</p>
<Link
to="/jobs?status=ingesting,ai_processing,translating,tts_generating,rendering_video"
className="inline-flex items-center bg-white text-blue-600 px-6 py-3 rounded-lg hover:bg-blue-50 transition-all duration-200 font-semibold shadow-lg hover:shadow-xl transform hover:-translate-y-0.5"
>
View pipeline
</Link>
</div>
<div className={`bg-gradient-to-br ${stats.failed > 0 ? 'from-red-500 to-red-700' : 'from-gray-400 to-gray-500'} rounded-2xl p-8 text-white`}>
<div className="flex items-center mb-4">
<div className="w-12 h-12 bg-white/20 rounded-lg flex items-center justify-center mr-4">
<span className="text-2xl">{stats.failed > 0 ? '🚨' : '✓'}</span>
</div>
<h2 className="text-2xl font-bold">Failures</h2>
</div>
<p className="text-white/80 mb-2 text-lg font-semibold">
{stats.failed} jobs need attention
</p>
<p className="text-white/70 mb-6 leading-relaxed">
{stats.failed > 0
? 'Jobs failed during processing. Click to retry or investigate.'
: 'No failures. All systems running normally.'}
</p>
{stats.failed > 0 && (
<Link
to="/jobs?status=tts_failed,render_failed"
className="inline-flex items-center bg-white text-red-600 px-6 py-3 rounded-lg hover:bg-red-50 transition-all duration-200 font-semibold shadow-lg hover:shadow-xl transform hover:-translate-y-0.5"
>
Investigate
</Link>
)}
</div>
</div>
);
case 'reviewer':
case 'linguist':
case 'admin':

View file

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

View file

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