Merge fix/multi-tenancy-and-english-first into main
This commit is contained in:
commit
c3a42cb5fe
30 changed files with 2360 additions and 388 deletions
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
190
backend/tests/unit/test_admin_users_org_filter.py
Normal file
190
backend/tests/unit/test_admin_users_org_filter.py
Normal 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"]
|
||||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
200
backend/tests/unit/test_language_qc_english_first.py
Normal file
200
backend/tests/unit/test_language_qc_english_first.py
Normal 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
|
||||
97
backend/tests/unit/test_review_notes_org_isolation.py
Normal file
97
backend/tests/unit/test_review_notes_org_isolation.py
Normal 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"
|
||||
321
backend/tests/unit/test_routes_jobs_org_isolation.py
Normal file
321
backend/tests/unit/test_routes_jobs_org_isolation.py
Normal 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
|
||||
94
backend/tests/unit/test_vtt_versions_org_isolation.py
Normal file
94
backend/tests/unit/test_vtt_versions_org_isolation.py
Normal 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"
|
||||
148
backend/tests/unit/test_websocket_org_isolation.py
Normal file
148
backend/tests/unit/test_websocket_org_isolation.py
Normal 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
|
||||
|
|
@ -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 ~4–6 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
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ export function useUsers(filters?: {
|
|||
size?: number;
|
||||
role?: string;
|
||||
active_only?: boolean;
|
||||
org_id?: string;
|
||||
}) {
|
||||
return useQuery({
|
||||
queryKey: ['users', filters],
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 > 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">
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
5
frontend/src/routes/jobs/ReviewerQueue.tsx
Normal file
5
frontend/src/routes/jobs/ReviewerQueue.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { LinguistQueue } from './LinguistQueue';
|
||||
|
||||
export function ReviewerQueue() {
|
||||
return <LinguistQueue defaultRole="reviewer" />;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue