feat(share): client review form on share link; hide client role from UI

- POST /public/share/{token}/decision — unauthenticated approve/reject via share token
  - approve: validates assets, sets status completed, triggers notification
  - reject: sets status qc_feedback, stores client name + notes in review history
- ShareView: review form (name, comments, Approve / Return for Corrections)
  - shows only when job is pending_final_review
  - confirmation screen after decision
- api.ts: submitShareDecision()
- Hide 'client' role from UserList/UserDetail dropdowns
- Hide 'Client' guide tab from Help

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-05-06 09:51:58 +01:00
parent f91cb16005
commit d70b5acaf9
6 changed files with 232 additions and 11 deletions

View file

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

View file

@ -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<string, number>; total_pending: number }> {

View file

@ -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<Record<UserRole, string>> = {
client: 'client',
linguist: 'linguist',
reviewer: 'reviewer',
production: 'production',

View file

@ -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<DecisionState>('idle');
const [decisionError, setDecisionError] = useState<string | null>(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 (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
@ -62,6 +95,7 @@ export function ShareView() {
}
const langOut = preview.language_outputs[selectedLang] ?? {};
const isPendingReview = preview.job_status === 'pending_final_review';
return (
<div className="min-h-screen bg-gray-50">
@ -70,9 +104,11 @@ export function ShareView() {
<div className="max-w-5xl mx-auto flex items-center justify-between">
<div>
<h1 className="text-xl font-semibold text-gray-900">{preview.job_title}</h1>
<p className="text-sm text-gray-500 mt-0.5">Read-only preview · Shared by Oliver</p>
<p className="text-sm text-gray-500 mt-0.5">Preview · Shared by Oliver</p>
</div>
<span className="text-xs px-2 py-1 bg-gray-100 text-gray-600 rounded-full">{preview.job_status.replace(/_/g, ' ')}</span>
<span className="text-xs px-2 py-1 bg-gray-100 text-gray-600 rounded-full">
{preview.job_status.replace(/_/g, ' ')}
</span>
</div>
</div>
@ -166,9 +202,86 @@ export function ShareView() {
</div>
</div>
<p className="text-xs text-gray-400 text-center">
This is a read-only preview link. To request revisions, contact your Oliver project manager.
</p>
{/* Client review section */}
{isPendingReview && decisionState === 'idle' || decisionState === 'error' ? (
<div className="bg-white border border-gray-200 rounded-xl p-6 space-y-5">
<div>
<h2 className="text-base font-semibold text-gray-900">Your Review</h2>
<p className="text-sm text-gray-500 mt-1">
Please review the materials above, then approve or return for corrections.
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Your name <span className="text-gray-400 font-normal">(optional)</span>
</label>
<input
type="text"
value={clientName}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Comments <span className="text-gray-400 font-normal">(required if returning for corrections)</span>
</label>
<textarea
rows={4}
value={reviewNotes}
onChange={(e) => setReviewNotes(e.target.value)}
placeholder="Leave any feedback or describe what needs to be corrected…"
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 resize-none"
/>
</div>
{decisionError && (
<p className="text-sm text-red-600 bg-red-50 border border-red-200 rounded-lg px-3 py-2">
{decisionError}
</p>
)}
<div className="flex gap-3 pt-1">
<button
onClick={() => handleDecision('approve')}
disabled={decisionState === 'submitting'}
className="flex-1 px-4 py-2.5 bg-green-600 hover:bg-green-700 disabled:opacity-50 text-white text-sm font-medium rounded-lg transition-colors"
>
{decisionState === 'submitting' ? 'Submitting…' : '✅ Approve'}
</button>
<button
onClick={() => handleDecision('reject')}
disabled={decisionState === 'submitting'}
className="flex-1 px-4 py-2.5 bg-white hover:bg-red-50 disabled:opacity-50 text-red-600 border border-red-300 hover:border-red-400 text-sm font-medium rounded-lg transition-colors"
>
{decisionState === 'submitting' ? 'Submitting…' : '↩ Return for Corrections'}
</button>
</div>
</div>
) : decisionState === 'approved' ? (
<div className="bg-green-50 border border-green-200 rounded-xl p-6 text-center">
<div className="text-4xl mb-3"></div>
<h2 className="text-lg font-semibold text-green-800">Approved thank you!</h2>
<p className="text-sm text-green-700 mt-1">
Your approval has been recorded. The Oliver team will be notified.
</p>
</div>
) : decisionState === 'rejected' ? (
<div className="bg-orange-50 border border-orange-200 rounded-xl p-6 text-center">
<div className="text-4xl mb-3"></div>
<h2 className="text-lg font-semibold text-orange-800">Returned for corrections</h2>
<p className="text-sm text-orange-700 mt-1">
Your feedback has been submitted. The team will review your notes and get back to you.
</p>
</div>
) : !isPendingReview ? (
<p className="text-xs text-gray-400 text-center">
This is a read-only preview link. To request revisions, contact your Oliver project manager.
</p>
) : null}
</div>
</div>
);

View file

@ -170,7 +170,6 @@ export function UserDetail() {
onChange={(e) => setFormData({ ...formData, role: e.target.value as UserRole })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="client">Client</option>
<option value="reviewer">Reviewer</option>
<option value="linguist">Linguist</option>
<option value="production">Production</option>

View file

@ -467,7 +467,6 @@ function CreateUserModal({ onClose, onSuccess }: { onClose: () => void; onSucces
onChange={(e) => setFormData({ ...formData, role: e.target.value as UserRole })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="client">Client</option>
<option value="reviewer">Reviewer</option>
<option value="linguist">Linguist</option>
<option value="production">Production</option>