diff --git a/backend/app/api/v1/routes_share.py b/backend/app/api/v1/routes_share.py index 3df3c83..6266627 100644 --- a/backend/app/api/v1/routes_share.py +++ b/backend/app/api/v1/routes_share.py @@ -1,7 +1,8 @@ -"""Share-token endpoints — create/revoke/list tokens + public read-only view.""" +"""Share-token endpoints — create/revoke/list tokens + public read-only view + client decision.""" import secrets from datetime import datetime, timedelta +from typing import Literal from fastapi import APIRouter, Depends, HTTPException from motor.motor_asyncio import AsyncIOMotorDatabase @@ -51,6 +52,17 @@ class PublicJobPreviewResponse(BaseModel): language_outputs: dict[str, PublicJobPreviewLanguage] +class ClientDecisionRequest(BaseModel): + action: Literal["approve", "reject"] + notes: str | None = None + client_name: str | None = None + + +class ClientDecisionResponse(BaseModel): + status: str + new_job_status: str + + # ── Authenticated routes ────────────────────────────────────────────────────── @router.post("/jobs/{job_id}/share", response_model=ShareTokenResponse, status_code=201) @@ -209,3 +221,95 @@ async def get_public_job_preview( languages=list(outputs.keys()), language_outputs=language_outputs, ) + + +@router.post("/public/share/{token}/decision", response_model=ClientDecisionResponse) +async def client_decision( + token: str, + request: ClientDecisionRequest, + db: AsyncIOMotorDatabase = Depends(get_database), +): + """Submit client approval or rejection via a share link. No authentication required.""" + from ...services.validation import asset_validation_service + + token_doc = await db[_TOKENS].find_one({"_id": token, "is_active": True}) + if not token_doc: + raise HTTPException(status_code=404, detail="Share link not found or has been revoked") + if token_doc.get("expires_at") and token_doc["expires_at"] < datetime.utcnow(): + raise HTTPException(status_code=410, detail="Share link has expired") + + job_id = token_doc["job_id"] + job_doc = await db[_JOBS].find_one({"_id": job_id}) + if not job_doc: + raise HTTPException(status_code=404, detail="Job not found") + + if job_doc.get("status") != "pending_final_review": + raise HTTPException( + status_code=409, + detail="This job is not currently awaiting client review" + ) + + now = datetime.utcnow() + by_label = f"client:{request.client_name or 'anonymous'} (share/{token[:8]})" + + if request.action == "approve": + is_valid, validation_errors = await asset_validation_service.validate_job_assets(job_doc) + if not is_valid: + raise HTTPException( + status_code=400, + detail=f"Asset validation failed: {'; '.join(validation_errors)}" + ) + new_status = "completed" + update = { + "$set": { + "status": new_status, + "review.notes": request.notes or "", + "updated_at": now, + }, + "$push": { + "review.history": { + "at": now, + "status": new_status, + "by": by_label, + "notes": request.notes or "", + } + }, + } + else: + new_status = "qc_feedback" + update = { + "$set": { + "status": new_status, + "review.notes": request.notes or "", + "review.reviewer_id": by_label, + "updated_at": now, + }, + "$push": { + "review.history": { + "at": now, + "status": new_status, + "by": by_label, + "notes": request.notes or "", + } + }, + } + + result = await db[_JOBS].find_one_and_update( + {"_id": job_id, "status": "pending_final_review"}, + update, + return_document=True, + ) + if not result: + raise HTTPException( + status_code=409, + detail="Decision could not be submitted — the job status may have changed" + ) + + if request.action == "approve": + try: + from ...tasks.notify import notify_client_task + notify_client_task.delay(job_id) + except Exception: + pass + + return ClientDecisionResponse(status="ok", new_job_status=new_status) diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index d31f32f..f8123d2 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1026,6 +1026,15 @@ class ApiClient { return r.data; } + async submitShareDecision(token: string, data: { + action: 'approve' | 'reject'; + notes?: string; + client_name?: string; + }): Promise<{ status: string; new_job_status: string }> { + const r = await this.client.post(`/public/share/${token}/decision`, data); + return r.data; + } + // ── Production admin ──────────────────────────────────────────────────────── async getProductionQueueStats(): Promise<{ queues: Record; total_pending: number }> { diff --git a/frontend/src/routes/Help.tsx b/frontend/src/routes/Help.tsx index 7b85b16..74195ee 100644 --- a/frontend/src/routes/Help.tsx +++ b/frontend/src/routes/Help.tsx @@ -8,7 +8,6 @@ import { useAuthStore } from '../lib/auth'; import type { UserRole } from '../types/api'; import globalContent from '../help-content/global.md?raw'; -import clientContent from '../help-content/client.md?raw'; import linguistContent from '../help-content/linguist.md?raw'; import reviewerContent from '../help-content/reviewer.md?raw'; import productionContent from '../help-content/production.md?raw'; @@ -38,7 +37,6 @@ interface RoleGuide { const GUIDES: RoleGuide[] = [ { key: 'global', label: 'Overview', icon: '🌐', content: globalContent }, { key: 'faq', label: 'FAQ', icon: '❓', content: faqContent }, - { key: 'client', label: 'Client', icon: '🎬', content: clientContent, roles: ['client'] }, { key: 'linguist', label: 'Linguist', icon: '🌍', content: linguistContent, roles: ['linguist'] }, { key: 'reviewer', label: 'Reviewer', icon: '🔎', content: reviewerContent, roles: ['reviewer'] }, { key: 'production', label: 'Production', icon: '⚙️', content: productionContent, roles: ['production'] }, @@ -116,7 +114,6 @@ function extractSections(markdown: string): Section[] { function defaultRoleKey(role: UserRole | undefined): string { if (!role) return 'global'; const map: Partial> = { - client: 'client', linguist: 'linguist', reviewer: 'reviewer', production: 'production', diff --git a/frontend/src/routes/ShareView.tsx b/frontend/src/routes/ShareView.tsx index 88bfe89..601d2bc 100644 --- a/frontend/src/routes/ShareView.tsx +++ b/frontend/src/routes/ShareView.tsx @@ -2,6 +2,8 @@ import { useState, useEffect } from 'react'; import { useParams } from 'react-router-dom'; import { apiClient } from '../lib/api'; +type DecisionState = 'idle' | 'submitting' | 'approved' | 'rejected' | 'error'; + type LanguageOutput = { captions_vtt_url?: string; audio_description_vtt_url?: string; @@ -25,6 +27,12 @@ export function ShareView() { const [loading, setLoading] = useState(true); const [selectedLang, setSelectedLang] = useState(''); + // Review form state + const [clientName, setClientName] = useState(''); + const [reviewNotes, setReviewNotes] = useState(''); + const [decisionState, setDecisionState] = useState('idle'); + const [decisionError, setDecisionError] = useState(null); + useEffect(() => { if (!token) return; apiClient.getPublicJobPreview(token) @@ -41,6 +49,31 @@ export function ShareView() { .finally(() => setLoading(false)); }, [token]); + const handleDecision = async (action: 'approve' | 'reject') => { + if (!token) return; + if (action === 'reject' && !reviewNotes.trim()) { + setDecisionError('Please describe what needs to be changed before returning to QC.'); + return; + } + setDecisionState('submitting'); + setDecisionError(null); + try { + await apiClient.submitShareDecision(token, { + action, + notes: reviewNotes.trim() || undefined, + client_name: clientName.trim() || undefined, + }); + setDecisionState(action === 'approve' ? 'approved' : 'rejected'); + if (preview) { + setPreview({ ...preview, job_status: action === 'approve' ? 'completed' : 'qc_feedback' }); + } + } catch (e: unknown) { + const msg = (e as { response?: { data?: { detail?: string } } })?.response?.data?.detail; + setDecisionError(msg || 'Failed to submit your decision. Please try again.'); + setDecisionState('error'); + } + }; + if (loading) { return (
@@ -62,6 +95,7 @@ export function ShareView() { } const langOut = preview.language_outputs[selectedLang] ?? {}; + const isPendingReview = preview.job_status === 'pending_final_review'; return (
@@ -70,9 +104,11 @@ export function ShareView() {

{preview.job_title}

-

Read-only preview · Shared by Oliver

+

Preview · Shared by Oliver

- {preview.job_status.replace(/_/g, ' ')} + + {preview.job_status.replace(/_/g, ' ')} +
@@ -166,9 +202,86 @@ export function ShareView() {
-

- This is a read-only preview link. To request revisions, contact your Oliver project manager. -

+ {/* Client review section */} + {isPendingReview && decisionState === 'idle' || decisionState === 'error' ? ( +
+
+

Your Review

+

+ Please review the materials above, then approve or return for corrections. +

+
+ +
+ + setClientName(e.target.value)} + placeholder="e.g. Jane Smith" + className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500" + /> +
+ +
+ +