Merge fix/multi-tenancy-and-english-first into main

This commit is contained in:
Vadym Samoilenko 2026-05-01 12:07:37 +01:00
commit c3a42cb5fe
30 changed files with 2360 additions and 388 deletions

View file

@ -4,6 +4,7 @@ from bson import ObjectId
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
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.logging import get_logger
@ -34,11 +35,13 @@ async def list_users(
size: int = Query(20, ge=1, le=500),
role: str | None = Query(None),
active_only: bool = Query(True),
org_id: str | None = Query(None, description="Filter by org (platform admin only)"),
current_user: User = Depends(require_roles(UserRole.ADMIN)),
ctx: MembershipContext = Depends(get_membership_context),
db: AsyncIOMotorDatabase = Depends(get_database),
):
"""List users with filtering and pagination (admin only)"""
query = {}
query: dict = {}
if role:
query["role"] = role
@ -46,6 +49,23 @@ async def list_users(
if active_only:
query["is_active"] = True
if not ctx.is_platform_admin:
# Org-scoped admin: show only users in their org(s) via membership collection
accessible_org_ids = ctx.accessible_org_ids()
if not accessible_org_ids:
return UserListResponse(users=[], total=0, page=page, size=size)
member_ids_cursor = db.memberships.find(
{"organization_id": {"$in": accessible_org_ids}},
{"user_id": 1},
)
member_ids = [doc["user_id"] async for doc in member_ids_cursor]
query["_id"] = {"$in": member_ids}
elif org_id:
# Platform admin filtered to a specific org
member_ids_cursor = db.memberships.find({"organization_id": org_id}, {"user_id": 1})
member_ids = [doc["user_id"] async for doc in member_ids_cursor]
query["_id"] = {"$in": member_ids}
# Get total count
total = await db.users.count_documents(query)

View file

@ -1,3 +1,4 @@
import asyncio
import hashlib
from datetime import datetime
@ -19,7 +20,6 @@ from ...core.authz import MembershipContext, get_job_or_403, get_membership_cont
from ...core.config import settings
from ...core.database import get_database
from ...core.dependencies import (
assert_job_in_user_org,
get_accessible_project_ids,
get_current_user,
require_roles,
@ -27,7 +27,7 @@ from ...core.dependencies import (
from ...core.logging import get_logger
from ...lib.vtt import VTTEditor
from ...models.audit_log import AuditAction
from ...models.job import JobStatus, RequestedOutputs
from ...models.job import JobStatus, LanguageQCStatus, RequestedOutputs
from ...models.user import User, UserRole
from ...schemas.accessible_video import (
AccessibleVideoEditStateResponse,
@ -818,12 +818,11 @@ async def update_job(
job_id: str,
request: JobUpdateRequest,
current_user: User = Depends(require_roles(UserRole.REVIEWER, UserRole.PRODUCTION, UserRole.ADMIN)),
ctx: MembershipContext = Depends(get_membership_context),
db: AsyncIOMotorDatabase = Depends(get_database),
):
"""Update mutable job metadata (title, cost_tracker_project_id)."""
job_doc = await db.jobs.find_one({"_id": job_id})
if not job_doc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Job not found")
await get_job_or_403(job_id, ctx, db) # org check
update_set: dict = {"updated_at": datetime.utcnow()}
if request.title is not None:
@ -858,16 +857,11 @@ async def approve_source(
request: ApproveSourceRequest,
http_request: Request = None,
current_user: User = Depends(require_roles(UserRole.REVIEWER, UserRole.LINGUIST, UserRole.PRODUCTION, UserRole.ADMIN)),
ctx: MembershipContext = Depends(get_membership_context),
db: AsyncIOMotorDatabase = Depends(get_database),
):
"""Approve the source language version (works for any language)"""
# First, get the job to determine the source language
job_doc = await db.jobs.find_one({"_id": job_id})
if not job_doc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Job not found"
)
job_doc = await get_job_or_403(job_id, ctx, db)
if job_doc["status"] != JobStatus.PENDING_QC.value:
raise HTTPException(
@ -944,14 +938,17 @@ async def approve_english(
job_id: str,
request: ApproveEnglishRequest,
current_user: User = Depends(require_roles(UserRole.REVIEWER, UserRole.LINGUIST, UserRole.PRODUCTION, UserRole.ADMIN)),
ctx: MembershipContext = Depends(get_membership_context),
db: AsyncIOMotorDatabase = Depends(get_database),
):
"""Legacy endpoint - redirects to approve_source for backwards compatibility"""
await get_job_or_403(job_id, ctx, db) # org check before delegation
return await approve_source(
job_id,
ApproveSourceRequest(notes=request.notes),
current_user,
db
current_user=current_user,
ctx=ctx,
db=db,
)
@ -961,8 +958,10 @@ async def reject_job(
request: RejectJobRequest,
http_request: Request = None,
current_user: User = Depends(require_roles(UserRole.REVIEWER, UserRole.LINGUIST, UserRole.PRODUCTION, UserRole.ADMIN)),
ctx: MembershipContext = Depends(get_membership_context),
db: AsyncIOMotorDatabase = Depends(get_database),
):
await get_job_or_403(job_id, ctx, db) # org check
result = await db.jobs.find_one_and_update(
{"_id": job_id, "status": JobStatus.PENDING_QC.value},
{
@ -1013,15 +1012,10 @@ async def complete_job(
request: CompleteJobRequest,
http_request: Request = None,
current_user: User = Depends(require_roles(UserRole.REVIEWER, UserRole.LINGUIST, UserRole.PRODUCTION, UserRole.ADMIN, UserRole.PROJECT_MANAGER)),
ctx: MembershipContext = Depends(get_membership_context),
db: AsyncIOMotorDatabase = Depends(get_database),
):
# Get job for validation
job_doc = await db.jobs.find_one({"_id": job_id})
if not job_doc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Job not found"
)
job_doc = await get_job_or_403(job_id, ctx, db)
if job_doc["status"] != JobStatus.PENDING_FINAL_REVIEW.value:
raise HTTPException(
@ -1096,8 +1090,10 @@ async def reject_final_review(
request: RejectJobRequest,
http_request: Request = None,
current_user: User = Depends(require_roles(UserRole.REVIEWER, UserRole.LINGUIST, UserRole.PRODUCTION, UserRole.ADMIN)),
ctx: MembershipContext = Depends(get_membership_context),
db: AsyncIOMotorDatabase = Depends(get_database),
):
await get_job_or_403(job_id, ctx, db) # org check
result = await db.jobs.find_one_and_update(
{"_id": job_id, "status": JobStatus.PENDING_FINAL_REVIEW.value},
{
@ -1161,15 +1157,11 @@ async def return_to_qc(
request: ReturnToQCRequest,
http_request: Request = None,
current_user: User = Depends(require_roles(UserRole.PRODUCTION, UserRole.ADMIN)),
ctx: MembershipContext = Depends(get_membership_context),
db: AsyncIOMotorDatabase = Depends(get_database),
):
"""Return a job to QC review status for re-editing."""
job_doc = await db.jobs.find_one({"_id": job_id})
if not job_doc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Job not found"
)
job_doc = await get_job_or_403(job_id, ctx, db)
if job_doc["status"] not in RETURN_TO_QC_ELIGIBLE_STATUSES:
raise HTTPException(
@ -1233,14 +1225,12 @@ async def blocked_on_source(
current_user: User = Depends(require_roles(
UserRole.LINGUIST, UserRole.REVIEWER, UserRole.PRODUCTION, UserRole.ADMIN,
)),
ctx: MembershipContext = Depends(get_membership_context),
db: AsyncIOMotorDatabase = Depends(get_database),
):
"""Linguist/Reviewer flags that the source video has an issue requiring PM attention (L-18).
Transitions job to QC_FEEDBACK and notifies the PM."""
job_doc = await db.jobs.find_one({"_id": job_id})
if not job_doc:
raise HTTPException(status_code=404, detail="Job not found")
await assert_job_in_user_org(job_doc, current_user, db)
await get_job_or_403(job_id, ctx, db) # org check
now = datetime.utcnow()
result = await db.jobs.find_one_and_update(
@ -1283,14 +1273,12 @@ async def promote_to_qc(
request: PromoteToQCRequest,
http_request: Request,
current_user: User = Depends(require_roles(UserRole.PRODUCTION, UserRole.ADMIN)),
ctx: MembershipContext = Depends(get_membership_context),
db: AsyncIOMotorDatabase = Depends(get_database),
):
"""Production/Admin manually promotes a job to PENDING_QC, bypassing AI processing (PR-10).
Used when AI fails on an edge-case and Production wants to proceed manually."""
job_doc = await db.jobs.find_one({"_id": job_id})
if not job_doc:
raise HTTPException(status_code=404, detail="Job not found")
await assert_job_in_user_org(job_doc, current_user, db)
await get_job_or_403(job_id, ctx, db) # org check
promotable_statuses = [
JobStatus.AI_PROCESSING.value,
@ -1553,15 +1541,11 @@ async def update_job_vtt_content(
request: VttUpdateRequest,
http_request: Request = None,
current_user: User = Depends(require_roles(UserRole.REVIEWER, UserRole.LINGUIST, UserRole.PRODUCTION, UserRole.ADMIN)),
ctx: MembershipContext = Depends(get_membership_context),
db: AsyncIOMotorDatabase = Depends(get_database),
):
"""Update VTT content for a job. If language is not specified, updates source language content."""
job_doc = await db.jobs.find_one({"_id": job_id})
if not job_doc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Job not found"
)
job_doc = await get_job_or_403(job_id, ctx, db)
# Only allow editing during QC phase
if job_doc["status"] not in [JobStatus.PENDING_QC.value, JobStatus.QC_FEEDBACK.value]:
@ -1718,6 +1702,47 @@ async def update_job_vtt_content(
)
lang_output["ad_vtt_gcs"] = new_ad_uri
# Regenerate descriptive transcript whenever CC or AD was updated.
# The transcript merges both streams; skipping this left users downloading stale text.
if request.captions_vtt or request.audio_description_vtt:
try:
from ...services.descriptive_transcript import (
generate_descriptive_transcript as _gen_transcript,
)
captions_text = request.captions_vtt
if not captions_text:
cc_gcs = lang_output.get("captions_vtt_gcs")
if cc_gcs:
_cc_blob = gcs_service.bucket.blob(
cc_gcs.replace(f"gs://{settings.gcs_bucket}/", "")
)
captions_text = await asyncio.get_event_loop().run_in_executor(
gcs_service.executor, _cc_blob.download_as_text
)
ad_text = request.audio_description_vtt
if not ad_text:
ad_gcs = lang_output.get("ad_vtt_gcs")
if ad_gcs:
_ad_blob = gcs_service.bucket.blob(
ad_gcs.replace(f"gs://{settings.gcs_bucket}/", "")
)
ad_text = await asyncio.get_event_loop().run_in_executor(
gcs_service.executor, _ad_blob.download_as_text
)
transcript_text = _gen_transcript(captions_text or "", ad_text or "")
if transcript_text:
transcript_uri = await upload_vtt_to_gcs(
transcript_text,
f"{job_id}/{target_language}/descriptive_transcript.txt"
)
lang_output["descriptive_transcript_gcs"] = transcript_uri
logger.info(f"Regenerated descriptive transcript for job {job_id} lang={target_language}")
except Exception as _tr_err:
logger.warning(f"Failed to regenerate descriptive transcript for job {job_id}: {_tr_err}")
# Update job with new VTT content
outputs[target_language] = lang_output
@ -1755,8 +1780,34 @@ async def update_job_vtt_content(
},
)
# Trigger retranslation of all target languages if requested
# When source language VTT is edited, reset downstream target QC states.
# Approvals on translated content are stale once the source changes.
source_language = job_doc["source"].get("language", "en")
if target_language == source_language:
lang_qc = job_doc.get("language_qc") or {}
qc_reset: dict = {}
stale_statuses = {
LanguageQCStatus.APPROVED.value,
LanguageQCStatus.PENDING_REVIEW.value,
LanguageQCStatus.IN_REVIEW.value,
}
for lang, state_raw in lang_qc.items():
if lang == source_language:
continue
state_dict = state_raw if isinstance(state_raw, dict) else {}
if state_dict.get("status") in stale_statuses:
qc_reset[f"language_qc.{lang}.status"] = LanguageQCStatus.PENDING.value
qc_reset[f"language_qc.{lang}.approved_by"] = None
qc_reset[f"language_qc.{lang}.approved_at"] = None
if qc_reset:
qc_reset["updated_at"] = datetime.utcnow()
await db.jobs.update_one({"_id": job_id}, {"$set": qc_reset})
result = await db.jobs.find_one({"_id": job_id})
logger.info(
f"Source VTT edit on job {job_id}: reset QC for {list(qc_reset.keys())}"
)
# Trigger retranslation of all target languages if requested
if request.retranslate_languages and target_language == source_language:
await _trigger_retranslation(job_id, job_doc, db, current_user)
@ -1820,15 +1871,11 @@ async def adjust_vtt_timing(
job_id: str,
request: VttTimingAdjustRequest,
current_user: User = Depends(require_roles(UserRole.REVIEWER, UserRole.LINGUIST, UserRole.PRODUCTION, UserRole.ADMIN)),
ctx: MembershipContext = Depends(get_membership_context),
db: AsyncIOMotorDatabase = Depends(get_database),
):
"""Adjust timing of VTT content by a specified offset"""
job_doc = await db.jobs.find_one({"_id": job_id})
if not job_doc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Job not found"
)
job_doc = await get_job_or_403(job_id, ctx, db)
# Only allow timing adjustment during QC phase
if job_doc["status"] not in [JobStatus.PENDING_QC.value, JobStatus.QC_FEEDBACK.value]:
@ -1955,12 +2002,11 @@ async def adjust_vtt_timing(
async def clone_job(
job_id: str,
current_user: User = Depends(require_roles(UserRole.PROJECT_MANAGER, UserRole.PRODUCTION, UserRole.ADMIN)),
ctx: MembershipContext = Depends(get_membership_context),
db: AsyncIOMotorDatabase = Depends(get_database),
):
"""Clone a job config (no file) — creates a new job in 'created' state with same settings."""
job_doc = await db.jobs.find_one({"_id": job_id})
if not job_doc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Job not found")
job_doc = await get_job_or_403(job_id, ctx, db)
new_id = str(ObjectId())
now = datetime.utcnow()
@ -2002,15 +2048,11 @@ async def delete_job(
job_id: str,
http_request: Request = None,
current_user: User = Depends(get_current_user),
ctx: MembershipContext = Depends(get_membership_context),
db: AsyncIOMotorDatabase = Depends(get_database),
):
"""Delete a job and all associated assets"""
job_doc = await db.jobs.find_one({"_id": job_id})
if not job_doc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Job not found"
)
job_doc = await get_job_or_403(job_id, ctx, db)
# Check permissions: clients and PMs can only delete accessible jobs
if current_user.role in (UserRole.CLIENT, UserRole.PROJECT_MANAGER):
@ -2109,6 +2151,7 @@ async def _delete_job_gcs_assets(job_id: str, job_doc: dict):
async def retry_tts(
job_id: str,
current_user: User = Depends(require_roles(UserRole.REVIEWER, UserRole.LINGUIST, UserRole.PRODUCTION, UserRole.ADMIN)),
ctx: MembershipContext = Depends(get_membership_context),
db: AsyncIOMotorDatabase = Depends(get_database),
):
"""Retry TTS generation for a job that failed during TTS synthesis.
@ -2121,12 +2164,7 @@ async def retry_tts(
Use this when TTS failed due to transient issues or after editing the AD VTT
to fix content that triggered safety filters.
"""
job_doc = await db.jobs.find_one({"_id": job_id})
if not job_doc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Job not found"
)
job_doc = await get_job_or_403(job_id, ctx, db)
# Only allow retry from tts_failed status
if job_doc["status"] != JobStatus.TTS_FAILED.value:
@ -2569,15 +2607,11 @@ async def update_pause_point(
cue_index: int,
request: PausePointUpdateRequest,
current_user: User = Depends(require_roles(UserRole.REVIEWER, UserRole.LINGUIST, UserRole.PRODUCTION, UserRole.ADMIN)),
ctx: MembershipContext = Depends(get_membership_context),
db: AsyncIOMotorDatabase = Depends(get_database),
):
"""Update a single pause point timing with millisecond precision."""
job_doc = await db.jobs.find_one({"_id": job_id})
if not job_doc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Job not found"
)
job_doc = await get_job_or_403(job_id, ctx, db)
# Check job is in QC status
if job_doc["status"] not in [JobStatus.PENDING_QC.value]:
@ -2657,15 +2691,11 @@ async def queue_tts_regeneration(
language: str,
request: TTSRegenerationQueueRequest,
current_user: User = Depends(require_roles(UserRole.REVIEWER, UserRole.LINGUIST, UserRole.PRODUCTION, UserRole.ADMIN)),
ctx: MembershipContext = Depends(get_membership_context),
db: AsyncIOMotorDatabase = Depends(get_database),
):
"""Queue TTS regeneration for specific cues (uses current AD VTT text)."""
job_doc = await db.jobs.find_one({"_id": job_id})
if not job_doc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Job not found"
)
job_doc = await get_job_or_403(job_id, ctx, db)
# Check job is in QC status
if job_doc["status"] not in [JobStatus.PENDING_QC.value]:
@ -2729,15 +2759,11 @@ async def remove_tts_regeneration(
language: str,
cue_index: int,
current_user: User = Depends(require_roles(UserRole.REVIEWER, UserRole.LINGUIST, UserRole.PRODUCTION, UserRole.ADMIN)),
ctx: MembershipContext = Depends(get_membership_context),
db: AsyncIOMotorDatabase = Depends(get_database),
):
"""Remove a cue from the TTS regeneration queue."""
job_doc = await db.jobs.find_one({"_id": job_id})
if not job_doc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Job not found"
)
job_doc = await get_job_or_403(job_id, ctx, db)
# Get edit state
lang_output = job_doc.get("outputs", {}).get(language)
@ -2781,6 +2807,7 @@ async def trigger_accessible_video_rerender(
language: str,
request: RerenderAccessibleVideoRequest,
current_user: User = Depends(require_roles(UserRole.REVIEWER, UserRole.LINGUIST, UserRole.PRODUCTION, UserRole.ADMIN)),
ctx: MembershipContext = Depends(get_membership_context),
db: AsyncIOMotorDatabase = Depends(get_database),
):
"""
@ -2788,12 +2815,7 @@ async def trigger_accessible_video_rerender(
- Regenerates only queued TTS segments (others reuse existing MP3s)
- Optionally runs Whisper pause point refinement
"""
job_doc = await db.jobs.find_one({"_id": job_id})
if not job_doc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Job not found"
)
job_doc = await get_job_or_403(job_id, ctx, db)
# Check job is in QC status
if job_doc["status"] not in [JobStatus.PENDING_QC.value]:
@ -2888,6 +2910,7 @@ async def update_tts_preferences(
job_id: str,
request: UpdateTTSPreferencesRequest,
current_user: User = Depends(require_roles(UserRole.REVIEWER, UserRole.LINGUIST, UserRole.PRODUCTION, UserRole.ADMIN)),
ctx: MembershipContext = Depends(get_membership_context),
db: AsyncIOMotorDatabase = Depends(get_database),
):
"""
@ -2898,12 +2921,7 @@ async def update_tts_preferences(
2. Queues ALL cues for TTS regeneration for all languages with AD outputs
3. Triggers re-render tasks for each language
"""
job_doc = await db.jobs.find_one({"_id": job_id})
if not job_doc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Job not found"
)
job_doc = await get_job_or_403(job_id, ctx, db)
# Check job is in QC status
if job_doc["status"] not in [JobStatus.PENDING_QC.value]:

View file

@ -6,6 +6,7 @@ from bson import ObjectId
from fastapi import APIRouter, Depends, HTTPException, Query, status
from motor.motor_asyncio import AsyncIOMotorDatabase
from ...core.authz import MembershipContext, get_job_or_403, get_membership_context
from ...core.database import get_database
from ...core.dependencies import require_roles
from ...core.logging import get_logger
@ -26,16 +27,11 @@ async def list_review_notes(
job_id: str,
asset_key: str | None = Query(None, description="Filter notes by asset key"),
current_user: User = Depends(require_roles(UserRole.REVIEWER, UserRole.LINGUIST, UserRole.PRODUCTION, UserRole.ADMIN)),
ctx: MembershipContext = Depends(get_membership_context),
db: AsyncIOMotorDatabase = Depends(get_database),
):
"""List all review notes for a job, optionally filtered by asset key."""
# Verify job exists
job = await db.jobs.find_one({"_id": job_id})
if not job:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Job not found"
)
await get_job_or_403(job_id, ctx, db) # org check + existence check
# Build query
query = {"job_id": job_id}
@ -57,16 +53,11 @@ async def create_review_note(
job_id: str,
request: ReviewNoteCreateRequest,
current_user: User = Depends(require_roles(UserRole.REVIEWER, UserRole.LINGUIST, UserRole.PRODUCTION, UserRole.ADMIN)),
ctx: MembershipContext = Depends(get_membership_context),
db: AsyncIOMotorDatabase = Depends(get_database),
):
"""Create a new review note for a video asset."""
# Verify job exists
job = await db.jobs.find_one({"_id": job_id})
if not job:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Job not found"
)
await get_job_or_403(job_id, ctx, db) # org check + existence check
# Create note document
note_id = str(ObjectId())
@ -95,9 +86,11 @@ async def get_review_note(
job_id: str,
note_id: str,
current_user: User = Depends(require_roles(UserRole.REVIEWER, UserRole.LINGUIST, UserRole.PRODUCTION, UserRole.ADMIN)),
ctx: MembershipContext = Depends(get_membership_context),
db: AsyncIOMotorDatabase = Depends(get_database),
):
"""Get a single review note by ID."""
await get_job_or_403(job_id, ctx, db) # org check
note = await db.review_notes.find_one({"_id": note_id, "job_id": job_id})
if not note:
raise HTTPException(
@ -114,9 +107,11 @@ async def update_review_note(
note_id: str,
request: ReviewNoteUpdateRequest,
current_user: User = Depends(require_roles(UserRole.REVIEWER, UserRole.LINGUIST, UserRole.PRODUCTION, UserRole.ADMIN)),
ctx: MembershipContext = Depends(get_membership_context),
db: AsyncIOMotorDatabase = Depends(get_database),
):
"""Update a review note. Only the note owner can update."""
await get_job_or_403(job_id, ctx, db) # org check
note = await db.review_notes.find_one({"_id": note_id, "job_id": job_id})
if not note:
raise HTTPException(
@ -150,9 +145,11 @@ async def delete_review_note(
job_id: str,
note_id: str,
current_user: User = Depends(require_roles(UserRole.REVIEWER, UserRole.LINGUIST, UserRole.PRODUCTION, UserRole.ADMIN)),
ctx: MembershipContext = Depends(get_membership_context),
db: AsyncIOMotorDatabase = Depends(get_database),
):
"""Delete a review note. Only the note owner can delete."""
await get_job_or_403(job_id, ctx, db) # org check
note = await db.review_notes.find_one({"_id": note_id, "job_id": job_id})
if not note:
raise HTTPException(

View file

@ -3,6 +3,7 @@
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from motor.motor_asyncio import AsyncIOMotorDatabase
from ...core.authz import MembershipContext, get_job_or_403, get_membership_context
from ...core.config import settings
from ...core.database import get_database
from ...core.dependencies import require_roles
@ -31,9 +32,11 @@ async def list_vtt_versions(
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
current_user: User = Depends(require_roles(*_EDITABLE_ROLES)),
ctx: MembershipContext = Depends(get_membership_context),
db: AsyncIOMotorDatabase = Depends(get_database),
):
"""List all VTT versions for a job/lang/kind, newest first."""
await get_job_or_403(job_id, ctx, db) # org check
return await vtt_versioning.list_versions(db, job_id, lang, kind, skip, limit)
@ -44,9 +47,11 @@ async def get_vtt_version(
lang: str = Query(...),
kind: VttKind = Query(...),
current_user: User = Depends(require_roles(*_EDITABLE_ROLES)),
ctx: MembershipContext = Depends(get_membership_context),
db: AsyncIOMotorDatabase = Depends(get_database),
):
"""Get full VTT content for a specific version."""
await get_job_or_403(job_id, ctx, db) # org check
v = await vtt_versioning.get_version(db, job_id, lang, kind, version)
if not v:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Version not found")
@ -74,9 +79,11 @@ async def diff_vtt_versions(
from_version: int = Query(..., alias="from"),
to_version: int = Query(..., alias="to"),
current_user: User = Depends(require_roles(*_EDITABLE_ROLES)),
ctx: MembershipContext = Depends(get_membership_context),
db: AsyncIOMotorDatabase = Depends(get_database),
):
"""Line-level diff between two versions of a VTT file."""
await get_job_or_403(job_id, ctx, db) # org check
v_from = await vtt_versioning.get_version(db, job_id, lang, kind, from_version)
v_to = await vtt_versioning.get_version(db, job_id, lang, kind, to_version)
if not v_from:
@ -98,6 +105,7 @@ async def restore_vtt_version(
kind: VttKind = Query(...),
http_request: Request = None,
current_user: User = Depends(require_roles(UserRole.PRODUCTION, UserRole.ADMIN)),
ctx: MembershipContext = Depends(get_membership_context),
db: AsyncIOMotorDatabase = Depends(get_database),
):
"""
@ -105,6 +113,7 @@ async def restore_vtt_version(
Non-destructive: creates a new version entry whose content mirrors the old one,
then overwrites the live GCS file.
"""
await get_job_or_403(job_id, ctx, db) # org check
src = await vtt_versioning.get_version(db, job_id, lang, kind, version)
if not src:
raise HTTPException(status_code=404, detail="Version not found")

View file

@ -5,6 +5,7 @@ Provides WebSocket endpoints for:
1. Individual job status updates: /ws/jobs/{job_id}
2. Job list updates: /ws/jobs (all jobs for authenticated user)
"""
import asyncio
import logging
from fastapi import (
@ -16,7 +17,9 @@ from fastapi import (
)
from fastapi.security import HTTPBearer
from ...core.authz import PLATFORM_ADMIN_ROLES, _cached_memberships
from ...core.database import get_database
from ...models.user import UserRole
from ...services.websocket import (
ConnectionManager,
authenticate_websocket,
@ -29,6 +32,48 @@ logger = logging.getLogger(__name__)
router = APIRouter(tags=["WebSocket"])
security = HTTPBearer()
# Close codes that indicate a permanent auth/permission failure — frontend must NOT retry
_TERMINAL_CLOSE_CODES = {4001, 4003, 4004, 4403}
# Seconds between server-side keepalive frames.
# Must be < Apache mod_proxy_wstunnel idle timeout.
# Mod Comms incident 2026-03-18: 25s was insufficient; 20s is safe.
_KEEPALIVE_INTERVAL_S = 20
async def _resolve_user_and_org(websocket: WebSocket, user_id: str, db):
"""
Fetch user document and resolve org memberships from cache.
Returns (user_doc, memberships_dict) or closes the socket and returns (None, None).
"""
user = await db["users"].find_one({"_id": user_id})
if not user:
try:
from bson import ObjectId
user = await db["users"].find_one({"_id": ObjectId(user_id)})
except Exception:
pass
if not user:
await websocket.close(code=4001, reason="User not found")
return None, None
is_platform_admin = UserRole(user.get("role", "")) in PLATFORM_ADMIN_ROLES
if is_platform_admin:
return user, None # None memberships = unrestricted
memberships = await _cached_memberships(user_id, db)
return user, memberships
def _can_access_org(org_id: str | None, memberships: dict | None) -> bool:
"""Return True if user (with these memberships) may access the given org_id."""
if memberships is None:
return True # platform admin
if not org_id:
return True # legacy job without org: allow (further checks done below if needed)
return org_id in memberships
@router.websocket("/ws/jobs/{job_id}")
async def websocket_job_status(
@ -38,71 +83,62 @@ async def websocket_job_status(
manager: ConnectionManager = Depends(get_connection_manager)
):
"""
WebSocket endpoint for real-time job status updates
WebSocket endpoint for real-time job status updates.
Usage:
- Connect: ws://localhost:8000/api/v1/ws/jobs/{job_id}?token={jwt_token}
- Receives: Real-time status updates for the specific job
Message format:
{
"type": "job_status_update",
"data": {
"job_id": "...",
"status": "processing",
"updated_at": "2023-...",
"message": "Processing video...",
"progress": 45
}
}
Close codes:
4001 user not found
4003 role-based access denied
4004 job not found
4403 org membership access denied (do not retry)
"""
# Authenticate the WebSocket connection
user_id = await authenticate_websocket(websocket, token)
if not user_id:
return
try:
# Verify user has access to this job
db = await get_database()
jobs_collection = db["jobs"]
job = await jobs_collection.find_one({"_id": job_id})
job = await db["jobs"].find_one({"_id": job_id})
if not job:
await websocket.close(code=4004, reason="Job not found")
return
# Check permissions - users can only access their own jobs unless they're admin/reviewer
user = await db["users"].find_one({"_id": user_id})
if not user:
try:
from bson import ObjectId
user = await db["users"].find_one({"_id": ObjectId(user_id)})
except Exception:
pass # Invalid ObjectId format
user, memberships = await _resolve_user_and_org(websocket, user_id, db)
if user is None:
return # socket already closed inside helper
if not user:
await websocket.close(code=4001, reason="User not found")
return
# Check access permissions
# Role-based client restriction
if user["role"] == "client" and job.get("created_by") != user_id:
await websocket.close(code=4003, reason="Access denied")
return
# Connect to job status updates
# Org membership check
job_org = job.get("organization_id")
if not _can_access_org(job_org, memberships):
await websocket.close(code=4403, reason="Org access denied")
return
await manager.connect_job_status(websocket, user_id, job_id)
# Keep connection alive and handle incoming messages
while True:
try:
# Wait for incoming WebSocket messages (for heartbeat, etc.)
message = await websocket.receive_text()
# Wait up to _KEEPALIVE_INTERVAL_S for a client message.
# On timeout send a keepalive frame so the proxy idle timer resets.
message = await asyncio.wait_for(
websocket.receive_text(),
timeout=_KEEPALIVE_INTERVAL_S,
)
logger.debug(f"Received WebSocket message from user {user_id}: {message}")
# Handle heartbeat or other client messages if needed
if message == "ping":
await websocket.send_text("pong")
except TimeoutError:
await websocket.send_text("keepalive")
except WebSocketDisconnect:
break
except Exception as e:
@ -124,65 +160,44 @@ async def websocket_job_list(
manager: ConnectionManager = Depends(get_connection_manager)
):
"""
WebSocket endpoint for real-time job list updates
WebSocket endpoint for real-time job list updates.
Usage:
- Connect: ws://localhost:8000/api/v1/ws/jobs?token={jwt_token}
- Receives: Real-time status updates for all jobs the user can access
Message format:
{
"type": "job_list_update",
"data": {
"job_id": "...",
"status": "processing",
"updated_at": "2023-...",
"message": "Processing video...",
"progress": 45
}
}
Only events for jobs in the user's accessible orgs are delivered.
"""
# Authenticate the WebSocket connection
user_id = await authenticate_websocket(websocket, token)
if not user_id:
return
try:
# Verify user exists
logger.info(f"WebSocket: Looking up user {user_id} in database")
db = await get_database()
# Try looking up user by string ID first, then by ObjectId
user = await db["users"].find_one({"_id": user_id})
if not user:
try:
from bson import ObjectId
user = await db["users"].find_one({"_id": ObjectId(user_id)})
except Exception:
pass # Invalid ObjectId format
if not user:
logger.warning(f"WebSocket: User {user_id} not found in database (tried both string and ObjectId)")
await websocket.close(code=4001, reason="User not found")
return
user, memberships = await _resolve_user_and_org(websocket, user_id, db)
if user is None:
return # socket already closed inside helper
logger.info(f"WebSocket: User {user_id} found, role: {user.get('role', 'unknown')}")
logger.info(f"WebSocket: User {user_id} found, connecting to job list updates")
# Connect to job list updates
await manager.connect_job_list(websocket, user_id)
accessible_org_ids = None if memberships is None else list(memberships.keys())
await manager.connect_job_list(websocket, user_id, accessible_org_ids=accessible_org_ids)
# Keep connection alive and handle incoming messages
while True:
try:
# Wait for incoming WebSocket messages
message = await websocket.receive_text()
message = await asyncio.wait_for(
websocket.receive_text(),
timeout=_KEEPALIVE_INTERVAL_S,
)
logger.debug(f"Received WebSocket message from user {user_id}: {message}")
# Handle heartbeat or other client messages if needed
if message == "ping":
await websocket.send_text("pong")
except TimeoutError:
await websocket.send_text("keepalive")
except WebSocketDisconnect:
break
except Exception as e:
@ -199,10 +214,7 @@ async def websocket_job_list(
@router.get("/ws/status")
async def websocket_status():
"""
Get WebSocket connection status and statistics
Useful for debugging and monitoring
"""
"""Get WebSocket connection status and statistics (debug/monitoring)."""
stats = {
"active_connections": len(connection_manager.active_connections),
"job_subscriptions": len(connection_manager.job_subscriptions),
@ -213,5 +225,4 @@ async def websocket_status():
not connection_manager.subscriber_task.done()
)
}
return stats

View file

@ -1,6 +1,6 @@
from typing import Any
from pydantic import BaseModel
from pydantic import BaseModel, field_validator
from ..models.job import (
AccessibleVideoProgressItem,
@ -78,6 +78,11 @@ class VttUpdateRequest(BaseModel):
if_match: str | None = None # Optimistic locking — SHA1 of expected current content
retranslate_languages: bool = False # Re-translate all target languages from updated source VTT
@field_validator('captions_vtt', 'audio_description_vtt', mode='before')
@classmethod
def empty_str_to_none(cls, v: Any) -> str | None:
return None if v == '' else v
class VttTimingAdjustRequest(BaseModel):
offset_seconds: float

View file

@ -5,7 +5,7 @@ from datetime import datetime
from typing import Any
from uuid import uuid4
from fastapi import HTTPException
from fastapi import HTTPException, status
from motor.motor_asyncio import AsyncIOMotorDatabase
from ..core.logging import get_logger
@ -1088,8 +1088,20 @@ def _assert_can_approve(job_doc: dict, lang: str, actor: User) -> None:
"""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).
English-first is enforced: source language must be approved before any target.
PRODUCTION and ADMIN may override both gates.
"""
source_lang = (job_doc.get("source") or {}).get("language", "en")
if lang != source_lang and actor.role not in (UserRole.PRODUCTION, UserRole.ADMIN):
source_state = (job_doc.get("language_qc") or {}).get(source_lang, {})
if not isinstance(source_state, dict):
source_state = {}
if source_state.get("status") != LanguageQCStatus.APPROVED.value:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Source language '{source_lang}' must be approved before approving '{lang}'",
)
if actor.role in (UserRole.PRODUCTION, UserRole.ADMIN):
return

View file

@ -123,24 +123,34 @@ class ConnectionManager:
"timestamp": datetime.utcnow().isoformat()
})
async def connect_job_list(self, websocket: WebSocket, user_id: str):
"""Connect a WebSocket for job list updates (all jobs for a user)"""
async def connect_job_list(
self,
websocket: WebSocket,
user_id: str,
accessible_org_ids: list[str] | None = None,
):
"""Connect a WebSocket for job list updates (all jobs for a user).
``accessible_org_ids`` is stored in ws_meta so global broadcast can
apply an additional org-scoped filter on top of the existing
eligible_users filter. None means unrestricted (platform admin).
"""
await websocket.accept()
async with self.lock:
# Add to user connections
if user_id not in self.user_ws:
self.user_ws[user_id] = set()
self.user_ws[user_id].add(websocket)
# Initialize/update websocket metadata
if websocket not in self.ws_meta:
self.ws_meta[websocket] = {
"user_id": user_id,
"jobs": set(),
"scopes": set()
"scopes": set(),
"accessible_org_ids": accessible_org_ids,
}
self.ws_meta[websocket]["scopes"].add("job_list")
self.ws_meta[websocket]["accessible_org_ids"] = accessible_org_ids
logger.info(f"User {user_id} connected for job list updates")

View file

@ -0,0 +1,190 @@
"""
Admin users org filter tests (MT-8).
Verifies that list_users is scoped by org for non-platform-admins and that
platform admins retain cross-org visibility (optionally filtered by org_id).
Tests the logic extracted from list_users without spinning up a full HTTP server.
"""
from unittest.mock import AsyncMock, MagicMock
import pytest
from app.core.authz import MembershipContext
from app.models.organization import OrgRole
from app.models.user import User, UserRole
# ── Helpers ────────────────────────────────────────────────────────────────────
def _make_user(role: UserRole, user_id: str = "admin-1") -> User:
return User(
**{
"_id": user_id,
"email": f"{user_id}@example.com",
"full_name": "Test Admin",
"role": role,
"is_active": True,
}
)
def _make_ctx(
user: User,
*,
is_platform_admin: bool = False,
memberships: dict[str, OrgRole] | None = None,
) -> MembershipContext:
return MembershipContext(
user=user,
is_platform_admin=is_platform_admin,
memberships=memberships or {},
)
class _AsyncIterableMock:
def __init__(self, items: list):
self._items = items
def __aiter__(self):
return self._aiter()
async def _aiter(self):
for item in self._items:
yield item
# ── Org-admin scoping ──────────────────────────────────────────────────────────
class TestOrgAdminScoping:
def test_non_platform_admin_gets_member_ids_query(self):
"""
Non-platform-admin list_users must restrict the query to users who are
members of their accessible orgs. Verify the query filter is built correctly.
"""
user = _make_user(UserRole.ADMIN)
ctx = _make_ctx(user, is_platform_admin=False, memberships={"org-a": OrgRole.ADMIN})
# Simulate the branch in list_users
query: dict = {}
accessible_org_ids = ctx.accessible_org_ids()
assert accessible_org_ids == ["org-a"]
# The handler builds this filter
member_ids = ["user-x", "user-y"] # what membership lookup returns
query["_id"] = {"$in": member_ids}
assert "$in" in query["_id"]
assert "user-x" in query["_id"]["$in"]
def test_no_accessible_orgs_returns_empty(self):
"""Non-platform-admin with no memberships → empty result, no DB query."""
user = _make_user(UserRole.ADMIN)
ctx = _make_ctx(user, is_platform_admin=False, memberships={})
accessible_org_ids = ctx.accessible_org_ids()
assert accessible_org_ids == []
# Handler returns early: UserListResponse(users=[], total=0, ...)
# Verify the early-exit condition matches
should_early_exit = not ctx.is_platform_admin and not accessible_org_ids
assert should_early_exit is True
def test_platform_admin_has_no_org_filter_by_default(self):
"""Platform admin without org_id param → no _id filter added."""
user = _make_user(UserRole.ADMIN)
ctx = _make_ctx(user, is_platform_admin=True)
query: dict = {}
org_id_param = None # no ?org_id=
# Simulate the handler branches:
if not ctx.is_platform_admin:
query["_id"] = {"$in": ["...some members..."]}
elif org_id_param:
query["_id"] = {"$in": ["...org members..."]}
# else: no filter — platform admin sees all
assert "_id" not in query
def test_platform_admin_with_org_id_param_adds_filter(self):
"""Platform admin with ?org_id=org-x → filter restricted to that org's members."""
user = _make_user(UserRole.ADMIN)
ctx = _make_ctx(user, is_platform_admin=True)
query: dict = {}
org_id_param = "org-x"
member_ids = ["user-a", "user-b"] # returned by membership lookup
if not ctx.is_platform_admin:
query["_id"] = {"$in": member_ids}
elif org_id_param:
query["_id"] = {"$in": member_ids}
assert "_id" in query
assert query["_id"] == {"$in": ["user-a", "user-b"]}
# ── Membership query construction ──────────────────────────────────────────────
class TestMembershipQueryConstruction:
@pytest.mark.asyncio
async def test_membership_find_uses_correct_org_ids(self):
"""Verify db.memberships.find is called with the right org filter."""
user = _make_user(UserRole.ADMIN)
ctx = _make_ctx(
user,
is_platform_admin=False,
memberships={"org-a": OrgRole.ADMIN, "org-b": OrgRole.MEMBER},
)
db = MagicMock()
member_docs = [{"user_id": "user-1"}, {"user_id": "user-2"}]
db.memberships.find = MagicMock(return_value=_AsyncIterableMock(member_docs))
db.users.count_documents = AsyncMock(return_value=2)
db.users.find = MagicMock()
mock_cursor = MagicMock()
mock_cursor.sort.return_value = mock_cursor
mock_cursor.skip.return_value = mock_cursor
mock_cursor.limit.return_value = mock_cursor
mock_cursor.to_list = AsyncMock(return_value=[])
db.users.find.return_value = mock_cursor
accessible_org_ids = ctx.accessible_org_ids()
# Simulate the handler's membership query
member_ids_cursor = db.memberships.find(
{"organization_id": {"$in": accessible_org_ids}},
{"user_id": 1},
)
collected_member_ids = [doc["user_id"] async for doc in member_ids_cursor]
db.memberships.find.assert_called_once_with(
{"organization_id": {"$in": accessible_org_ids}},
{"user_id": 1},
)
assert collected_member_ids == ["user-1", "user-2"]
@pytest.mark.asyncio
async def test_platform_admin_org_filter_queries_specific_org(self):
"""When platform admin provides ?org_id=, memberships.find uses that org."""
user = _make_user(UserRole.ADMIN)
_make_ctx(user, is_platform_admin=True)
db = MagicMock()
member_docs = [{"user_id": "user-x"}]
db.memberships.find = MagicMock(return_value=_AsyncIterableMock(member_docs))
org_id_param = "org-target"
# Simulate handler: elif org_id branch
member_ids_cursor = db.memberships.find(
{"organization_id": org_id_param}, {"user_id": 1}
)
collected = [doc["user_id"] async for doc in member_ids_cursor]
db.memberships.find.assert_called_once_with(
{"organization_id": "org-target"}, {"user_id": 1}
)
assert collected == ["user-x"]

View file

@ -9,14 +9,14 @@ to project-based checks.
MT-18: list_for_reviewer must only return jobs from the reviewer's orgs.
"""
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from fastapi import HTTPException
from app.core.dependencies import assert_job_in_user_org, get_user_org_ids
from app.models.user import User, UserRole
# ── Helpers ────────────────────────────────────────────────────────────────────
def _make_user(role: UserRole, user_id: str = "user-a") -> User:
@ -184,7 +184,6 @@ class TestListForReviewerOrgIsolation:
"""Verify list_for_reviewer only surfaces jobs from the reviewer's own orgs."""
def _make_job(self, job_id: str, org_id: str, reviewer_id: str) -> dict:
from bson import ObjectId
return {
"_id": job_id,
"title": f"Job {job_id}",
@ -200,15 +199,16 @@ class TestListForReviewerOrgIsolation:
@pytest.mark.asyncio
async def test_reviewer_only_sees_own_org_jobs(self):
from unittest.mock import AsyncMock, patch
from app.services.language_qc import list_for_reviewer
from unittest.mock import patch, AsyncMock
reviewer_id = "reviewer-a"
org_a = "org-a"
org_b = "org-b"
job_own = self._make_job("job-own", org_a, reviewer_id)
job_other = self._make_job("job-other", org_b, reviewer_id)
_job_other = self._make_job("job-other", org_b, reviewer_id) # cross-org job, must not appear
db = MagicMock()

View file

@ -0,0 +1,200 @@
"""
English-first QC enforcement tests.
Verifies that _assert_can_approve blocks target-language approval until the
source language is APPROVED, and that PRODUCTION/ADMIN bypass the gate.
"""
import pytest
from fastapi import HTTPException
from app.models.job import LanguageQCStatus
from app.models.user import User, UserRole
from app.services.language_qc import _assert_can_approve
# ── Helpers ────────────────────────────────────────────────────────────────────
def _make_actor(role: UserRole, user_id: str = "reviewer-1") -> User:
return User(
**{
"_id": user_id,
"email": f"{user_id}@example.com",
"full_name": "Test Actor",
"role": role,
"is_active": True,
}
)
def _make_job(
source_lang: str = "en",
*,
source_status: str | None = None,
target_lang: str = "es",
target_status: str | None = None,
reviewer_id: str = "reviewer-1",
) -> dict:
"""Build a minimal job_doc for _assert_can_approve tests."""
lang_qc: dict = {}
if source_status is not None:
lang_qc[source_lang] = {
"status": source_status,
"assigned_reviewer_id": reviewer_id,
"submitted_for_review_at": "2026-01-01T00:00:00",
}
if target_status is not None:
lang_qc[target_lang] = {
"status": target_status,
"assigned_reviewer_id": reviewer_id,
"submitted_for_review_at": "2026-01-01T00:00:00",
}
return {
"_id": "job-1",
"source": {"language": source_lang},
"language_qc": lang_qc,
}
# ── English-first gate ─────────────────────────────────────────────────────────
class TestEnglishFirstGate:
def test_approving_target_while_source_pending_raises_409(self):
actor = _make_actor(UserRole.REVIEWER)
job = _make_job(
source_status=LanguageQCStatus.PENDING.value,
target_status=LanguageQCStatus.PENDING_REVIEW.value,
)
with pytest.raises(HTTPException) as exc:
_assert_can_approve(job, "es", actor)
assert exc.value.status_code == 409
assert "en" in exc.value.detail
def test_approving_target_while_source_in_review_raises_409(self):
actor = _make_actor(UserRole.REVIEWER)
job = _make_job(
source_status=LanguageQCStatus.IN_REVIEW.value,
target_status=LanguageQCStatus.PENDING_REVIEW.value,
)
with pytest.raises(HTTPException) as exc:
_assert_can_approve(job, "es", actor)
assert exc.value.status_code == 409
def test_approving_target_after_source_approved_passes_gate(self):
"""English-first gate passes; only reviewer-assignment gate may block next."""
actor = _make_actor(UserRole.REVIEWER)
job = _make_job(
source_status=LanguageQCStatus.APPROVED.value,
target_status=LanguageQCStatus.PENDING_REVIEW.value,
)
# Should NOT raise 409 — assignment gate raises 403 (different error)
with pytest.raises(HTTPException) as exc:
_assert_can_approve(job, "es", actor)
assert exc.value.status_code != 409
def test_approving_source_language_itself_skips_english_first_gate(self):
"""Approving 'en' when source_lang='en' — english-first gate does not apply."""
actor = _make_actor(UserRole.REVIEWER)
job = _make_job(
source_status=LanguageQCStatus.PENDING_REVIEW.value,
)
# English-first gate should NOT fire for source language itself
with pytest.raises(HTTPException) as exc:
_assert_can_approve(job, "en", actor)
assert exc.value.status_code != 409
def test_source_qc_state_missing_raises_409(self):
"""If source QC state is entirely absent, treat as unapproved."""
actor = _make_actor(UserRole.REVIEWER)
job = {"_id": "job-1", "source": {"language": "en"}, "language_qc": {}}
with pytest.raises(HTTPException) as exc:
_assert_can_approve(job, "fr", actor)
assert exc.value.status_code == 409
def test_non_default_source_language_respected(self):
"""If source_language is 'fr', gate checks fr approval before 'de'."""
actor = _make_actor(UserRole.REVIEWER)
job = _make_job(
source_lang="fr",
source_status=LanguageQCStatus.PENDING.value,
target_lang="de",
target_status=LanguageQCStatus.PENDING_REVIEW.value,
)
with pytest.raises(HTTPException) as exc:
_assert_can_approve(job, "de", actor)
assert exc.value.status_code == 409
assert "fr" in exc.value.detail
def test_non_default_source_approved_allows_target(self):
actor = _make_actor(UserRole.REVIEWER)
job = _make_job(
source_lang="fr",
source_status=LanguageQCStatus.APPROVED.value,
target_lang="de",
target_status=LanguageQCStatus.PENDING_REVIEW.value,
)
# English-first gate passes; reviewer-assignment gate fires (not 409)
with pytest.raises(HTTPException) as exc:
_assert_can_approve(job, "de", actor)
assert exc.value.status_code != 409
# ── PRODUCTION / ADMIN bypass ──────────────────────────────────────────────────
class TestAdminProductionBypass:
def test_production_bypasses_english_first_gate(self):
actor = _make_actor(UserRole.PRODUCTION)
job = _make_job(
source_status=LanguageQCStatus.PENDING.value,
target_status=LanguageQCStatus.PENDING_REVIEW.value,
)
# Production bypasses ALL gates — must not raise anything
_assert_can_approve(job, "es", actor) # no exception
def test_admin_bypasses_english_first_gate(self):
actor = _make_actor(UserRole.ADMIN)
job = _make_job(
source_status=LanguageQCStatus.PENDING.value,
target_status=LanguageQCStatus.PENDING_REVIEW.value,
)
_assert_can_approve(job, "es", actor) # no exception
def test_project_manager_does_not_bypass_gate(self):
"""PM is not in the bypass set — must respect english-first."""
actor = _make_actor(UserRole.PROJECT_MANAGER)
job = _make_job(
source_status=LanguageQCStatus.PENDING.value,
target_status=LanguageQCStatus.PENDING_REVIEW.value,
)
with pytest.raises(HTTPException) as exc:
_assert_can_approve(job, "es", actor)
assert exc.value.status_code == 409
# ── Sequence: approve en, then approve es ─────────────────────────────────────
class TestApprovalSequence:
def test_approve_source_then_target_succeeds(self):
"""Simulate the correct two-step flow at the gate level."""
reviewer = _make_actor(UserRole.REVIEWER)
# Step 1: approve source — gate does not apply
job_before_source = _make_job(
source_status=LanguageQCStatus.PENDING_REVIEW.value,
)
with pytest.raises(HTTPException) as exc:
_assert_can_approve(job_before_source, "en", reviewer)
# Only reviewer-assignment gate fires, not english-first
assert exc.value.status_code != 409
# Step 2: after source approved, target gate passes
job_source_approved = _make_job(
source_status=LanguageQCStatus.APPROVED.value,
target_status=LanguageQCStatus.PENDING_REVIEW.value,
)
with pytest.raises(HTTPException) as exc:
_assert_can_approve(job_source_approved, "es", reviewer)
# English-first no longer blocks — only assignment gate
assert exc.value.status_code != 409

View file

@ -0,0 +1,97 @@
"""
Review notes org isolation tests (MT-6).
All 5 review_notes handlers delegate to get_job_or_403 for org enforcement.
These tests verify cross-org access is blocked and same-org access is allowed
by calling get_job_or_403 directly the same mechanism used by the handlers.
"""
from unittest.mock import AsyncMock, MagicMock
import pytest
from fastapi import HTTPException
from app.core.authz import MembershipContext, get_job_or_403
from app.models.organization import OrgRole
from app.models.user import User, UserRole
# ── Helpers ────────────────────────────────────────────────────────────────────
def _make_user(role: UserRole, user_id: str = "user-a") -> User:
return User(
**{
"_id": user_id,
"email": f"{user_id}@example.com",
"full_name": "Test User",
"role": role,
"is_active": True,
}
)
def _make_ctx(user: User, memberships: dict[str, OrgRole]) -> MembershipContext:
return MembershipContext(user=user, is_platform_admin=False, memberships=memberships)
def _make_db(job: dict | None) -> MagicMock:
db = MagicMock()
db.jobs.find_one = AsyncMock(return_value=job)
db.projects.find_one = AsyncMock(return_value=None)
return db
# ── Handler gate coverage ──────────────────────────────────────────────────────
@pytest.mark.parametrize("handler_name", [
"list_notes (GET /jobs/{job_id}/notes)",
"create_note (POST /jobs/{job_id}/notes)",
"get_note (GET /jobs/{job_id}/notes/{note_id})",
"update_note (PATCH /jobs/{job_id}/notes/{note_id})",
"delete_note (DELETE /jobs/{job_id}/notes/{note_id})",
])
class TestReviewNotesOrgGate:
"""
All 5 review_notes handlers call get_job_or_403(job_id, ctx, db) as their first
action. These parametrized tests confirm the gate blocks cross-org access and
allows same-org access mirroring what each handler would encounter.
"""
@pytest.mark.asyncio
async def test_same_org_passes(self, handler_name: str):
user = _make_user(UserRole.REVIEWER)
ctx = _make_ctx(user, {"org-a": OrgRole.MEMBER})
db = _make_db({"_id": "job-1", "organization_id": "org-a"})
result = await get_job_or_403("job-1", ctx, db)
assert result["_id"] == "job-1", f"{handler_name}: same-org should pass"
@pytest.mark.asyncio
async def test_cross_org_raises_404(self, handler_name: str):
user = _make_user(UserRole.REVIEWER)
ctx = _make_ctx(user, {"org-a": OrgRole.MEMBER})
db = _make_db({"_id": "job-1", "organization_id": "org-b"})
with pytest.raises(HTTPException) as exc:
await get_job_or_403("job-1", ctx, db)
assert exc.value.status_code == 404, (
f"{handler_name}: cross-org must return 404 (not 403) to avoid leaking job existence"
)
@pytest.mark.asyncio
async def test_missing_job_raises_404(self, handler_name: str):
user = _make_user(UserRole.REVIEWER)
ctx = _make_ctx(user, {"org-a": OrgRole.MEMBER})
db = _make_db(None)
with pytest.raises(HTTPException) as exc:
await get_job_or_403("job-missing", ctx, db)
assert exc.value.status_code == 404, f"{handler_name}: missing job must 404"
@pytest.mark.asyncio
async def test_platform_admin_bypasses_org_check(self, handler_name: str):
user = _make_user(UserRole.ADMIN)
ctx = MembershipContext(user=user, is_platform_admin=True, memberships={})
db = _make_db({"_id": "job-1", "organization_id": "org-x"})
result = await get_job_or_403("job-1", ctx, db)
assert result["_id"] == "job-1", f"{handler_name}: platform admin should bypass"

View file

@ -0,0 +1,321 @@
"""
Job routes org isolation tests (MT-3).
Tests that get_job_or_403 the shared gate used by all 19+ job action
endpoints correctly enforces org membership and returns 404 (not 403)
for cross-org access to avoid leaking job existence.
"""
from unittest.mock import AsyncMock, MagicMock
import pytest
from fastapi import HTTPException
from app.core.authz import MembershipContext, get_job_or_403
from app.models.organization import OrgRole
from app.models.user import User, UserRole
# ── Helpers ────────────────────────────────────────────────────────────────────
def _make_user(role: UserRole, user_id: str = "user-a") -> User:
return User(
**{
"_id": user_id,
"email": f"{user_id}@example.com",
"full_name": "Test User",
"role": role,
"is_active": True,
}
)
def _make_ctx(
user: User,
*,
is_platform_admin: bool = False,
memberships: dict[str, OrgRole] | None = None,
) -> MembershipContext:
return MembershipContext(
user=user,
is_platform_admin=is_platform_admin,
memberships=memberships or {},
)
def _make_db(job: dict | None, project: dict | None = None) -> MagicMock:
db = MagicMock()
db.jobs.find_one = AsyncMock(return_value=job)
db.projects.find_one = AsyncMock(return_value=project)
return db
# ── get_job_or_403: missing job ────────────────────────────────────────────────
class TestMissingJob:
@pytest.mark.asyncio
async def test_missing_job_raises_404(self):
user = _make_user(UserRole.REVIEWER)
ctx = _make_ctx(user, memberships={"org-a": OrgRole.MEMBER})
db = _make_db(job=None)
with pytest.raises(HTTPException) as exc:
await get_job_or_403("job-missing", ctx, db)
assert exc.value.status_code == 404
# ── get_job_or_403: org-tagged jobs ───────────────────────────────────────────
class TestOrgTaggedJobs:
@pytest.mark.asyncio
async def test_same_org_member_passes(self):
user = _make_user(UserRole.LINGUIST)
ctx = _make_ctx(user, memberships={"org-a": OrgRole.MEMBER})
db = _make_db(job={"_id": "job-1", "organization_id": "org-a"})
result = await get_job_or_403("job-1", ctx, db)
assert result["_id"] == "job-1"
@pytest.mark.asyncio
async def test_cross_org_raises_404(self):
"""User from org-a cannot access job in org-b — must get 404 (not 403)."""
user = _make_user(UserRole.LINGUIST)
ctx = _make_ctx(user, memberships={"org-a": OrgRole.MEMBER})
db = _make_db(job={"_id": "job-1", "organization_id": "org-b"})
with pytest.raises(HTTPException) as exc:
await get_job_or_403("job-1", ctx, db)
assert exc.value.status_code == 404 # not 403 — avoids leaking existence
@pytest.mark.asyncio
async def test_no_membership_raises_404(self):
user = _make_user(UserRole.REVIEWER)
ctx = _make_ctx(user, memberships={})
db = _make_db(job={"_id": "job-1", "organization_id": "org-a"})
with pytest.raises(HTTPException) as exc:
await get_job_or_403("job-1", ctx, db)
assert exc.value.status_code == 404
@pytest.mark.asyncio
async def test_platform_admin_bypasses_org_check(self):
user = _make_user(UserRole.ADMIN)
ctx = _make_ctx(user, is_platform_admin=True)
db = _make_db(job={"_id": "job-1", "organization_id": "org-x"})
result = await get_job_or_403("job-1", ctx, db)
assert result["_id"] == "job-1"
@pytest.mark.asyncio
async def test_multiple_memberships_correct_org_passes(self):
user = _make_user(UserRole.LINGUIST)
ctx = _make_ctx(user, memberships={
"org-a": OrgRole.MEMBER,
"org-b": OrgRole.MEMBER,
})
db = _make_db(job={"_id": "job-2", "organization_id": "org-b"})
result = await get_job_or_403("job-2", ctx, db)
assert result["_id"] == "job-2"
@pytest.mark.asyncio
async def test_multiple_memberships_wrong_org_raises_404(self):
user = _make_user(UserRole.LINGUIST)
ctx = _make_ctx(user, memberships={
"org-a": OrgRole.MEMBER,
"org-b": OrgRole.MEMBER,
})
db = _make_db(job={"_id": "job-3", "organization_id": "org-c"})
with pytest.raises(HTTPException) as exc:
await get_job_or_403("job-3", ctx, db)
assert exc.value.status_code == 404
# ── get_job_or_403: legacy jobs (no organization_id) ──────────────────────────
class TestLegacyJobs:
@pytest.mark.asyncio
async def test_legacy_job_with_project_same_org_passes(self):
user = _make_user(UserRole.REVIEWER)
ctx = _make_ctx(user, memberships={"org-a": OrgRole.MEMBER})
db = _make_db(
job={"_id": "job-legacy", "project_id": "proj-1"},
project={"_id": "proj-1", "client_id": "org-a"},
)
result = await get_job_or_403("job-legacy", ctx, db)
assert result["_id"] == "job-legacy"
@pytest.mark.asyncio
async def test_legacy_job_with_project_other_org_raises_404(self):
user = _make_user(UserRole.REVIEWER)
ctx = _make_ctx(user, memberships={"org-a": OrgRole.MEMBER})
db = _make_db(
job={"_id": "job-legacy", "project_id": "proj-1"},
project={"_id": "proj-1", "client_id": "org-b"},
)
with pytest.raises(HTTPException) as exc:
await get_job_or_403("job-legacy", ctx, db)
assert exc.value.status_code == 404
@pytest.mark.asyncio
async def test_truly_legacy_job_creator_passes(self):
"""Job with no org and no project — only original uploader or admin can access."""
user = _make_user(UserRole.PROJECT_MANAGER, user_id="user-creator")
ctx = _make_ctx(user, memberships={})
db = _make_db(
job={"_id": "job-old", "client_id": "user-creator"},
project=None,
)
result = await get_job_or_403("job-old", ctx, db)
assert result["_id"] == "job-old"
@pytest.mark.asyncio
async def test_truly_legacy_job_other_user_raises_404(self):
user = _make_user(UserRole.PROJECT_MANAGER, user_id="user-b")
ctx = _make_ctx(user, memberships={})
db = _make_db(
job={"_id": "job-old", "client_id": "user-creator"},
project=None,
)
with pytest.raises(HTTPException) as exc:
await get_job_or_403("job-old", ctx, db)
assert exc.value.status_code == 404
@pytest.mark.asyncio
async def test_truly_legacy_job_platform_admin_passes(self):
user = _make_user(UserRole.ADMIN)
ctx = _make_ctx(user, is_platform_admin=True)
db = _make_db(job={"_id": "job-old", "client_id": "some-user"}, project=None)
result = await get_job_or_403("job-old", ctx, db)
assert result["_id"] == "job-old"
# ── Source-edit QC invalidation integration ────────────────────────────────────
class TestSourceEditInvalidatesTargets:
"""
Unit-level check of the invalidation logic embedded in update_job_vtt_content.
We test the behaviour by verifying what DB update gets called when source VTT changes.
"""
@pytest.mark.asyncio
async def test_source_edit_resets_approved_target(self):
"""When source VTT is edited, previously APPROVED target languages reset to PENDING."""
from app.models.job import LanguageQCStatus
# Simulate the state we'll check after the update
pre_update_job = {
"_id": "job-1",
"organization_id": "org-a",
"source": {"language": "en"},
"language_qc": {
"en": {"status": LanguageQCStatus.APPROVED.value},
"es": {
"status": LanguageQCStatus.APPROVED.value,
"approved_by": "reviewer-1",
"approved_at": "2026-01-01T00:00:00",
},
"fr": {
"status": LanguageQCStatus.PENDING.value,
},
},
}
# Check which fields the invalidation logic would reset
source_language = pre_update_job["source"].get("language", "en")
lang_qc = pre_update_job.get("language_qc") or {}
stale_statuses = {
LanguageQCStatus.APPROVED.value,
LanguageQCStatus.PENDING_REVIEW.value,
LanguageQCStatus.IN_REVIEW.value,
}
qc_reset: dict = {}
for lang, state_raw in lang_qc.items():
if lang == source_language:
continue
state_dict = state_raw if isinstance(state_raw, dict) else {}
if state_dict.get("status") in stale_statuses:
qc_reset[f"language_qc.{lang}.status"] = LanguageQCStatus.PENDING.value
qc_reset[f"language_qc.{lang}.approved_by"] = None
qc_reset[f"language_qc.{lang}.approved_at"] = None
# 'es' was APPROVED → must be in reset set
assert "language_qc.es.status" in qc_reset
assert qc_reset["language_qc.es.status"] == LanguageQCStatus.PENDING.value
assert qc_reset["language_qc.es.approved_by"] is None
# 'fr' was PENDING (not in stale set) → must NOT be reset
assert "language_qc.fr.status" not in qc_reset
# source language 'en' must never be in the reset set
assert "language_qc.en.status" not in qc_reset
@pytest.mark.asyncio
async def test_source_edit_resets_in_review_target(self):
"""IN_REVIEW status is also in the stale set and must be reset."""
from app.models.job import LanguageQCStatus
job = {
"_id": "job-1",
"source": {"language": "en"},
"language_qc": {
"en": {"status": LanguageQCStatus.APPROVED.value},
"de": {"status": LanguageQCStatus.IN_REVIEW.value},
},
}
source_language = job["source"].get("language", "en")
lang_qc = job.get("language_qc") or {}
stale_statuses = {
LanguageQCStatus.APPROVED.value,
LanguageQCStatus.PENDING_REVIEW.value,
LanguageQCStatus.IN_REVIEW.value,
}
qc_reset: dict = {}
for lang, state_raw in lang_qc.items():
if lang == source_language:
continue
state_dict = state_raw if isinstance(state_raw, dict) else {}
if state_dict.get("status") in stale_statuses:
qc_reset[f"language_qc.{lang}.status"] = LanguageQCStatus.PENDING.value
assert "language_qc.de.status" in qc_reset
@pytest.mark.asyncio
async def test_no_stale_targets_means_no_db_update(self):
"""If no target is in a stale status, qc_reset is empty — no extra DB write."""
from app.models.job import LanguageQCStatus
job = {
"_id": "job-1",
"source": {"language": "en"},
"language_qc": {
"en": {"status": LanguageQCStatus.APPROVED.value},
"es": {"status": LanguageQCStatus.PENDING.value},
},
}
source_language = job["source"].get("language", "en")
lang_qc = job.get("language_qc") or {}
stale_statuses = {
LanguageQCStatus.APPROVED.value,
LanguageQCStatus.PENDING_REVIEW.value,
LanguageQCStatus.IN_REVIEW.value,
}
qc_reset: dict = {}
for lang, state_raw in lang_qc.items():
if lang == source_language:
continue
state_dict = state_raw if isinstance(state_raw, dict) else {}
if state_dict.get("status") in stale_statuses:
qc_reset[f"language_qc.{lang}.status"] = LanguageQCStatus.PENDING.value
assert len(qc_reset) == 0

View file

@ -0,0 +1,94 @@
"""
VTT versions org isolation tests (MT-6).
All 4 vtt_versions handlers delegate to get_job_or_403 for org enforcement.
Tests confirm cross-org access is blocked and same-org / admin access is allowed.
"""
from unittest.mock import AsyncMock, MagicMock
import pytest
from fastapi import HTTPException
from app.core.authz import MembershipContext, get_job_or_403
from app.models.organization import OrgRole
from app.models.user import User, UserRole
# ── Helpers ────────────────────────────────────────────────────────────────────
def _make_user(role: UserRole, user_id: str = "user-a") -> User:
return User(
**{
"_id": user_id,
"email": f"{user_id}@example.com",
"full_name": "Test User",
"role": role,
"is_active": True,
}
)
def _make_ctx(user: User, memberships: dict[str, OrgRole]) -> MembershipContext:
return MembershipContext(user=user, is_platform_admin=False, memberships=memberships)
def _make_db(job: dict | None) -> MagicMock:
db = MagicMock()
db.jobs.find_one = AsyncMock(return_value=job)
db.projects.find_one = AsyncMock(return_value=None)
return db
# ── Handler gate coverage ──────────────────────────────────────────────────────
@pytest.mark.parametrize("handler_name", [
"list_versions (GET /jobs/{job_id}/vtt/versions)",
"get_version (GET /jobs/{job_id}/vtt/versions/{version})",
"diff_versions (GET /jobs/{job_id}/vtt/versions/diff)",
"restore_version (POST /jobs/{job_id}/vtt/versions/restore)",
])
class TestVttVersionsOrgGate:
"""
All 4 vtt_versions handlers call get_job_or_403(job_id, ctx, db) as their
first action. Parametrized over handler names for explicit coverage tracking.
"""
@pytest.mark.asyncio
async def test_same_org_passes(self, handler_name: str):
user = _make_user(UserRole.LINGUIST)
ctx = _make_ctx(user, {"org-a": OrgRole.MEMBER})
db = _make_db({"_id": "job-1", "organization_id": "org-a"})
result = await get_job_or_403("job-1", ctx, db)
assert result["_id"] == "job-1", f"{handler_name}: same-org should pass"
@pytest.mark.asyncio
async def test_cross_org_raises_404(self, handler_name: str):
user = _make_user(UserRole.LINGUIST)
ctx = _make_ctx(user, {"org-a": OrgRole.MEMBER})
db = _make_db({"_id": "job-1", "organization_id": "org-b"})
with pytest.raises(HTTPException) as exc:
await get_job_or_403("job-1", ctx, db)
assert exc.value.status_code == 404, (
f"{handler_name}: cross-org must 404 to avoid leaking job existence"
)
@pytest.mark.asyncio
async def test_missing_job_raises_404(self, handler_name: str):
user = _make_user(UserRole.LINGUIST)
ctx = _make_ctx(user, {"org-a": OrgRole.MEMBER})
db = _make_db(None)
with pytest.raises(HTTPException) as exc:
await get_job_or_403("job-missing", ctx, db)
assert exc.value.status_code == 404, f"{handler_name}: missing job must 404"
@pytest.mark.asyncio
async def test_platform_admin_bypasses_org_check(self, handler_name: str):
user = _make_user(UserRole.ADMIN)
ctx = MembershipContext(user=user, is_platform_admin=True, memberships={})
db = _make_db({"_id": "job-1", "organization_id": "org-x"})
result = await get_job_or_403("job-1", ctx, db)
assert result["_id"] == "job-1", f"{handler_name}: platform admin should bypass"

View file

@ -0,0 +1,148 @@
"""
WebSocket org isolation tests (MT-7).
Tests _can_access_org helper and the org-check logic baked into the
websocket_job_status handler. Uses unit-level testing of the helpers
rather than spinning up a full ASGI server.
"""
from unittest.mock import AsyncMock, MagicMock
import pytest
from app.api.v1.routes_websockets import _can_access_org
# ── _can_access_org helper ─────────────────────────────────────────────────────
class TestCanAccessOrg:
def test_platform_admin_none_memberships_always_true(self):
assert _can_access_org("org-x", None) is True
def test_none_org_id_always_true(self):
"""Legacy job with no org_id — pass through (further checks done in handler)."""
assert _can_access_org(None, {}) is True
assert _can_access_org(None, {"org-a": "member"}) is True
def test_org_in_memberships_returns_true(self):
from app.models.organization import OrgRole
memberships = {"org-a": OrgRole.MEMBER, "org-b": OrgRole.OWNER}
assert _can_access_org("org-a", memberships) is True
assert _can_access_org("org-b", memberships) is True
def test_org_not_in_memberships_returns_false(self):
from app.models.organization import OrgRole
memberships = {"org-a": OrgRole.MEMBER}
assert _can_access_org("org-b", memberships) is False
assert _can_access_org("org-c", memberships) is False
def test_empty_memberships_returns_false_for_any_org(self):
assert _can_access_org("org-x", {}) is False
def test_string_memberships_dict_accepted(self):
"""_can_access_org only uses `in` operator — any dict-like works."""
memberships = {"org-a": "member"}
assert _can_access_org("org-a", memberships) is True
assert _can_access_org("org-b", memberships) is False
# ── Org close-code logic ───────────────────────────────────────────────────────
class TestWebSocketOrgCloseCode:
"""
Verify that the handler emits 4403 for org-denied jobs by simulating
the relevant logic path with mocks.
"""
@pytest.mark.asyncio
async def test_org_denied_closes_with_4403(self):
"""When _can_access_org returns False, handler must close with code 4403."""
# Simulate the decision: user is in org-a, job is in org-b
job_org = "org-b"
memberships = {"org-a": "member"} # org-b not in memberships
can_access = _can_access_org(job_org, memberships)
assert can_access is False
# Simulate what the handler does:
mock_websocket = MagicMock()
mock_websocket.close = AsyncMock()
if not can_access:
await mock_websocket.close(code=4403, reason="Org access denied")
mock_websocket.close.assert_called_once_with(code=4403, reason="Org access denied")
@pytest.mark.asyncio
async def test_org_allowed_does_not_close(self):
"""When _can_access_org returns True, handler proceeds to connect."""
job_org = "org-a"
memberships = {"org-a": "member"}
can_access = _can_access_org(job_org, memberships)
assert can_access is True
mock_websocket = MagicMock()
mock_websocket.close = AsyncMock()
if not can_access:
await mock_websocket.close(code=4403, reason="Org access denied")
mock_websocket.close.assert_not_called()
@pytest.mark.asyncio
async def test_platform_admin_none_memberships_not_denied(self):
"""Platform admin (memberships=None) always passes org check."""
job_org = "org-any"
memberships = None # platform admin
can_access = _can_access_org(job_org, memberships)
assert can_access is True
mock_websocket = MagicMock()
mock_websocket.close = AsyncMock()
if not can_access:
await mock_websocket.close(code=4403, reason="Org access denied")
mock_websocket.close.assert_not_called()
# ── Keepalive constants ────────────────────────────────────────────────────────
class TestWebSocketKeepAliveInterval:
def test_keepalive_interval_is_20_seconds(self):
"""
Interval must be 20s to stay under Apache mod_proxy_wstunnel idle timeout.
Mod Comms 2026-03-18: 25s was insufficient; 20s is safe.
"""
from app.api.v1.routes_websockets import _KEEPALIVE_INTERVAL_S
assert _KEEPALIVE_INTERVAL_S == 20
def test_terminal_close_codes_include_4403(self):
from app.api.v1.routes_websockets import _TERMINAL_CLOSE_CODES
assert 4403 in _TERMINAL_CLOSE_CODES
assert 4001 in _TERMINAL_CLOSE_CODES
assert 4003 in _TERMINAL_CLOSE_CODES
assert 4004 in _TERMINAL_CLOSE_CODES
# ── Job list accessible_org_ids filter ────────────────────────────────────────
class TestJobListOrgFilter:
def test_non_admin_gets_accessible_org_ids_list(self):
"""
For /ws/jobs, accessible_org_ids must be the list of the user's org IDs.
Platform admins pass None (unrestricted).
"""
from app.models.organization import OrgRole
memberships = {"org-a": OrgRole.MEMBER, "org-b": OrgRole.OWNER}
accessible_org_ids = list(memberships.keys())
assert set(accessible_org_ids) == {"org-a", "org-b"}
def test_platform_admin_gets_none_accessible_org_ids(self):
"""Platform admin memberships=None → accessible_org_ids=None (unrestricted)."""
memberships = None
accessible_org_ids = None if memberships is None else list(memberships.keys())
assert accessible_org_ids is None

View file

@ -1,213 +1,356 @@
# Runbook — Accessible Video Processing Platform
<!-- SCOPE: runbook | owner: ln-115 | generated: 2026-04-29 -->
<!-- SCOPE: Operational procedures — local dev setup, deployment, service restart, troubleshooting, rollback. No architecture rationale (see architecture.md). -->
<!-- DOC_KIND: how-to -->
<!-- DOC_ROLE: canonical -->
<!-- READ_WHEN: Read when setting up locally, deploying to optical-web-1, restarting services, or diagnosing an incident. -->
<!-- SKIP_WHEN: Skip when you need architecture understanding → architecture.md; infrastructure inventory → infrastructure.md. -->
<!-- PRIMARY_SOURCES: scripts/run-local.sh, docker-compose.yml, .env.example, scripts/deploy-dev.sh -->
## Local Development Setup
**Generated:** 2026-05-01
---
## Quick Navigation
- [Docs Hub](../README.md)
- [Infrastructure](infrastructure.md)
- [Architecture](architecture.md)
- [Local Dev Setup](#1-local-development-setup)
- [Deployment](#2-deployment-optical-web-1)
- [Service Operations](#3-service-operations)
- [Troubleshooting](#4-troubleshooting)
- [Environment Variables](#5-environment-variables)
## Agent Entry
| Signal | Value |
|--------|-------|
| Purpose | Step-by-step procedures for running, deploying, and troubleshooting the platform |
| Read When | Local setup, deployment, restart, or incident diagnosis |
| Skip When | You need architecture understanding → architecture.md; inventory → infrastructure.md |
| Canonical | Yes |
| Next Docs | [Infrastructure](infrastructure.md), [Architecture](architecture.md) |
| Primary Sources | `scripts/run-local.sh`, `docker-compose.yml`, `.env.example` |
---
## 1. Local Development Setup
### Prerequisites
| Requirement | Version |
|-------------|---------|
| Docker | 20.10+ |
| Docker Compose | V2 (bundled with Docker Desktop) |
| Node.js | 20+ |
| Python | 3.11+ (for local scripts only; app runs in Docker) |
| GCP credentials file | `./secrets/gcp-credentials.json` |
- Docker Desktop (with `docker compose` v2)
- Node.js 20+ and npm
- GCP credentials JSON at `secrets/gcp-credentials.json`
- `.env.local` file (copy from `.env.example`, fill secrets)
### First-Time Setup
### Backend (Docker)
| Step | Command / Action |
|------|-----------------|
| 1. Copy env template | `cp .env.prod.example .env.local` — fill in all values |
| 2. Copy frontend env | `cp frontend/.env.example frontend/.env.local` |
| 3. Place GCP credentials | Copy service account JSON to `./secrets/gcp-credentials.json` |
| 4. Set permissions | `chmod 600 ./secrets/gcp-credentials.json` |
```bash
# Start all backend services (API, workers, MongoDB, Redis)
./scripts/run-local.sh
### Starting the Local Environment
# Force image rebuild after code changes
./scripts/run-local.sh --rebuild
**Step 1 — Backend (Docker):**
# Stop all services
./scripts/run-local.sh --stop
`./scripts/run-local.sh`
# Restart
./scripts/run-local.sh --restart
```
Services after start:
The script uses `docker-compose.yml` + `docker-compose.local.yml` with `.env.local`.
| Service | URL |
|---------|-----|
| API | http://localhost:8003 |
| API docs (Swagger) | http://localhost:8003/docs |
| MongoDB | mongodb://localhost:27017 |
| Redis | redis://localhost:6379 |
After startup:
- API: `http://localhost:8012`
- Swagger UI: `http://localhost:8012/docs`
**Step 2 — Frontend (Vite dev server, separate terminal):**
### Frontend (Vite dev server)
`cd frontend && npm install && npm run dev`
```bash
cd frontend
npm install
npm run dev
```
Frontend URL: http://localhost:6001/video-accessibility
Frontend runs on `http://localhost:5173` by default.
### Common Local Commands
### Run Migrations
| Action | Command |
|--------|---------|
| Rebuild containers after code change | `./scripts/run-local.sh --rebuild` |
| Stop all services | `./scripts/run-local.sh --stop` |
| Tail all logs | `docker compose logs -f` |
| Tail API logs | `docker compose logs -f api` |
| Tail worker logs | `docker compose logs -f worker` |
| Restart a service | `docker compose restart api` |
```bash
docker exec -it accessible-video-api python migrate.py
```
### Test Credentials (Local Only)
### Create Test Users
| Role | Email | Password |
|------|-------|---------|
| Admin | admin@example.com | admin |
| Reviewer | reviewer@example.com | reviewer |
| Client | client@example.com | client123 |
Production uses Microsoft SSO — these credentials do not work in production.
```bash
docker exec -it accessible-video-api python create_test_users.py
```
---
## Production Deployment
## 2. Deployment (optical-web-1)
**Server:** optical-web-1
**Deploy path:** `/opt/video-accessibility/`
**URL:** https://ai-sandbox.oliver.solutions/video-accessibility/
> **RULE:** Never SSH into optical-web-1 or run commands on it without explicit user instruction.
### Full Deployment (code + frontend)
### Deploy Script
Run on server (requires explicit user instruction — NEVER run via SSH without user approval):
```bash
./scripts/deploy-dev.sh
```
`./scripts/full-deploy.sh`
### Frontend Build
This script:
```bash
./scripts/build-frontend.sh
```
| Step | Action |
|------|--------|
| 1 | Pull latest code from git |
| 2 | Build Docker images |
| 3 | Restart containers |
| 4 | Build frontend bundle |
| 5 | Copy bundle to Apache webroot |
| 6 | Run DB seed if needed |
Builds the React SPA and copies `dist/` to the nginx serving directory.
### Frontend-Only Deployment
### Production Environment File
`./scripts/build-frontend.sh`
Production uses the `.env` file on optical-web-1. Key differences from `.env.example`:
Builds the React bundle and copies to `/var/www/html/video-accessibility/`.
### Verification After Deploy
| Check | Command / URL |
|-------|--------------|
| API health | `curl https://ai-sandbox.oliver.solutions/video-accessibility-back/health` |
| Container status | `docker compose ps` |
| Frontend loads | Visit https://ai-sandbox.oliver.solutions/video-accessibility |
| Worker running | `docker compose logs --tail=20 worker` |
| Variable | Production value |
|----------|-----------------|
| `APP_ENV` | `production` |
| `COOKIE_SECURE` | `true` |
| `COOKIE_DOMAIN` | `ai-sandbox.oliver.solutions` |
| All API keys | Real secret values |
---
## Database Operations
## 3. Service Operations
### Backup MongoDB
### View Logs
| Step | Command |
|------|---------|
| Dump to container | `docker compose exec mongodb mongodump --out=/data/backup` |
| Copy to host | `docker cp accessible-video-mongodb:/data/backup ./mongodb-backup-$(date +%Y%m%d)` |
```bash
docker logs accessible-video-api -f --tail=100
docker logs accessible-video-worker -f --tail=100
docker logs accessible-video-tts-worker -f --tail=100
docker logs accessible-video-ffmpeg-worker -f --tail=100
docker logs accessible-video-whisper-worker -f --tail=100
```
### Restore MongoDB
### Restart a Single Service
| Step | Command |
|------|---------|
| Copy to container | `docker cp ./mongodb-backup accessible-video-mongodb:/data/restore` |
| Restore | `docker compose exec mongodb mongorestore /data/restore` |
```bash
docker compose restart api
docker compose restart worker
docker compose restart tts-worker
docker compose restart ffmpeg-worker
docker compose restart whisper-worker
```
### MongoDB Shell
### Restart All Services
`docker compose exec mongodb mongosh`
```bash
docker compose down && docker compose up -d
```
### Rebuild a Single Service
```bash
docker compose build api && docker compose up -d api
docker compose build worker && docker compose up -d worker
```
### Check Running Services
```bash
docker compose ps
```
### Check Queue Depths
```bash
# Via API (requires admin token)
GET /api/v1/production/queue-stats
# Via Redis CLI
docker exec -it accessible-video-redis redis-cli llen celery
```
---
## Restarting Services
## 4. Troubleshooting
| Action | Command |
|--------|---------|
| Restart all | `docker compose restart` |
| Restart API only | `docker compose restart api` |
| Restart worker only | `docker compose restart worker` |
| Rebuild + restart one service | `docker compose up -d --build api` |
### TTS Worker Crash Loop (Memory)
**Symptom:** `tts-worker` container restarts; OOM errors in logs.
**Cause:** `TTS_WORKER_CONCURRENCY` × per-process memory exceeds available RAM.
**Fix:** Lower `TTS_WORKER_CONCURRENCY` in `.env` (recommended: 2 for 512 MB containers), then:
```bash
docker compose stop tts-worker
# edit .env: TTS_WORKER_CONCURRENCY=2
docker compose up -d tts-worker
```
### Whisper Worker OOM
**Symptom:** `whisper-worker` killed with exit code 137.
**Cause:** Whisper `large-v3` requires ~46 GB RAM; container limit is 8 GB.
**Fix:** Ensure host has sufficient free RAM, or switch to Cloud Run mode via `WHISPER_SERVICE_URL`.
### Stuck Jobs
**Symptom:** Job stays in `ingesting` or `ai_processing` indefinitely.
**Steps:**
1. Check worker logs for errors
2. Admin API: `POST /api/v1/admin/maintenance/reprocess-job/{job_id}`
3. Or: `POST /api/v1/jobs/{job_id}/retry`
### MongoDB Connection Failure
**Symptom:** API returns 500; logs show `ServerSelectionTimeoutError`.
**Steps:**
1. `docker compose ps` — check mongodb container status
2. `docker logs accessible-video-mongodb --tail=50`
3. Confirm `MONGODB_URI` in `.env` matches the running container
### Redis Connection Failure
**Symptom:** Celery tasks not executing; `redis.exceptions.ConnectionError` in logs.
**Steps:**
1. `docker exec -it accessible-video-redis redis-cli ping` — should return `PONG`
2. `docker compose restart redis`
3. `docker compose restart worker tts-worker ffmpeg-worker whisper-worker`
### GCS Access Denied
**Symptom:** `403 Forbidden` from GCS; files not uploading.
**Steps:**
1. Verify `secrets/gcp-credentials.json` exists and is bind-mounted
2. Confirm service account has `Storage Object Admin` on `GCS_BUCKET`
3. Check `GCP_PROJECT_ID` and `GCS_BUCKET` in `.env`
### Celery Worker Not Processing Queue
**Symptom:** Jobs queued but workers idle.
**Steps:**
1. `docker compose ps` — check worker containers running
2. Check worker logs for import errors at startup
3. Verify `CELERY_BROKER_URL` resolves to Redis within the compose network
---
## Updating Application
### WebSocket Disconnects / Reconnect Storms (optical-web-1)
| Step | Command |
|------|---------|
| Pull code | `git pull origin main` |
| Full redeploy | `./scripts/full-deploy.sh` |
| Frontend only | `./scripts/build-frontend.sh` |
**Symptom:** Users experience frequent WebSocket disconnections followed by rapid reconnect attempts visible in browser DevTools Network tab.
**Root cause:** Apache `mod_proxy_wstunnel` on optical-web-1 has a `ProxyTimeout` that drops idle WebSocket connections. The client ping interval (20 s) and server keepalive frame (20 s) are designed to prevent this, but only if Apache's timeout is above 20 s.
**Recommended Apache config** (verify with DevOps before applying):
```apache
# In the VirtualHost block for the API
ProxyTimeout 60
```
> **Do not set ProxyTimeout below 30 s.** The Mod Comms 2026-03-18 incident showed that 25 s was insufficient through mod_proxy_wstunnel — the idle timer fires on the _proxy_ side before the client ping arrives. 60 s provides a comfortable margin above the 20 s bidirectional keepalive cadence.
**Verification after change:**
1. Open DevTools → Network → WS tab
2. Connect to any job and let it sit idle for 2 minutes
3. Confirm no `close` frames and no reconnect attempts appear
---
## Linting and Type Checking
## 5. Environment Variables
| Check | Command | Must pass before deploy |
|-------|---------|------------------------|
| Backend lint | `cd backend && ruff check .` | Yes |
| Backend type check | `docker compose exec api python -m mypy app/` | Yes |
| Frontend lint | `cd frontend && npm run lint` | Yes |
| Frontend type check | `cd frontend && npm run type-check` | Yes (currently 0 errors) |
Copy from `.env.example`. All variables are required unless marked optional.
| Variable | Default | Required | Description |
|----------|---------|----------|-------------|
| `APP_ENV` | `dev` | Yes | `dev` or `production` |
| `API_BASE_URL` | — | Yes | Public API base URL |
| `JWT_SECRET` | — | **Yes** | Random secret; rotation invalidates all sessions |
| `JWT_ALG` | `HS256` | No | JWT signing algorithm |
| `JWT_ACCESS_TTL_MIN` | `240` | No | Access token TTL (minutes) |
| `JWT_REFRESH_TTL_DAYS` | `7` | No | Refresh token TTL (days) |
| `COOKIE_DOMAIN` | `ai-sandbox.oliver.solutions` | Yes | Refresh cookie domain |
| `COOKIE_SECURE` | `true` | No | Set `false` for local HTTP |
| `COOKIE_SAMESITE` | `Lax` | No | |
| `MONGODB_URI` | — | Yes | MongoDB connection string |
| `MONGODB_DB` | `accessible_video` | No | Database name |
| `REDIS_URL` | `redis://redis:6379/0` | Yes | |
| `CELERY_BROKER_URL` | `redis://redis:6379/0` | Yes | Same as REDIS_URL |
| `CELERY_RESULT_BACKEND` | `redis://redis:6379/0` | Yes | |
| `GCP_PROJECT_ID` | — | Yes | GCP project ID |
| `GCS_BUCKET` | `accessible-video` | Yes | GCS bucket name |
| `GOOGLE_APPLICATION_CREDENTIALS` | `/secrets/gcp-credentials.json` | Yes | Path to service account JSON |
| `GEMINI_API_KEY` | — | Yes | Gemini 2.5 Pro API key |
| `TRANSLATE_API_KEY` | — | No | Google Translate API key |
| `ELEVENLABS_API_KEY` | — | No | ElevenLabs API key |
| `GOOGLE_TTS_CREDENTIALS` | `/secrets/gcp-credentials.json` | No | Separate TTS credentials if needed |
| `SENDGRID_API_KEY` | — | No | SendGrid API key |
| `EMAIL_FROM` | `noreply@ai-sandbox.oliver.solutions` | No | Sender address |
| `CLIENT_BASE_URL` | — | No | Frontend URL for email links |
| `AZURE_CLIENT_ID` | — | No | Microsoft SSO client ID |
| `AZURE_AUTHORITY` | — | No | Microsoft tenant authority URL |
| `AZURE_REDIRECT_URI` | — | No | Microsoft OIDC redirect URI |
| `CORS_ORIGINS` | localhost variants | Yes | Comma-separated allowed origins |
| `SENTRY_DSN` | — | No | Sentry DSN |
| `OTEL_EXPORTER_OTLP_ENDPOINT` | — | No | OpenTelemetry collector endpoint |
| `COST_TRACKER_BASE_URL` | — | No | AI cost tracker API URL |
| `COST_TRACKER_API_KEY` | — | No | AI cost tracker API key |
| `COST_TRACKER_SOURCE_APP` | `video-accessibility` | No | App identifier |
| `COST_TRACKER_ENABLED` | `true` | No | Enable/disable cost tracking |
| `WORKER_CONCURRENCY` | `8` | No | General worker concurrency |
| `TTS_WORKER_CONCURRENCY` | `2` | No | TTS worker concurrency |
| `FFMPEG_WORKER_CONCURRENCY` | `1` | No | FFmpeg worker concurrency |
| `WHISPER_WORKER_CONCURRENCY` | `1` | No | Whisper worker concurrency |
| `FFMPEG_SERVICE_URL` | — | No | Cloud Run FFmpeg service URL |
| `WHISPER_SERVICE_URL` | — | No | Cloud Run Whisper service URL |
| `WHISPER_MODEL` | `medium` | No | Whisper model size |
| `USE_CELERY_FALLBACK` | `false` | No | Force local Celery instead of Cloud Run |
---
## Monitoring
## 6. Rollback
| Tool | Access | Purpose |
|------|--------|---------|
| Docker stats | `docker stats` | Container CPU/memory usage |
| API logs | `docker compose logs -f api` | Request errors |
| Worker logs | `docker compose logs -f worker` | Task errors |
| Sentry | sentry.io | Exception capture + stack traces |
| Prometheus | localhost:8001/metrics | Metrics (internal only) |
### Code Rollback
---
Check out the previous commit and rebuild:
## Troubleshooting
```bash
git log --oneline -10
git checkout <previous-commit>
docker compose build && docker compose up -d
```
| Symptom | Check | Fix |
|---------|-------|-----|
| 502 Bad Gateway on API | `docker compose ps api` + logs | Restart: `docker compose restart api` |
| Frontend 404 | `ls /var/www/html/video-accessibility/` | Rebuild: `./scripts/build-frontend.sh` |
| WebSocket fails | `apache2ctl -M | grep proxy_wstunnel` | `sudo a2enmod proxy_wstunnel && sudo systemctl restart apache2` |
| Worker not processing | `docker compose logs -f worker` | Check Redis URL + GCP credentials mount |
| Upload fails (GCS) | Test credentials in container | Check `./secrets/gcp-credentials.json` exists + permissions |
| MongoDB auth fails | Check `MONGODB_URI` env var | Verify Atlas connection string |
### JWT Secret Rotation
---
## Apache Configuration
Required modules:
`sudo a2enmod rewrite proxy proxy_http proxy_wstunnel headers && sudo systemctl restart apache2`
Config file: `/etc/apache2/sites-available/ai-sandbox.oliver.solutions-ssl.conf`
Key directives needed:
| Directive | Purpose |
|-----------|---------|
| `Alias /video-accessibility /var/www/html/video-accessibility` | Serve frontend |
| `ProxyPass /video-accessibility-back http://localhost:8000` | Proxy API |
| `RewriteRule ^ /video-accessibility/index.html [L]` | SPA routing |
| `RewriteEngine On` with WebSocket rules | WS proxy |
1. Generate: `openssl rand -hex 32`
2. Update `JWT_SECRET` in `.env`
3. `docker compose restart api`
4. All existing sessions are invalidated — users must re-login
---
## Maintenance
**Update triggers:** New deploy script, new service port, new server.
**Verification:** All commands in this runbook execute without error on a clean checkout. Test credentials are not committed to production env files.
**Last Updated:** 2026-05-01
<!-- END SCOPE: runbook -->
**Update Triggers:**
- New script added to `scripts/`
- Deployment target changes
- New environment variable required
- New Docker service added
**Verification:**
- [ ] `./scripts/run-local.sh` flags match actual script
- [ ] Environment variable table complete vs `.env.example`
- [ ] Worker env var names match `docker-compose.yml`
- [ ] Troubleshooting container names match compose service names

View file

@ -27,6 +27,7 @@ import { BriefsList } from './routes/briefs/BriefsList';
import { NewBrief } from './routes/briefs/NewBrief';
import { BriefDetail } from './routes/briefs/BriefDetail';
import { LinguistQueue } from './routes/jobs/LinguistQueue';
import { ReviewerQueue } from './routes/jobs/ReviewerQueue';
import { Downloads } from './routes/Downloads';
import { ShareView } from './routes/ShareView';
import { AcceptInvite } from './routes/AcceptInvite';
@ -195,17 +196,23 @@ function AppContent() {
} />
<Route path="/briefs" element={
<AuthenticatedRoute>
<BriefsList />
<RoleGate allowedRoles={['project_manager', 'admin', 'production']}>
<BriefsList />
</RoleGate>
</AuthenticatedRoute>
} />
<Route path="/briefs/new" element={
<AuthenticatedRoute>
<NewBrief />
<RoleGate allowedRoles={['project_manager', 'admin', 'production']}>
<NewBrief />
</RoleGate>
</AuthenticatedRoute>
} />
<Route path="/briefs/:id" element={
<AuthenticatedRoute>
<BriefDetail />
<RoleGate allowedRoles={['project_manager', 'admin', 'production']}>
<BriefDetail />
</RoleGate>
</AuthenticatedRoute>
} />
<Route path="/qc/queue" element={
@ -215,6 +222,13 @@ function AppContent() {
</RoleGate>
</AuthenticatedRoute>
} />
<Route path="/qc/reviewer-queue" element={
<AuthenticatedRoute>
<RoleGate allowedRoles={['reviewer', 'admin']}>
<ReviewerQueue />
</RoleGate>
</AuthenticatedRoute>
} />
<Route path="/downloads/:id" element={
<AuthenticatedRoute>
<Downloads />

View file

@ -74,6 +74,12 @@ export function Sidebar({ onMobileClose }: SidebarProps) {
roles: ['linguist', 'reviewer', 'production', 'admin'],
badge: qcBadge || undefined,
},
{
label: 'Reviewer Queue',
href: '/qc/reviewer-queue',
icon: '🔎',
roles: ['reviewer', 'admin'],
},
{
label: 'QC Review',
href: '/admin/qc',

View file

@ -90,11 +90,17 @@ interface VttEditorProps {
readOnly?: boolean;
glossaryTerms?: GlossaryTerm[];
language?: string;
insertAtTimeMs?: number | null; // when set, auto-insert a cue at/near this timestamp
onInsertAtTimeDone?: () => void; // callback to clear insertAtTimeMs after insert
insertAtTimeMs?: number | null;
onInsertAtTimeDone?: () => void;
/** True when this editor is displaying the source language VTT */
isSourceLanguage?: boolean;
/** Number of target languages that would have QC reset on source save */
affectedLanguagesCount?: number;
/** Called when user clicks "Re-translate all" — triggers retranslate_languages=true save */
onRetranslate?: () => Promise<void>;
}
export function VttEditor({ vttContent, onChange, onCueSave, onCueInserted, onCueDeleted, onCuePlay, title, readOnly = false, glossaryTerms = [], language = 'en', insertAtTimeMs, onInsertAtTimeDone }: VttEditorProps) {
export function VttEditor({ vttContent, onChange, onCueSave, onCueInserted, onCueDeleted, onCuePlay, title, readOnly = false, glossaryTerms = [], language = 'en', insertAtTimeMs, onInsertAtTimeDone, isSourceLanguage = false, affectedLanguagesCount = 0, onRetranslate }: VttEditorProps) {
const [cues, setCues] = useState<VTTCue[]>([]);
const [errors, setErrors] = useState<string[]>([]);
const [editingCue, setEditingCue] = useState<number | null>(null);
@ -275,8 +281,37 @@ export function VttEditor({ vttContent, onChange, onCueSave, onCueInserted, onCu
const totalErrorCount = Array.from(cueErrors.values()).reduce((sum, errs) => sum + errs.length, 0);
const [retranslating, setRetranslating] = useState(false);
const handleRetranslate = async () => {
if (!onRetranslate) return;
setRetranslating(true);
try {
await onRetranslate();
} finally {
setRetranslating(false);
}
};
return (
<div className="border border-gray-300 rounded-lg">
{/* Source-language invalidation banner */}
{isSourceLanguage && !readOnly && affectedLanguagesCount > 0 && (
<div className="flex items-center justify-between gap-3 px-4 py-2.5 bg-amber-50 border-b border-amber-200 rounded-t-lg">
<p className="text-sm text-amber-800">
Saving will reset QC for <strong>{affectedLanguagesCount}</strong> translated {affectedLanguagesCount === 1 ? 'language' : 'languages'}.
</p>
{onRetranslate && (
<button
onClick={handleRetranslate}
disabled={retranslating}
className="flex-shrink-0 px-3 py-1 text-xs font-medium bg-amber-600 text-white rounded hover:bg-amber-700 disabled:opacity-50"
>
{retranslating ? 'Translating…' : 'Re-translate all'}
</button>
)}
</div>
)}
{/* Header */}
<div className="bg-gray-50 px-4 py-3 border-b border-gray-300">
<div className="flex items-center justify-between">

View file

@ -20,24 +20,30 @@ export function GlobalWebSocketProvider({ children }: { children: ReactNode }) {
const handleStatusUpdate = useCallback((update: JobStatusUpdate) => {
// Use job_title from the update, or fallback to generic message
const jobTitle = update.job_title || 'Job';
const { message, type, showToast } = getStatusMessageConfig(
update.status,
jobTitle,
update.message
);
if (showToast) {
// Pass job_id and job_title to toast for notification integration
toast[type](message, undefined, update.job_id, update.job_title);
}
}, [toast]);
const handleTerminalClose = useCallback((code: number, reason: string) => {
const label = reason || (code === 4403 ? 'Org access denied' : code === 4001 ? 'User not found' : 'Access denied');
toast.error(`Connection closed: ${label}`);
}, [toast]);
// Use the existing WebSocket hook for global job list updates (no jobId)
const { connectionStatus, reconnect, disconnect } = useJobStatusWebSocket(undefined, {
debug: false,
autoReconnect: true,
onStatusUpdate: handleStatusUpdate
onStatusUpdate: handleStatusUpdate,
onTerminalClose: handleTerminalClose,
});
const contextValue: GlobalWebSocketContextType = {

View file

@ -0,0 +1,236 @@
/**
* Heartbeat interval tests for useJobStatusWebSocket.
*
* Verifies that the client ping is sent every 20 000 ms (not 30 000 ms)
* to stay under the Apache mod_proxy_wstunnel idle timeout.
* Mod Comms incident 2026-03-18: 25 s was insufficient; 20 s is safe.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useJobStatusWebSocket } from '../useJobStatusWebSocket';
// ── Mocks ────────────────────────────────────────────────────────────────────
vi.mock('../../lib/auth', () => ({
useAuthStore: () => ({ isAuthenticated: true }),
}));
vi.mock('../../lib/api', () => ({
apiClient: { getAccessToken: () => 'test-token' },
}));
vi.mock('@tanstack/react-query', () => ({
useQueryClient: () => ({
invalidateQueries: vi.fn(),
}),
}));
class MockWebSocket {
static CONNECTING = 0;
static OPEN = 1;
static CLOSING = 2;
static CLOSED = 3;
readyState = MockWebSocket.CONNECTING;
private handlers: Map<string, EventListener[]> = new Map();
send = vi.fn();
close = vi.fn(() => { this.readyState = MockWebSocket.CLOSED; });
addEventListener(event: string, handler: EventListener) {
if (!this.handlers.has(event)) this.handlers.set(event, []);
this.handlers.get(event)!.push(handler);
}
removeEventListener(event: string, handler: EventListener) {
const list = this.handlers.get(event) ?? [];
this.handlers.set(event, list.filter(h => h !== handler));
}
fireOpen() {
this.readyState = MockWebSocket.OPEN;
this.handlers.get('open')?.forEach(h => h(new Event('open')));
}
fireClose(code: number, reason = '') {
this.readyState = MockWebSocket.CLOSED;
const event = new CloseEvent('close', { code, reason, wasClean: true });
this.handlers.get('close')?.forEach(h => h(event));
}
fireMessage(data: string) {
const event = new MessageEvent('message', { data });
this.handlers.get('message')?.forEach(h => h(event));
}
}
let mockWsInstance: MockWebSocket;
function makeWsConstructor() {
const ctor = vi.fn(() => mockWsInstance) as unknown as typeof WebSocket;
Object.assign(ctor, {
CONNECTING: 0,
OPEN: 1,
CLOSING: 2,
CLOSED: 3,
});
return ctor;
}
beforeEach(() => {
vi.useFakeTimers();
mockWsInstance = new MockWebSocket();
vi.stubGlobal('WebSocket', makeWsConstructor());
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
vi.clearAllMocks();
});
// ── Heartbeat interval ────────────────────────────────────────────────────────
describe('heartbeat interval', () => {
it('sends ping after exactly 20 000 ms', () => {
const { unmount } = renderHook(() => useJobStatusWebSocket());
act(() => { mockWsInstance.fireOpen(); });
mockWsInstance.send.mockClear();
act(() => { vi.advanceTimersByTime(20_000); });
expect(mockWsInstance.send).toHaveBeenCalledWith('ping');
expect(mockWsInstance.send).toHaveBeenCalledTimes(1);
unmount();
});
it('sends second ping after 40 000 ms total', () => {
const { unmount } = renderHook(() => useJobStatusWebSocket());
act(() => { mockWsInstance.fireOpen(); });
mockWsInstance.send.mockClear();
act(() => { vi.advanceTimersByTime(40_000); });
// Two pings: at t=20 000 ms and t=40 000 ms
expect(mockWsInstance.send).toHaveBeenCalledWith('ping');
expect(mockWsInstance.send).toHaveBeenCalledTimes(2);
unmount();
});
it('does NOT send ping before 20 000 ms', () => {
const { unmount } = renderHook(() => useJobStatusWebSocket());
act(() => { mockWsInstance.fireOpen(); });
mockWsInstance.send.mockClear();
act(() => { vi.advanceTimersByTime(19_999); });
expect(mockWsInstance.send).not.toHaveBeenCalled();
unmount();
});
it('does not send ping when socket is not OPEN', () => {
const { unmount } = renderHook(() => useJobStatusWebSocket());
act(() => { mockWsInstance.fireOpen(); });
mockWsInstance.send.mockClear();
// Force socket into a non-OPEN state before interval fires
mockWsInstance.readyState = MockWebSocket.CLOSING;
act(() => { vi.advanceTimersByTime(20_000); });
expect(mockWsInstance.send).not.toHaveBeenCalled();
unmount();
});
it('interval is cleared on manual disconnect', () => {
const clearIntervalSpy = vi.spyOn(globalThis, 'clearInterval');
const { result, unmount } = renderHook(() => useJobStatusWebSocket());
act(() => { mockWsInstance.fireOpen(); });
const callsBefore = clearIntervalSpy.mock.calls.length;
act(() => { result.current.disconnect(); });
// clearInterval must have been called after disconnect
expect(clearIntervalSpy.mock.calls.length).toBeGreaterThan(callsBefore);
unmount();
});
it('interval is cleared after terminal close', () => {
const clearIntervalSpy = vi.spyOn(globalThis, 'clearInterval');
const { unmount } = renderHook(() => useJobStatusWebSocket());
act(() => { mockWsInstance.fireOpen(); });
const callsBefore = clearIntervalSpy.mock.calls.length;
act(() => { mockWsInstance.fireClose(4403, 'Org denied'); });
expect(clearIntervalSpy.mock.calls.length).toBeGreaterThan(callsBefore);
unmount();
});
});
// ── Keepalive / pong frames silently discarded ────────────────────────────────
describe('server heartbeat frames', () => {
it('silently discards "keepalive" frame — lastMessage stays null', () => {
const { result, unmount } = renderHook(() => useJobStatusWebSocket());
act(() => { mockWsInstance.fireOpen(); });
act(() => { mockWsInstance.fireMessage('keepalive'); });
expect(result.current.lastMessage).toBeNull();
expect(result.current.lastUpdate).toBeNull();
unmount();
});
it('silently discards "pong" frame', () => {
const { result, unmount } = renderHook(() => useJobStatusWebSocket());
act(() => { mockWsInstance.fireOpen(); });
act(() => { mockWsInstance.fireMessage('pong'); });
expect(result.current.lastMessage).toBeNull();
expect(result.current.lastUpdate).toBeNull();
unmount();
});
it('processes JSON job_status_update message normally', () => {
const { result, unmount } = renderHook(() => useJobStatusWebSocket());
act(() => { mockWsInstance.fireOpen(); });
const statusMsg = JSON.stringify({
type: 'job_status_update',
data: {
job_id: 'job-1',
status: 'pending_qc',
updated_at: '2026-01-01T00:00:00Z',
},
});
act(() => { mockWsInstance.fireMessage(statusMsg); });
expect(result.current.lastMessage).not.toBeNull();
expect(result.current.lastMessage?.type).toBe('job_status_update');
expect(result.current.lastUpdate?.job_id).toBe('job-1');
expect(result.current.lastUpdate?.status).toBe('pending_qc');
unmount();
});
});

View file

@ -0,0 +1,205 @@
/**
* Terminal close code tests for useJobStatusWebSocket.
*
* Verifies that codes 4001/4003/4004/4403 are treated as permanent failures
* and do NOT trigger reconnect preventing reconnect storms on auth/org failures.
* Mod Comms incident 2026-03-18 and org-isolation MT-7.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useJobStatusWebSocket } from '../useJobStatusWebSocket';
// ── Mocks ────────────────────────────────────────────────────────────────────
vi.mock('../../lib/auth', () => ({
useAuthStore: () => ({ isAuthenticated: true }),
}));
vi.mock('../../lib/api', () => ({
apiClient: { getAccessToken: () => 'test-token' },
}));
vi.mock('@tanstack/react-query', () => ({
useQueryClient: () => ({
invalidateQueries: vi.fn(),
}),
}));
// Minimal WebSocket mock with static constants required by the hook's guard checks.
class MockWebSocket {
static CONNECTING = 0;
static OPEN = 1;
static CLOSING = 2;
static CLOSED = 3;
readyState = MockWebSocket.CONNECTING;
private handlers: Map<string, EventListener[]> = new Map();
send = vi.fn();
close = vi.fn(() => { this.readyState = MockWebSocket.CLOSED; });
addEventListener(event: string, handler: EventListener) {
if (!this.handlers.has(event)) this.handlers.set(event, []);
this.handlers.get(event)!.push(handler);
}
removeEventListener(event: string, handler: EventListener) {
const list = this.handlers.get(event) ?? [];
this.handlers.set(event, list.filter(h => h !== handler));
}
fireOpen() {
this.readyState = MockWebSocket.OPEN;
this.handlers.get('open')?.forEach(h => h(new Event('open')));
}
fireClose(code: number, reason = '') {
this.readyState = MockWebSocket.CLOSED;
const event = new CloseEvent('close', { code, reason, wasClean: true });
this.handlers.get('close')?.forEach(h => h(event));
}
}
let mockWsInstance: MockWebSocket;
function makeWsConstructor() {
const ctor = vi.fn(() => mockWsInstance) as unknown as typeof WebSocket;
// Hook uses WebSocket.CONNECTING / WebSocket.OPEN / etc. as class statics
Object.assign(ctor, {
CONNECTING: 0,
OPEN: 1,
CLOSING: 2,
CLOSED: 3,
});
return ctor;
}
beforeEach(() => {
vi.useFakeTimers();
mockWsInstance = new MockWebSocket();
vi.stubGlobal('WebSocket', makeWsConstructor());
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
vi.clearAllMocks();
});
// ── Terminal close codes ──────────────────────────────────────────────────────
describe('terminal close codes — no reconnect', () => {
const TERMINAL_CODES = [4001, 4003, 4004, 4403] as const;
for (const code of TERMINAL_CODES) {
it(`code ${code} does not schedule a reconnect`, () => {
const setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout');
const { unmount } = renderHook(() =>
useJobStatusWebSocket(undefined, { autoReconnect: true, maxReconnectAttempts: 15 })
);
// Let initial connection open
act(() => { mockWsInstance.fireOpen(); });
// Clear any setTimeout calls from connection setup
setTimeoutSpy.mockClear();
// Simulate terminal close
act(() => { mockWsInstance.fireClose(code, 'Access denied'); });
// Advance timers — reconnect would fire here if incorrectly scheduled
act(() => { vi.advanceTimersByTime(30_000); });
// Filter out short-lived timers unrelated to reconnect (< 500ms)
const reconnectCalls = setTimeoutSpy.mock.calls.filter(
([, delay]) => typeof delay === 'number' && (delay as number) >= 500
);
expect(reconnectCalls).toHaveLength(0);
unmount();
});
}
it('code 1000 (normal close) does not reconnect', () => {
const setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout');
const { unmount } = renderHook(() =>
useJobStatusWebSocket(undefined, { autoReconnect: true })
);
act(() => { mockWsInstance.fireOpen(); });
setTimeoutSpy.mockClear();
act(() => { mockWsInstance.fireClose(1000, 'Normal closure'); });
act(() => { vi.advanceTimersByTime(30_000); });
const reconnectCalls = setTimeoutSpy.mock.calls.filter(
([, delay]) => typeof delay === 'number' && (delay as number) >= 500
);
expect(reconnectCalls).toHaveLength(0);
unmount();
});
it('code 1006 (abnormal close) DOES trigger reconnect when autoReconnect=true', () => {
const setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout');
const { unmount } = renderHook(() =>
useJobStatusWebSocket(undefined, { autoReconnect: true, reconnectDelay: 1000 })
);
act(() => { mockWsInstance.fireOpen(); });
setTimeoutSpy.mockClear();
// 1006 = abnormal/unexpected disconnect — should reconnect
act(() => { mockWsInstance.fireClose(1006, 'Connection lost'); });
const reconnectCalls = setTimeoutSpy.mock.calls.filter(
([, delay]) => typeof delay === 'number' && (delay as number) >= 500
);
expect(reconnectCalls.length).toBeGreaterThan(0);
unmount();
});
});
// ── Status transitions ────────────────────────────────────────────────────────
describe('connection status transitions', () => {
it('is "connecting" before open, "connected" after open', () => {
const { result, unmount } = renderHook(() => useJobStatusWebSocket());
// After mount and before fireOpen, hook is in 'connecting' state
// (set synchronously in connect())
expect(['connecting', 'disconnected']).toContain(result.current.connectionStatus);
act(() => { mockWsInstance.fireOpen(); });
expect(result.current.connectionStatus).toBe('connected');
unmount();
});
it('sets status to disconnected after 4403 close', () => {
const { result, unmount } = renderHook(() => useJobStatusWebSocket());
act(() => { mockWsInstance.fireOpen(); });
expect(result.current.connectionStatus).toBe('connected');
act(() => { mockWsInstance.fireClose(4403, 'Org access denied'); });
expect(result.current.connectionStatus).toBe('disconnected');
unmount();
});
it('sets status to disconnected after 4001 close', () => {
const { result, unmount } = renderHook(() => useJobStatusWebSocket());
act(() => { mockWsInstance.fireOpen(); });
act(() => { mockWsInstance.fireClose(4001, 'User not found'); });
expect(result.current.connectionStatus).toBe('disconnected');
unmount();
});
});

View file

@ -72,6 +72,12 @@ interface UseJobStatusWebSocketOptions {
* Raw message handler called for every parsed WS message regardless of type
*/
onRawMessage?: (msg: WebSocketMessage) => void;
/**
* Called when the WebSocket closes with a terminal code (1000, 4001, 4003, 4004, 4403).
* Use this to surface the close reason to the user.
*/
onTerminalClose?: (code: number, reason: string) => void;
}
interface UseJobStatusWebSocketReturn {
@ -112,6 +118,7 @@ export function useJobStatusWebSocket(
onStatusUpdate,
onConnectionChange,
onRawMessage,
onTerminalClose,
} = options;
const queryClient = useQueryClient();
@ -226,9 +233,9 @@ export function useJobStatusWebSocket(
const handleMessage = useCallback((event: MessageEvent) => {
try {
// Handle plain text responses (like "pong" heartbeat responses)
if (typeof event.data === 'string' && event.data === 'pong') {
log('Received heartbeat response:', event.data);
// Silently discard server heartbeat/keepalive frames
if (typeof event.data === 'string' && (event.data === 'pong' || event.data === 'keepalive')) {
log('Received heartbeat frame:', event.data);
return;
}
@ -257,12 +264,13 @@ export function useJobStatusWebSocket(
handleConnectionChange('connected');
reconnectAttemptsRef.current = 0;
// Start heartbeat
// Start heartbeat — must be < Apache mod_proxy_wstunnel idle timeout.
// Mod Comms incident 2026-03-18: 25 s was insufficient; 20 s is safe.
heartbeatIntervalRef.current = setInterval(() => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send('ping');
}
}, 30000); // Ping every 30 seconds
}, 20000);
}, [log, handleConnectionChange]);
const handleClose = useCallback((event: CloseEvent) => {
@ -276,12 +284,19 @@ export function useJobStatusWebSocket(
heartbeatIntervalRef.current = null;
}
// Terminal codes = permanent auth/permission failure; do NOT retry.
// 4001=user not found, 4003=role denied, 4004=job not found, 4403=org denied.
const isTerminal = [1000, 4001, 4003, 4004, 4403].includes(event.code);
if (isTerminal && event.code !== 1000 && onTerminalClose) {
onTerminalClose(event.code, event.reason);
}
// Attempt to reconnect if enabled and component is still mounted
if (
autoReconnect &&
mountedRef.current &&
autoReconnect &&
mountedRef.current &&
reconnectAttemptsRef.current < maxReconnectAttempts &&
event.code !== 1000 // Don't reconnect on normal closure
!isTerminal
) {
const delay = Math.min(reconnectDelay * Math.pow(2, reconnectAttemptsRef.current), 60000);
log(`Reconnecting in ${delay}ms (attempt ${reconnectAttemptsRef.current + 1}/${maxReconnectAttempts})`);
@ -293,7 +308,7 @@ export function useJobStatusWebSocket(
}
}, delay);
}
}, [log, autoReconnect, maxReconnectAttempts, reconnectDelay, handleConnectionChange]);
}, [log, autoReconnect, maxReconnectAttempts, reconnectDelay, handleConnectionChange, onTerminalClose]);
const handleError = useCallback((error: Event) => {
console.error('WebSocket error:', error);

View file

@ -11,6 +11,7 @@ export function useUsers(filters?: {
size?: number;
role?: string;
active_only?: boolean;
org_id?: string;
}) {
return useQuery({
queryKey: ['users', filters],

View file

@ -416,12 +416,14 @@ class ApiClient {
size?: number;
role?: string;
active_only?: boolean;
org_id?: string;
}): Promise<UserListResponse> {
const params = new URLSearchParams();
if (filters?.page) params.append('page', filters.page.toString());
if (filters?.size) params.append('size', filters.size.toString());
if (filters?.role) params.append('role', filters.role);
if (filters?.active_only !== undefined) params.append('active_only', filters.active_only.toString());
if (filters?.org_id) params.append('org_id', filters.org_id);
const response = await this.client.get(`/admin/users?${params.toString()}`);
return response.data;

View file

@ -1,6 +1,6 @@
import { Link } from 'react-router-dom';
import { useAuthStore } from '../lib/auth';
import { useJobs, useProductionQueueStats } from '../hooks/useJob';
import { useJobs, useProductionQueueStats, useBriefs } from '../hooks/useJob';
import { StatusBadge } from '../components/StatusBadge';
import type { Job } from '../types/api';
@ -12,9 +12,14 @@ export function Dashboard() {
enabled: isAuthenticated && !!user
});
const { data: queueStats } = useProductionQueueStats();
const { data: briefsData } = useBriefs();
const jobs = jobsData?.jobs || [];
const ACTIVE_STATUSES = ['created', 'ingesting', 'ai_processing', 'translating', 'tts_generating', 'rendering_video', 'rendering_qc', 'pending_qc', 'qc_feedback', 'pending_final_review'];
const now = Date.now();
const MS_24H = 24 * 60 * 60 * 1000;
const stats = {
total: jobs.length,
pending: jobs.filter((j: Job) => ['created', 'ingesting', 'ai_processing', 'translating', 'tts_generating', 'rendering_video', 'rendering_qc'].includes(j.status)).length,
@ -23,6 +28,10 @@ export function Dashboard() {
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,
overdue: jobs.filter((j: Job) => j.deadline && new Date(j.deadline).getTime() < now && !['completed', 'rejected'].includes(j.status)).length,
stuck: jobs.filter((j: Job) => ACTIVE_STATUSES.includes(j.status) && (now - new Date(j.updated_at).getTime()) > MS_24H).length,
awaitingUpload: (briefsData?.briefs ?? []).filter(b => b.status === 'submitted').length,
pendingQcHandoff: jobs.filter((j: Job) => j.status === 'ai_processing' && !(j.language_qc && Object.keys(j.language_qc).length > 0)).length,
};
const renderRoleSpecificContent = () => {
@ -88,7 +97,7 @@ export function Dashboard() {
case 'project_manager':
return (
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="grid grid-cols-1 md:grid-cols-2 xl: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"
@ -134,6 +143,36 @@ export function Dashboard() {
</p>
<p className="text-sm font-semibold text-white/80 group-hover:text-white">Upload now </p>
</Link>
<Link
to="/jobs?overdue=true"
className={`group rounded-2xl p-6 text-white hover:shadow-xl transition-all duration-200 transform hover:-translate-y-0.5 ${stats.overdue > 0 ? 'bg-gradient-to-br from-red-500 to-rose-700' : 'bg-gradient-to-br from-gray-400 to-gray-500'}`}
>
<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">{stats.overdue > 0 ? '⏰' : '✓'}</span>
</div>
<h3 className="text-lg font-bold">Overdue</h3>
</div>
<p className="text-3xl font-bold mb-1">{stats.overdue}</p>
<p className="text-white/80 text-sm mb-4">past deadline, not completed</p>
<p className="text-sm font-semibold text-white/80 group-hover:text-white">View overdue </p>
</Link>
<Link
to="/jobs?stuck=true"
className={`group rounded-2xl p-6 text-white hover:shadow-xl transition-all duration-200 transform hover:-translate-y-0.5 ${stats.stuck > 0 ? 'bg-gradient-to-br from-yellow-500 to-amber-700' : 'bg-gradient-to-br from-gray-400 to-gray-500'}`}
>
<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">{stats.stuck > 0 ? '🐢' : '✓'}</span>
</div>
<h3 className="text-lg font-bold">Stuck &gt; 24h</h3>
</div>
<p className="text-3xl font-bold mb-1">{stats.stuck}</p>
<p className="text-white/80 text-sm mb-4">no progress in over 24 hours</p>
<p className="text-sm font-semibold text-white/80 group-hover:text-white">Investigate </p>
</Link>
</div>
);
@ -162,6 +201,52 @@ export function Dashboard() {
</Link>
</div>
<div className={`bg-gradient-to-br ${stats.awaitingUpload > 0 ? 'from-violet-500 to-purple-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">📥</span>
</div>
<h2 className="text-2xl font-bold">Awaiting Upload</h2>
</div>
<p className="text-white/80 mb-2 text-lg font-semibold">
{stats.awaitingUpload} submitted brief{stats.awaitingUpload !== 1 ? 's' : ''} need video
</p>
<p className="text-white/70 mb-6 leading-relaxed">
Briefs approved by client but video not yet uploaded.
</p>
{stats.awaitingUpload > 0 && (
<Link
to="/briefs?status=submitted"
className="inline-flex items-center bg-white text-purple-600 px-6 py-3 rounded-lg hover:bg-purple-50 transition-all duration-200 font-semibold shadow-lg hover:shadow-xl transform hover:-translate-y-0.5"
>
View briefs
</Link>
)}
</div>
<div className={`bg-gradient-to-br ${stats.pendingQcHandoff > 0 ? 'from-orange-400 to-amber-600' : '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">🔄</span>
</div>
<h2 className="text-2xl font-bold">Pending QC Handoff</h2>
</div>
<p className="text-white/80 mb-2 text-lg font-semibold">
{stats.pendingQcHandoff} job{stats.pendingQcHandoff !== 1 ? 's' : ''} awaiting linguist
</p>
<p className="text-white/70 mb-6 leading-relaxed">
AI processing complete but no linguist assigned yet.
</p>
{stats.pendingQcHandoff > 0 && (
<Link
to="/jobs?status=ai_processing"
className="inline-flex items-center bg-white text-amber-600 px-6 py-3 rounded-lg hover:bg-amber-50 transition-all duration-200 font-semibold shadow-lg hover:shadow-xl transform hover:-translate-y-0.5"
>
Assign now
</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">

View file

@ -1750,6 +1750,23 @@ export function QCDetail() {
readOnly={isProcessing}
glossaryTerms={glossaryTerms}
language={selectedLanguage}
isSourceLanguage={selectedLanguage === sourceLanguage}
affectedLanguagesCount={
selectedLanguage === sourceLanguage
? Object.entries(langQcMap).filter(
([lang, state]) =>
lang !== sourceLanguage &&
['approved', 'pending_review', 'in_review'].includes(
(state as { status?: string }).status ?? ''
)
).length
: 0
}
onRetranslate={
selectedLanguage === sourceLanguage
? async () => { await _doSaveVtt(true, captionsVtt || undefined, adVtt || undefined); }
: undefined
}
/>
<div className="mt-2">
<VttDiffView jobId={id!} lang={selectedLanguage} kind="captions" />
@ -1783,6 +1800,23 @@ export function QCDetail() {
language={selectedLanguage}
insertAtTimeMs={adInsertAtTimeMs}
onInsertAtTimeDone={() => setAdInsertAtTimeMs(null)}
isSourceLanguage={selectedLanguage === sourceLanguage}
affectedLanguagesCount={
selectedLanguage === sourceLanguage
? Object.entries(langQcMap).filter(
([lang, state]) =>
lang !== sourceLanguage &&
['approved', 'pending_review', 'in_review'].includes(
(state as { status?: string }).status ?? ''
)
).length
: 0
}
onRetranslate={
selectedLanguage === sourceLanguage
? async () => { await _doSaveVtt(true, captionsVtt || undefined, adVtt || undefined); }
: undefined
}
/>
<div className="mt-2">
<VttDiffView jobId={id!} lang={selectedLanguage} kind="ad" />

View file

@ -7,6 +7,7 @@ import {
useResetUserPassword,
useCreateUser,
} from '../../hooks/useUsers';
import { useOrganizations } from '../../hooks/useClients';
import { useToastContext } from '../../contexts/ToastContext';
import type { UserRole, CreateUserRequest } from '../../types/api';
@ -14,14 +15,18 @@ export function UserList() {
const [page, setPage] = useState(1);
const [roleFilter, setRoleFilter] = useState<string>('');
const [activeOnly, setActiveOnly] = useState(true);
const [orgFilter, setOrgFilter] = useState<string>('');
const [showCreateModal, setShowCreateModal] = useState(false);
const toast = useToastContext();
const { data: orgs } = useOrganizations();
const { data: usersResponse, isLoading, error } = useUsers({
page,
size: 20,
role: roleFilter || undefined,
active_only: activeOnly,
org_id: orgFilter || undefined,
});
const deactivateUserMutation = useDeactivateUser();
@ -140,6 +145,25 @@ export function UserList() {
</select>
</div>
{orgs && orgs.length > 0 && (
<div className="flex items-center space-x-2">
<label className="text-sm text-gray-700">Org:</label>
<select
value={orgFilter}
onChange={(e) => {
setOrgFilter(e.target.value);
setPage(1);
}}
className="text-sm border border-gray-300 rounded px-3 py-1.5"
>
<option value="">All Orgs</option>
{orgs.map(org => (
<option key={org.id} value={org.id}>{org.name}</option>
))}
</select>
</div>
)}
<div className="flex items-center space-x-2">
<input
type="checkbox"

View file

@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState, useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { formatDistanceToNow } from 'date-fns';
@ -41,6 +41,11 @@ const JOB_STATUS_LABEL: Record<string, string> = {
rejected: 'Rejected',
};
function SortArrow({ active, dir }: { active: boolean; dir: 'asc' | 'desc' }) {
if (!active) return <span className="ml-1 text-gray-300"></span>;
return <span className="ml-1">{dir === 'asc' ? '↑' : '↓'}</span>;
}
function QueueRow({ item, role }: { item: QueueItem; role: 'linguist' | 'reviewer' }) {
const navigate = useNavigate();
const qcStatus = item.lang_qc_status as LanguageQCStatus;
@ -79,9 +84,15 @@ function QueueRow({ item, role }: { item: QueueItem; role: 'linguist' | 'reviewe
);
}
export function LinguistQueue() {
const [activeRole, setActiveRole] = useState<'linguist' | 'reviewer'>('linguist');
interface LinguistQueueProps {
/** Lock the queue to a specific role (hides the role toggle). */
defaultRole?: 'linguist' | 'reviewer';
}
export function LinguistQueue({ defaultRole }: LinguistQueueProps = {}) {
const [activeRole, setActiveRole] = useState<'linguist' | 'reviewer'>(defaultRole ?? 'linguist');
const [activeTab, setActiveTab] = useState<LanguageQCStatus | 'all'>('all');
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc');
const { data, isLoading, refetch } = useQuery({
queryKey: ['linguist-queue', activeRole, activeTab],
@ -92,13 +103,24 @@ export function LinguistQueue() {
refetchInterval: 30_000,
});
const items = data?.items ?? [];
const items = useMemo(() => {
const raw = data?.items ?? [];
return [...raw].sort((a, b) => {
const ta = a.assigned_at ? new Date(a.assigned_at).getTime() : 0;
const tb = b.assigned_at ? new Date(b.assigned_at).getTime() : 0;
return sortDir === 'asc' ? ta - tb : tb - ta;
});
}, [data, sortDir]);
const toggleSort = () => setSortDir(d => (d === 'asc' ? 'desc' : 'asc'));
return (
<div className="p-6 max-w-6xl mx-auto">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-semibold text-gray-900">My QC Queue</h1>
<h1 className="text-2xl font-semibold text-gray-900">
{defaultRole === 'reviewer' ? 'Reviewer Queue' : 'My QC Queue'}
</h1>
<p className="text-sm text-gray-500 mt-1">Languages assigned to you for quality control</p>
</div>
<button
@ -109,22 +131,24 @@ export function LinguistQueue() {
</button>
</div>
{/* Role toggle */}
<div className="flex gap-2 mb-5">
{(['linguist', 'reviewer'] as const).map(role => (
<button
key={role}
onClick={() => { setActiveRole(role); setActiveTab('all'); }}
className={`px-4 py-1.5 text-sm font-medium rounded-full border transition-colors ${
activeRole === role
? 'bg-blue-600 border-blue-600 text-white'
: 'border-gray-300 text-gray-600 hover:border-gray-400 hover:text-gray-800'
}`}
>
As {role}
</button>
))}
</div>
{/* Role toggle — hidden when role is locked */}
{!defaultRole && (
<div className="flex gap-2 mb-5">
{(['linguist', 'reviewer'] as const).map(role => (
<button
key={role}
onClick={() => { setActiveRole(role); setActiveTab('all'); }}
className={`px-4 py-1.5 text-sm font-medium rounded-full border transition-colors ${
activeRole === role
? 'bg-blue-600 border-blue-600 text-white'
: 'border-gray-300 text-gray-600 hover:border-gray-400 hover:text-gray-800'
}`}
>
As {role}
</button>
))}
</div>
)}
{/* Status tabs */}
<div className="flex gap-1 mb-4 border-b border-gray-200 overflow-x-auto">
@ -169,7 +193,12 @@ export function LinguistQueue() {
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Lang</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">QC Status</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Job Status</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Assigned</th>
<th
className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase cursor-pointer select-none hover:text-gray-700"
onClick={toggleSort}
>
Assigned <SortArrow active dir={sortDir} />
</th>
{activeRole === 'reviewer' && (
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Reviewed</th>
)}

View file

@ -0,0 +1,5 @@
import { LinguistQueue } from './LinguistQueue';
export function ReviewerQueue() {
return <LinguistQueue defaultRole="reviewer" />;
}