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:
parent
f91cb16005
commit
d70b5acaf9
6 changed files with 232 additions and 11 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 }> {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue