fix(briefs): fix Project/Assign-To dropdowns and expand Requested Outputs

Projects:
- PM now sees all active projects (same as admin/production) — was filtering
  to empty when pm_client_ids and org memberships were both unset

Assign To:
- Replaced useOrganizations()+useOrgMembers() with a new GET /admin/brief-assignees
  endpoint accessible to all authenticated users — returns active admin/PM/production
  users sorted by name; shows role next to name in dropdown

Requested Outputs:
- Added SDH Captions (VTT), Descriptive Transcript, Accessible Video (MP4)
- Accessible Video shows Pause Insert / Voice Overlay radio selector
- Added descriptive_transcript field to RequestedOutputs model (backend + frontend)

Access:
- Brief routes now open to 'client' role in addition to admin/PM/production

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-05-01 17:54:21 +01:00
parent a14444d61c
commit 68ac65ac05
9 changed files with 122 additions and 22 deletions

11
.gitignore vendored
View file

@ -99,3 +99,14 @@ docs/*.pdf
/var/www/html/video-accessibility.backup.*
backend/.env
# Node / npm artifacts at repo root (Playwright MCP installs these)
node_modules/
package.json
package-lock.json
# Playwright MCP session snapshots
.playwright-mcp/
# Test videos
test-video.mp4

View file

@ -6,7 +6,7 @@ from motor.motor_asyncio import AsyncIOMotorDatabase
from ...core.authz import MembershipContext, get_membership_context
from ...core.database import get_database
from ...core.dependencies import require_roles
from ...core.dependencies import get_current_user, require_roles
from ...core.logging import get_logger
from ...core.security import get_password_hash
from ...models.audit_log import AuditAction, AuditLogQuery, AuditLogResponse
@ -96,6 +96,32 @@ async def list_users(
)
@router.get("/brief-assignees", response_model=list[UserResponse])
async def list_brief_assignees(
current_user: User = Depends(get_current_user),
db: AsyncIOMotorDatabase = Depends(get_database),
):
"""Return users who can be assigned a brief (PM, production, admin). Accessible to all brief-creating roles."""
docs = await db.users.find(
{
"role": {"$in": [UserRole.ADMIN.value, UserRole.PROJECT_MANAGER.value, UserRole.PRODUCTION.value]},
"is_active": True,
},
{"hashed_password": 0},
).sort("full_name", 1).to_list(None)
return [UserResponse(
id=str(d["_id"]),
email=d["email"],
full_name=d["full_name"],
role=d["role"],
auth_provider=d.get("auth_provider", "local"),
is_active=d["is_active"],
created_at=d.get("created_at", datetime.utcnow()).isoformat() if d.get("created_at") else None,
pm_client_ids=d.get("pm_client_ids", []),
languages=d.get("languages", []),
) for d in docs]
@router.get("/users/{user_id}", response_model=UserResponse)
async def get_user(
user_id: str,

View file

@ -379,7 +379,7 @@ async def list_all_projects(
db: AsyncIOMotorDatabase = Depends(get_database),
):
"""Return all active projects accessible to the current user (across all clients)."""
if current_user.role in (UserRole.ADMIN, UserRole.PRODUCTION):
if current_user.role in (UserRole.ADMIN, UserRole.PRODUCTION, UserRole.PROJECT_MANAGER):
docs = await db.projects.find({"is_active": True}).to_list(None)
else:
accessible_client_ids = await _get_accessible_client_ids(current_user, db)

View file

@ -76,6 +76,7 @@ class RequestedOutputs(BaseModel):
accessible_video_mp4: bool = False # Rendered video with embedded audio descriptions
accessible_video_method: Literal["overlay", "pause_insert"] | None = None # User-selected method
sdh_vtt: bool = False # SDH (Subtitles for Deaf and Hard of Hearing) captions with speaker labels, sound effects, music notation
descriptive_transcript: bool = False # WCAG-compliant combined speech+description transcript text file
languages: list[str] = []
transcreation: list[str] = []
tts_preferences: TTSPreferences | None = None

View file

@ -198,21 +198,21 @@ function AppContent() {
} />
<Route path="/briefs" element={
<AuthenticatedRoute>
<RoleGate allowedRoles={['project_manager', 'admin', 'production']}>
<RoleGate allowedRoles={['client', 'project_manager', 'admin', 'production']}>
<BriefsList />
</RoleGate>
</AuthenticatedRoute>
} />
<Route path="/briefs/new" element={
<AuthenticatedRoute>
<RoleGate allowedRoles={['project_manager', 'admin', 'production']}>
<RoleGate allowedRoles={['client', 'project_manager', 'admin', 'production']}>
<NewBrief />
</RoleGate>
</AuthenticatedRoute>
} />
<Route path="/briefs/:id" element={
<AuthenticatedRoute>
<RoleGate allowedRoles={['project_manager', 'admin', 'production']}>
<RoleGate allowedRoles={['client', 'project_manager', 'admin', 'production']}>
<BriefDetail />
</RoleGate>
</AuthenticatedRoute>

View file

@ -302,6 +302,13 @@ export function useMyMemberships() {
});
}
export function useBriefAssignees() {
return useQuery({
queryKey: ['brief-assignees'],
queryFn: () => apiClient.listBriefAssignees(),
});
}
// ── Invitations ───────────────────────────────────────────────────────────────
export function useInvitations(orgId: string) {

View file

@ -390,6 +390,11 @@ class ApiClient {
return response.data;
}
async listBriefAssignees(): Promise<import('../types/api').User[]> {
const response = await this.client.get('/admin/brief-assignees');
return response.data;
}
async createBrief(data: import('../types/api').JobBriefCreate): Promise<import('../types/api').JobBriefResponse> {
const response = await this.client.post('/briefs', data);
return response.data;

View file

@ -2,7 +2,7 @@ import { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { useCreateBrief } from '../../hooks/useJob';
import { useToastContext } from '../../contexts/ToastContext';
import { useAllProjects, useOrganizations, useOrgMembers } from '../../hooks/useClients';
import { useAllProjects, useBriefAssignees } from '../../hooks/useClients';
const ALL_LANGUAGES: { code: string; label: string }[] = [
{ code: 'en', label: 'EN' },
@ -68,14 +68,18 @@ export function NewBrief() {
const [deadline, setDeadline] = useState('');
const [projectId, setProjectId] = useState('');
const [assigneeId, setAssigneeId] = useState('');
// Requested outputs
const [captionsVtt, setCaptionsVtt] = useState(true);
const [adVtt, setAdVtt] = useState(true);
const [adMp3, setAdMp3] = useState(true);
const [accessibleMp4, setAccessibleMp4] = useState(false);
const [accessibleMethod, setAccessibleMethod] = useState<'overlay' | 'pause_insert'>('pause_insert');
const [sdhVtt, setSdhVtt] = useState(false);
const [descriptiveTranscript, setDescriptiveTranscript] = useState(false);
const { data: projects = [] } = useAllProjects();
const { data: organizations = [] } = useOrganizations();
const firstOrgId = organizations[0]?.id ?? '';
const { data: members = [] } = useOrgMembers(firstOrgId);
const { data: assignees = [] } = useBriefAssignees();
const toggleLang = (lang: string) => {
setLanguages(prev =>
@ -97,8 +101,10 @@ export function NewBrief() {
captions_vtt: captionsVtt,
audio_description_vtt: adVtt,
audio_description_mp3: adMp3,
accessible_video_mp4: false,
sdh_vtt: false,
accessible_video_mp4: accessibleMp4,
accessible_video_method: accessibleMp4 ? accessibleMethod : undefined,
sdh_vtt: sdhVtt,
descriptive_transcript: descriptiveTranscript,
languages,
transcreation: [],
translation_mode: 'video_native',
@ -167,9 +173,9 @@ export function NewBrief() {
className="w-full border border-gray-300 rounded px-3 py-2 text-sm"
>
<option value=""> Unassigned </option>
{members.map(m => (
<option key={m.user_id} value={m.user_id}>
{m.full_name || m.email}
{assignees.map(u => (
<option key={u.id} value={u.id}>
{u.full_name || u.email} ({u.role.replace('_', ' ')})
</option>
))}
</select>
@ -177,17 +183,60 @@ export function NewBrief() {
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Requested Outputs</label>
<div className="space-y-2">
<div className="space-y-3">
{([
[captionsVtt, setCaptionsVtt, 'Captions (VTT)'],
[adVtt, setAdVtt, 'Audio Descriptions (VTT)'],
[adMp3, setAdMp3, 'Audio Descriptions (MP3)'],
] as [boolean, (v: boolean) => void, string][]).map(([val, setter, label]) => (
<label key={label} className="flex items-center gap-2 text-sm text-gray-700">
<input type="checkbox" checked={val} onChange={e => setter(e.target.checked)} className="rounded" />
{label}
[captionsVtt, setCaptionsVtt, 'Captions (VTT)', 'Closed captions in WebVTT format'],
[adVtt, setAdVtt, 'Audio Descriptions (VTT)', 'Audio description cues in WebVTT format'],
[adMp3, setAdMp3, 'Audio Descriptions (MP3)', 'Synthesised audio description track'],
[sdhVtt, setSdhVtt, 'SDH Captions (VTT)', 'Subtitles for Deaf and Hard of Hearing — includes speaker labels and sound effects'],
[descriptiveTranscript, setDescriptiveTranscript, 'Descriptive Transcript', 'Full text transcript with integrated audio descriptions'],
] as [boolean, (v: boolean) => void, string, string][]).map(([val, setter, label, hint]) => (
<label key={label} className="flex items-start gap-2 text-sm text-gray-700 cursor-pointer">
<input
type="checkbox"
checked={val}
onChange={e => setter(e.target.checked)}
className="rounded mt-0.5 flex-shrink-0"
/>
<span>
<span className="font-medium">{label}</span>
<span className="text-gray-400 ml-1"> {hint}</span>
</span>
</label>
))}
{/* Accessible Video — with method selector */}
<div className="space-y-2">
<label className="flex items-start gap-2 text-sm text-gray-700 cursor-pointer">
<input
type="checkbox"
checked={accessibleMp4}
onChange={e => setAccessibleMp4(e.target.checked)}
className="rounded mt-0.5 flex-shrink-0"
/>
<span>
<span className="font-medium">Accessible Video (MP4)</span>
<span className="text-gray-400 ml-1"> Video with embedded audio descriptions</span>
</span>
</label>
{accessibleMp4 && (
<div className="ml-6 flex gap-4">
{(['pause_insert', 'overlay'] as const).map(method => (
<label key={method} className="flex items-center gap-1.5 text-sm cursor-pointer">
<input
type="radio"
name="accessible_method"
value={method}
checked={accessibleMethod === method}
onChange={() => setAccessibleMethod(method)}
className="text-indigo-600"
/>
<span>{method === 'pause_insert' ? 'Pause Insert' : 'Voice Overlay'}</span>
</label>
))}
</div>
)}
</div>
</div>
</div>

View file

@ -70,6 +70,7 @@ export interface RequestedOutputs {
accessible_video_mp4: boolean; // Rendered video with embedded audio descriptions
accessible_video_method?: AccessibleVideoMethod; // User-selected method for accessible video
sdh_vtt?: boolean; // SDH captions with speaker labels, sound effects, music notation
descriptive_transcript?: boolean; // WCAG-compliant combined speech+description transcript
languages: string[];
transcreation: string[];
tts_preferences?: TTSPreferences;