feat: two-stage QC (linguist→reviewer), project picker, comments, email notifications, deadlines
- Two-stage QC workflow: linguist edits + submits → reviewer approves/rejects per language. New statuses: in_progress, pending_review, in_review. New service functions: submit_for_review, open_review, assign_reviewer, reassign_reviewer, add_comment. Linguist and reviewer deadlines. - Reject now resets language to in_progress so linguist can iterate without full re-assignment. - QC comment threads per language (append-only), visible to all assignees. - Email notifications via Mailgun on: assignment, submit-for-review, comment, approve, reject. Best-effort (failures do not roll back QC actions). asyncio.gather for parallel fan-out. - New audit actions: LANGUAGE_QC_REVIEWER_ASSIGN/REASSIGN, LANGUAGE_QC_SUBMIT, LANGUAGE_QC_OPEN_REVIEW, LANGUAGE_QC_COMMENT. - Inline project picker in NewJob: "+ Create new project…" option with name, default languages, default linguist, default reviewer. Pre-fills languages on the new job. - Project model extended with default_languages, default_linguist_id, default_reviewer_id. - RBAC: CLIENT org-members can now create projects (backend guard relaxed). - LinguistQueue: role toggle "As linguist / As reviewer" + new status tabs. - QCDetail: two-slot assignment cards (linguist + reviewer), deadline display, role-aware action buttons, comments panel with optimistic insert and 15s refetch. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
bfb3a18d65
commit
a168af1aa7
19 changed files with 1695 additions and 302 deletions
|
|
@ -661,21 +661,24 @@ async def get_user_audit_logs(
|
|||
):
|
||||
"""Get audit logs for a specific user — accepts user ID or email (production/admin only)"""
|
||||
|
||||
# Accept email address: look up the user to get their ID
|
||||
import re as _re
|
||||
|
||||
# Accept email address: look up user by case-insensitive email match
|
||||
resolved_id = user_id
|
||||
if "@" in user_id:
|
||||
user_doc = await db.users.find_one({"email": user_id}, {"_id": 1})
|
||||
user_doc = await db.users.find_one(
|
||||
{"email": _re.compile(f"^{_re.escape(user_id)}$", _re.IGNORECASE)},
|
||||
{"_id": 1},
|
||||
)
|
||||
if user_doc:
|
||||
resolved_id = str(user_doc["_id"])
|
||||
# If not found, query by user_email field in audit logs directly below
|
||||
|
||||
logs = await audit_logger.get_user_activity(resolved_id, days)
|
||||
|
||||
# If resolved by ObjectId returned nothing, try querying by email field
|
||||
# Fallback: query by email field in audit logs (case-insensitive via audit_logger)
|
||||
if not logs and "@" in user_id:
|
||||
from ...models.audit_log import AuditLogQuery as ALQ
|
||||
from ...services.audit_logger import audit_logger as al
|
||||
from datetime import timedelta
|
||||
q = ALQ(user_email=user_id, limit=1000, sort_by="timestamp", sort_order=-1)
|
||||
result = await al.query_logs(q)
|
||||
logs = result.logs
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ from motor.motor_asyncio import AsyncIOMotorDatabase
|
|||
from pydantic import BaseModel
|
||||
|
||||
from ...core.database import get_database
|
||||
from ...core.dependencies import get_current_user, require_pm_for_client, require_roles
|
||||
from ...core.dependencies import get_current_user, require_roles
|
||||
from ...models.client import (
|
||||
Client,
|
||||
ClientCreate,
|
||||
|
|
@ -91,6 +91,9 @@ def _project_from_doc(doc: dict) -> Project:
|
|||
name=doc["name"],
|
||||
client_id=doc["client_id"],
|
||||
is_active=doc.get("is_active", True),
|
||||
default_languages=doc.get("default_languages", []),
|
||||
default_linguist_id=doc.get("default_linguist_id"),
|
||||
default_reviewer_id=doc.get("default_reviewer_id"),
|
||||
created_at=doc.get("created_at"),
|
||||
updated_at=doc.get("updated_at"),
|
||||
)
|
||||
|
|
@ -381,7 +384,7 @@ async def create_project(
|
|||
db: AsyncIOMotorDatabase = Depends(get_database),
|
||||
):
|
||||
await _get_client_or_404(client_id, db)
|
||||
await _assert_pm_or_admin(current_user, client_id, db)
|
||||
await _assert_pm_or_client_member(current_user, client_id, db)
|
||||
now = _now()
|
||||
project_id = str(ObjectId())
|
||||
await db.projects.insert_one({
|
||||
|
|
@ -389,6 +392,9 @@ async def create_project(
|
|||
"name": body.name,
|
||||
"client_id": client_id,
|
||||
"is_active": True,
|
||||
"default_languages": body.default_languages,
|
||||
"default_linguist_id": body.default_linguist_id,
|
||||
"default_reviewer_id": body.default_reviewer_id,
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
})
|
||||
|
|
@ -449,6 +455,24 @@ async def _assert_pm_or_admin(user: User, client_id: str, db: AsyncIOMotorDataba
|
|||
raise HTTPException(status_code=403, detail="Not a manager for this client")
|
||||
|
||||
|
||||
async def _assert_pm_or_client_member(user: User, client_id: str, db: AsyncIOMotorDatabase) -> None:
|
||||
"""Allow PM/ADMIN/PROD or any org member (CLIENT role) with membership in this client's org."""
|
||||
if user.role in (UserRole.ADMIN, UserRole.PRODUCTION):
|
||||
return
|
||||
if user.role == UserRole.PROJECT_MANAGER:
|
||||
if client_id in (user.pm_client_ids or []):
|
||||
return
|
||||
mem = await db.memberships.find_one({"user_id": str(user.id), "organization_id": client_id})
|
||||
if mem and mem.get("role_in_org") in ("owner", "admin", "manager"):
|
||||
return
|
||||
# Allow CLIENT users who are members of the org
|
||||
if user.role == UserRole.CLIENT:
|
||||
mem = await db.memberships.find_one({"user_id": str(user.id), "organization_id": client_id})
|
||||
if mem:
|
||||
return
|
||||
raise HTTPException(status_code=403, detail="Not authorized to create projects for this client")
|
||||
|
||||
|
||||
async def _assert_client_access(user: User, client_id: str, db: AsyncIOMotorDatabase) -> None:
|
||||
"""Allow platform staff, org members (any role), or PM of the client."""
|
||||
if user.role in (UserRole.ADMIN, UserRole.REVIEWER, UserRole.PRODUCTION, UserRole.LINGUIST):
|
||||
|
|
|
|||
|
|
@ -212,6 +212,46 @@ async def activate_version(
|
|||
return {"status": "ok", "active_version_id": version_id}
|
||||
|
||||
|
||||
# ── Re-queue embedding ────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/{glossary_id}/versions/{version_id}/reembed", status_code=202)
|
||||
async def reembed_version(
|
||||
client_id: str,
|
||||
glossary_id: str,
|
||||
version_id: str,
|
||||
current_user: User = Depends(require_roles(UserRole.ADMIN, UserRole.PROJECT_MANAGER)),
|
||||
):
|
||||
"""Re-queue the embedding task for a glossary version (resets failed/pending/stuck embeds)."""
|
||||
glossary = await svc.get_glossary(glossary_id)
|
||||
if not glossary or glossary.client_id != client_id:
|
||||
raise HTTPException(status_code=404, detail="Glossary not found")
|
||||
|
||||
versions = await svc.get_versions(glossary_id)
|
||||
version = next((v for v in versions if str(v.id) == version_id), None)
|
||||
if not version:
|
||||
raise HTTPException(status_code=404, detail="Version not found")
|
||||
|
||||
try:
|
||||
from ...tasks.embed_glossary import embed_glossary_version_task
|
||||
from bson import ObjectId
|
||||
import motor.motor_asyncio
|
||||
from ...core.config import settings
|
||||
|
||||
client_db = motor.motor_asyncio.AsyncIOMotorClient(settings.mongodb_uri)
|
||||
db = client_db[settings.mongodb_db]
|
||||
await db.glossary_versions.update_one(
|
||||
{"_id": ObjectId(version_id)},
|
||||
{"$set": {"embedding_status": "pending", "embedded_count": 0}},
|
||||
)
|
||||
client_db.close()
|
||||
|
||||
embed_glossary_version_task.delay(version_id)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to queue embedding: {exc}") from exc
|
||||
|
||||
return {"status": "queued", "version_id": version_id}
|
||||
|
||||
|
||||
# ── Archive (soft-delete) ─────────────────────────────────────────────────────
|
||||
|
||||
@router.delete("/{glossary_id}", status_code=204)
|
||||
|
|
|
|||
|
|
@ -1,14 +1,15 @@
|
|||
"""Per-language QC endpoints — assignment, approval, rejection, queue."""
|
||||
"""Per-language QC endpoints — two-stage (linguist + reviewer) assignment, workflow, comments."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
||||
from fastapi import APIRouter, Depends, Query, Request
|
||||
from motor.motor_asyncio import AsyncIOMotorDatabase
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from ...core.database import get_database
|
||||
from ...core.dependencies import get_current_user, require_roles
|
||||
from ...models.job import LanguageQCState, LanguageQCStatus
|
||||
from ...core.dependencies import require_roles
|
||||
from ...models.job import LanguageQCComment, LanguageQCState
|
||||
from ...models.user import User, UserRole
|
||||
from ...services import language_qc as lqc
|
||||
|
||||
|
|
@ -20,11 +21,25 @@ router = APIRouter(tags=["language-qc"])
|
|||
class AssignRequest(BaseModel):
|
||||
linguist_user_id: str
|
||||
notes: Optional[str] = None
|
||||
deadline: Optional[datetime] = None
|
||||
|
||||
|
||||
class ReassignRequest(BaseModel):
|
||||
linguist_user_id: str
|
||||
notes: Optional[str] = None
|
||||
deadline: Optional[datetime] = None
|
||||
|
||||
|
||||
class AssignReviewerRequest(BaseModel):
|
||||
reviewer_user_id: str
|
||||
notes: Optional[str] = None
|
||||
deadline: Optional[datetime] = None
|
||||
|
||||
|
||||
class ReassignReviewerRequest(BaseModel):
|
||||
reviewer_user_id: str
|
||||
notes: Optional[str] = None
|
||||
deadline: Optional[datetime] = None
|
||||
|
||||
|
||||
class ApproveLanguageRequest(BaseModel):
|
||||
|
|
@ -39,6 +54,10 @@ class ReopenLanguageRequest(BaseModel):
|
|||
notes: Optional[str] = None
|
||||
|
||||
|
||||
class AddCommentRequest(BaseModel):
|
||||
body: str = Field(..., min_length=1, max_length=4000)
|
||||
|
||||
|
||||
class LanguageQCStateResponse(BaseModel):
|
||||
lang: str
|
||||
state: LanguageQCState
|
||||
|
|
@ -75,11 +94,12 @@ async def get_language_qc(
|
|||
)),
|
||||
db: AsyncIOMotorDatabase = Depends(get_database),
|
||||
):
|
||||
"""Return per-language QC state map for a job."""
|
||||
states = await lqc.get_all_states(db, job_id)
|
||||
return LanguageQCMapResponse(job_id=job_id, language_qc=states)
|
||||
|
||||
|
||||
# ── Linguist assignment ───────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/jobs/{job_id}/languages/{lang}/assign", response_model=LanguageQCStateResponse)
|
||||
async def assign_language(
|
||||
job_id: str,
|
||||
|
|
@ -91,10 +111,9 @@ async def assign_language(
|
|||
)),
|
||||
db: AsyncIOMotorDatabase = Depends(get_database),
|
||||
):
|
||||
"""Assign a linguist to a language on this job (PM / PROD / ADMIN only)."""
|
||||
state = await lqc.assign_linguist(
|
||||
db, job_id, lang, request.linguist_user_id, current_user,
|
||||
http_request=http_request, notes=request.notes,
|
||||
http_request=http_request, notes=request.notes, deadline=request.deadline,
|
||||
)
|
||||
return LanguageQCStateResponse(lang=lang, state=state)
|
||||
|
||||
|
|
@ -110,14 +129,100 @@ async def reassign_language(
|
|||
)),
|
||||
db: AsyncIOMotorDatabase = Depends(get_database),
|
||||
):
|
||||
"""Hand off a language to another linguist (assigned linguist or PM/PROD/ADMIN)."""
|
||||
state = await lqc.reassign_linguist(
|
||||
db, job_id, lang, request.linguist_user_id, current_user,
|
||||
http_request=http_request, notes=request.notes,
|
||||
http_request=http_request, notes=request.notes, deadline=request.deadline,
|
||||
)
|
||||
return LanguageQCStateResponse(lang=lang, state=state)
|
||||
|
||||
|
||||
# ── Reviewer assignment ───────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/jobs/{job_id}/languages/{lang}/assign-reviewer", response_model=LanguageQCStateResponse)
|
||||
async def assign_reviewer(
|
||||
job_id: str,
|
||||
lang: str,
|
||||
request: AssignReviewerRequest,
|
||||
http_request: Request,
|
||||
current_user: User = Depends(require_roles(
|
||||
UserRole.PROJECT_MANAGER, UserRole.PRODUCTION, UserRole.ADMIN,
|
||||
)),
|
||||
db: AsyncIOMotorDatabase = Depends(get_database),
|
||||
):
|
||||
state = await lqc.assign_reviewer(
|
||||
db, job_id, lang, request.reviewer_user_id, current_user,
|
||||
http_request=http_request, notes=request.notes, deadline=request.deadline,
|
||||
)
|
||||
return LanguageQCStateResponse(lang=lang, state=state)
|
||||
|
||||
|
||||
@router.post("/jobs/{job_id}/languages/{lang}/reassign-reviewer", response_model=LanguageQCStateResponse)
|
||||
async def reassign_reviewer(
|
||||
job_id: str,
|
||||
lang: str,
|
||||
request: ReassignReviewerRequest,
|
||||
http_request: Request,
|
||||
current_user: User = Depends(require_roles(
|
||||
UserRole.PROJECT_MANAGER, UserRole.PRODUCTION, UserRole.ADMIN,
|
||||
)),
|
||||
db: AsyncIOMotorDatabase = Depends(get_database),
|
||||
):
|
||||
state = await lqc.reassign_reviewer(
|
||||
db, job_id, lang, request.reviewer_user_id, current_user,
|
||||
http_request=http_request, notes=request.notes, deadline=request.deadline,
|
||||
)
|
||||
return LanguageQCStateResponse(lang=lang, state=state)
|
||||
|
||||
|
||||
# ── Workflow transitions ──────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/jobs/{job_id}/languages/{lang}/start-work", response_model=LanguageQCStateResponse)
|
||||
async def start_linguist_work(
|
||||
job_id: str,
|
||||
lang: str,
|
||||
http_request: Request,
|
||||
current_user: User = Depends(require_roles(
|
||||
UserRole.LINGUIST, UserRole.PRODUCTION, UserRole.ADMIN,
|
||||
)),
|
||||
db: AsyncIOMotorDatabase = Depends(get_database),
|
||||
):
|
||||
"""Linguist opens the language — pending → in_progress."""
|
||||
state = await lqc.start_linguist_work(db, job_id, lang, current_user)
|
||||
return LanguageQCStateResponse(lang=lang, state=state)
|
||||
|
||||
|
||||
@router.post("/jobs/{job_id}/languages/{lang}/submit", response_model=LanguageQCStateResponse)
|
||||
async def submit_for_review(
|
||||
job_id: str,
|
||||
lang: str,
|
||||
http_request: Request,
|
||||
current_user: User = Depends(require_roles(
|
||||
UserRole.LINGUIST, UserRole.PRODUCTION, UserRole.ADMIN,
|
||||
)),
|
||||
db: AsyncIOMotorDatabase = Depends(get_database),
|
||||
):
|
||||
"""Linguist submits — in_progress → pending_review. Notifies reviewer by email."""
|
||||
state = await lqc.submit_for_review(db, job_id, lang, current_user, http_request=http_request)
|
||||
return LanguageQCStateResponse(lang=lang, state=state)
|
||||
|
||||
|
||||
@router.post("/jobs/{job_id}/languages/{lang}/open-review", response_model=LanguageQCStateResponse)
|
||||
async def open_review(
|
||||
job_id: str,
|
||||
lang: str,
|
||||
http_request: Request,
|
||||
current_user: User = Depends(require_roles(
|
||||
UserRole.REVIEWER, UserRole.PRODUCTION, UserRole.ADMIN,
|
||||
)),
|
||||
db: AsyncIOMotorDatabase = Depends(get_database),
|
||||
):
|
||||
"""Reviewer opens the review — pending_review → in_review."""
|
||||
state = await lqc.open_review(db, job_id, lang, current_user, http_request=http_request)
|
||||
return LanguageQCStateResponse(lang=lang, state=state)
|
||||
|
||||
|
||||
# ── Approve / Reject / Reopen ─────────────────────────────────────────────────
|
||||
|
||||
@router.post("/jobs/{job_id}/languages/{lang}/approve", response_model=LanguageQCStateResponse)
|
||||
async def approve_language(
|
||||
job_id: str,
|
||||
|
|
@ -125,11 +230,10 @@ async def approve_language(
|
|||
request: ApproveLanguageRequest,
|
||||
http_request: Request,
|
||||
current_user: User = Depends(require_roles(
|
||||
UserRole.LINGUIST, UserRole.REVIEWER, UserRole.PRODUCTION, UserRole.ADMIN,
|
||||
UserRole.REVIEWER, UserRole.PRODUCTION, UserRole.ADMIN,
|
||||
)),
|
||||
db: AsyncIOMotorDatabase = Depends(get_database),
|
||||
):
|
||||
"""Approve a language (assigned linguist, PROD, or ADMIN)."""
|
||||
state = await lqc.approve_language(
|
||||
db, job_id, lang, current_user, http_request=http_request, notes=request.notes,
|
||||
)
|
||||
|
|
@ -143,11 +247,10 @@ async def reject_language(
|
|||
request: RejectLanguageRequest,
|
||||
http_request: Request,
|
||||
current_user: User = Depends(require_roles(
|
||||
UserRole.LINGUIST, UserRole.REVIEWER, UserRole.PRODUCTION, UserRole.ADMIN,
|
||||
UserRole.REVIEWER, UserRole.PRODUCTION, UserRole.ADMIN,
|
||||
)),
|
||||
db: AsyncIOMotorDatabase = Depends(get_database),
|
||||
):
|
||||
"""Reject a language with required notes (assigned linguist, PROD, or ADMIN)."""
|
||||
state = await lqc.reject_language(
|
||||
db, job_id, lang, current_user, request.notes, http_request=http_request,
|
||||
)
|
||||
|
|
@ -163,16 +266,54 @@ async def reopen_language(
|
|||
current_user: User = Depends(require_roles(UserRole.PRODUCTION, UserRole.ADMIN)),
|
||||
db: AsyncIOMotorDatabase = Depends(get_database),
|
||||
):
|
||||
"""Re-open an approved language for re-review (PROD / ADMIN only)."""
|
||||
state = await lqc.reopen_language(
|
||||
db, job_id, lang, current_user, http_request=http_request, notes=request.notes,
|
||||
)
|
||||
return LanguageQCStateResponse(lang=lang, state=state)
|
||||
|
||||
|
||||
# ── Comments ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/jobs/{job_id}/languages/{lang}/comments", response_model=LanguageQCComment, status_code=201)
|
||||
async def add_comment(
|
||||
job_id: str,
|
||||
lang: str,
|
||||
request: AddCommentRequest,
|
||||
http_request: Request,
|
||||
current_user: User = Depends(require_roles(
|
||||
UserRole.LINGUIST, UserRole.REVIEWER, UserRole.PROJECT_MANAGER,
|
||||
UserRole.PRODUCTION, UserRole.ADMIN,
|
||||
)),
|
||||
db: AsyncIOMotorDatabase = Depends(get_database),
|
||||
):
|
||||
comment = await lqc.add_comment(
|
||||
db, job_id, lang, current_user, request.body, http_request=http_request,
|
||||
)
|
||||
return comment
|
||||
|
||||
|
||||
@router.get("/jobs/{job_id}/languages/{lang}/comments", response_model=list[LanguageQCComment])
|
||||
async def list_comments(
|
||||
job_id: str,
|
||||
lang: str,
|
||||
current_user: User = Depends(require_roles(
|
||||
UserRole.LINGUIST, UserRole.REVIEWER, UserRole.PROJECT_MANAGER,
|
||||
UserRole.PRODUCTION, UserRole.ADMIN,
|
||||
)),
|
||||
db: AsyncIOMotorDatabase = Depends(get_database),
|
||||
):
|
||||
state = await lqc.get_state(db, job_id, lang)
|
||||
if state is None:
|
||||
return []
|
||||
return state.comments
|
||||
|
||||
|
||||
# ── Queues ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/me/language-qc-queue", response_model=QueueResponse)
|
||||
async def my_language_qc_queue(
|
||||
qc_status: Optional[str] = Query(None, description="Filter by status: pending, in_review, approved, rejected"),
|
||||
role: str = Query("linguist", description="'linguist' or 'reviewer'"),
|
||||
qc_status: Optional[str] = Query(None, description="Filter by status"),
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
current_user: User = Depends(require_roles(
|
||||
|
|
@ -180,10 +321,15 @@ async def my_language_qc_queue(
|
|||
)),
|
||||
db: AsyncIOMotorDatabase = Depends(get_database),
|
||||
):
|
||||
"""List jobs and languages assigned to the current user."""
|
||||
jobs = await lqc.list_for_linguist(
|
||||
db, str(current_user.id), status_filter=qc_status, skip=skip, limit=limit,
|
||||
)
|
||||
"""List jobs and languages assigned to the current user as linguist or reviewer."""
|
||||
if role == "reviewer":
|
||||
jobs = await lqc.list_for_reviewer(
|
||||
db, str(current_user.id), status_filter=qc_status, skip=skip, limit=limit,
|
||||
)
|
||||
else:
|
||||
jobs = await lqc.list_for_linguist(
|
||||
db, str(current_user.id), status_filter=qc_status, skip=skip, limit=limit,
|
||||
)
|
||||
|
||||
items: list[QueueItem] = []
|
||||
for job in jobs:
|
||||
|
|
|
|||
|
|
@ -11,8 +11,8 @@ from .security import decode_token
|
|||
|
||||
security = HTTPBearer()
|
||||
|
||||
# Roles that see all jobs (no tenant isolation)
|
||||
STAFF_ROLES = {UserRole.ADMIN, UserRole.REVIEWER, UserRole.LINGUIST, UserRole.PRODUCTION}
|
||||
# Only admins bypass tenant isolation; other staff are scoped by team membership
|
||||
STAFF_ROLES = {UserRole.ADMIN}
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
|
|
@ -108,15 +108,19 @@ async def get_accessible_project_ids(
|
|||
"""
|
||||
Returns project IDs the user may access, or None meaning "see everything".
|
||||
|
||||
- Staff / Admin → None (unrestricted)
|
||||
- Otherwise → projects in orgs where the user holds any membership
|
||||
(falls back to legacy pm_client_ids/team lookups if no memberships found)
|
||||
- Admin → None (unrestricted)
|
||||
- Staff (REVIEWER/LINGUIST/PRODUCTION) → scoped by team membership;
|
||||
if not yet assigned to any team, falls back to None (see all)
|
||||
so existing staff aren't locked out before teams are configured
|
||||
- PM → projects in accessible orgs/clients (pm_client_ids legacy)
|
||||
- CLIENT → projects in orgs where the user holds any membership
|
||||
"""
|
||||
if user.role in STAFF_ROLES:
|
||||
return None
|
||||
|
||||
# Primary path: use memberships collection (Phase 3 SaaS)
|
||||
user_id = str(user.id)
|
||||
|
||||
# Primary path: use memberships collection (Phase 3 SaaS)
|
||||
membership_cursor = db.memberships.find({"user_id": user_id}, {"organization_id": 1})
|
||||
org_ids = [doc["organization_id"] async for doc in membership_cursor]
|
||||
|
||||
|
|
@ -127,29 +131,37 @@ async def get_accessible_project_ids(
|
|||
).to_list(None)
|
||||
return [str(p["_id"]) for p in projects]
|
||||
|
||||
# Legacy fallback (pre-backfill) — keeps the app working before migration runs
|
||||
if user.role == UserRole.PROJECT_MANAGER:
|
||||
client_ids = user.pm_client_ids or []
|
||||
if not client_ids:
|
||||
return []
|
||||
# Legacy fallback: team membership (used by REVIEWER/LINGUIST/PRODUCTION and legacy CLIENT)
|
||||
teams = await db.teams.find(
|
||||
{"member_user_ids": user_id},
|
||||
{"client_id": 1},
|
||||
).to_list(None)
|
||||
client_ids = list({t["client_id"] for t in teams})
|
||||
|
||||
if client_ids:
|
||||
projects = await db.projects.find(
|
||||
{"client_id": {"$in": client_ids}, "is_active": True},
|
||||
{"_id": 1},
|
||||
).to_list(None)
|
||||
return [str(p["_id"]) for p in projects]
|
||||
|
||||
teams = await db.teams.find(
|
||||
{"member_user_ids": user_id},
|
||||
{"client_id": 1},
|
||||
).to_list(None)
|
||||
client_ids = list({t["client_id"] for t in teams})
|
||||
if not client_ids:
|
||||
return []
|
||||
projects = await db.projects.find(
|
||||
{"client_id": {"$in": client_ids}, "is_active": True},
|
||||
{"_id": 1},
|
||||
).to_list(None)
|
||||
return [str(p["_id"]) for p in projects]
|
||||
# PM legacy: scoped via pm_client_ids
|
||||
if user.role == UserRole.PROJECT_MANAGER:
|
||||
pm_client_ids = user.pm_client_ids or []
|
||||
if not pm_client_ids:
|
||||
return []
|
||||
projects = await db.projects.find(
|
||||
{"client_id": {"$in": pm_client_ids}, "is_active": True},
|
||||
{"_id": 1},
|
||||
).to_list(None)
|
||||
return [str(p["_id"]) for p in projects]
|
||||
|
||||
# Staff with no team assignments → unrestricted until teams are configured
|
||||
if user.role in {UserRole.REVIEWER, UserRole.LINGUIST, UserRole.PRODUCTION}:
|
||||
return None
|
||||
|
||||
# CLIENT with no memberships and no teams → show nothing
|
||||
return []
|
||||
|
||||
|
||||
def require_pm_for_client(client_id_param: str = "client_id"):
|
||||
|
|
|
|||
|
|
@ -51,9 +51,14 @@ class AuditAction(str, Enum):
|
|||
# Per-language QC actions
|
||||
LANGUAGE_QC_ASSIGN = "language_qc.assign"
|
||||
LANGUAGE_QC_REASSIGN = "language_qc.reassign"
|
||||
LANGUAGE_QC_REVIEWER_ASSIGN = "language_qc.reviewer_assign"
|
||||
LANGUAGE_QC_REVIEWER_REASSIGN = "language_qc.reviewer_reassign"
|
||||
LANGUAGE_QC_SUBMIT = "language_qc.submit"
|
||||
LANGUAGE_QC_OPEN_REVIEW = "language_qc.open_review"
|
||||
LANGUAGE_QC_APPROVE = "language_qc.approve"
|
||||
LANGUAGE_QC_REJECT = "language_qc.reject"
|
||||
LANGUAGE_QC_REOPEN = "language_qc.reopen"
|
||||
LANGUAGE_QC_COMMENT = "language_qc.comment"
|
||||
|
||||
# Admin actions
|
||||
ADMIN_CONFIG_CHANGE = "admin.config.change"
|
||||
|
|
|
|||
|
|
@ -58,14 +58,23 @@ class Project(BaseModel):
|
|||
name: str
|
||||
client_id: str
|
||||
is_active: bool = True
|
||||
default_languages: list[str] = []
|
||||
default_linguist_id: Optional[str] = None
|
||||
default_reviewer_id: Optional[str] = None
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class ProjectCreate(BaseModel):
|
||||
name: str
|
||||
default_languages: list[str] = []
|
||||
default_linguist_id: Optional[str] = None
|
||||
default_reviewer_id: Optional[str] = None
|
||||
|
||||
|
||||
class ProjectUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
default_languages: Optional[list[str]] = None
|
||||
default_linguist_id: Optional[str] = None
|
||||
default_reviewer_id: Optional[str] = None
|
||||
|
|
|
|||
|
|
@ -144,7 +144,9 @@ class Review(BaseModel):
|
|||
|
||||
class LanguageQCStatus(str, Enum):
|
||||
PENDING = "pending"
|
||||
IN_REVIEW = "in_review"
|
||||
IN_PROGRESS = "in_progress" # linguist is working
|
||||
PENDING_REVIEW = "pending_review" # linguist submitted, awaiting reviewer
|
||||
IN_REVIEW = "in_review" # reviewer has opened it
|
||||
APPROVED = "approved"
|
||||
REJECTED = "rejected"
|
||||
|
||||
|
|
@ -153,22 +155,50 @@ class LanguageQCEvent(BaseModel):
|
|||
at: datetime
|
||||
actor_user_id: str
|
||||
actor_email: str
|
||||
action: Literal["assign", "reassign", "start_review", "approve", "reject", "reopen"]
|
||||
action: Literal[
|
||||
"assign", "reassign",
|
||||
"reviewer_assigned", "reviewer_reassigned",
|
||||
"start_work", "submit_for_review", "open_review",
|
||||
"approve", "reject", "reopen",
|
||||
"comment_added",
|
||||
]
|
||||
notes: Optional[str] = None
|
||||
previous_assignee_id: Optional[str] = None
|
||||
|
||||
|
||||
class LanguageQCComment(BaseModel):
|
||||
id: str
|
||||
author_id: str
|
||||
author_name: str
|
||||
author_email: str
|
||||
body: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class LanguageQCState(BaseModel):
|
||||
status: LanguageQCStatus = LanguageQCStatus.PENDING
|
||||
# Linguist slot
|
||||
assigned_linguist_id: Optional[str] = None
|
||||
assigned_linguist_email: Optional[str] = None
|
||||
assigned_linguist_name: Optional[str] = None
|
||||
assigned_at: Optional[datetime] = None
|
||||
assigned_by_user_id: Optional[str] = None
|
||||
submitted_for_review_at: Optional[datetime] = None
|
||||
linguist_deadline: Optional[datetime] = None # when linguist must submit
|
||||
# Reviewer slot
|
||||
assigned_reviewer_id: Optional[str] = None
|
||||
assigned_reviewer_email: Optional[str] = None
|
||||
assigned_reviewer_name: Optional[str] = None
|
||||
assigned_reviewer_at: Optional[datetime] = None
|
||||
review_started_at: Optional[datetime] = None
|
||||
reviewer_deadline: Optional[datetime] = None # when reviewer must decide
|
||||
# Final outcome
|
||||
reviewed_at: Optional[datetime] = None
|
||||
reviewed_by_user_id: Optional[str] = None
|
||||
reviewed_by_email: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
history: list[LanguageQCEvent] = []
|
||||
comments: list[LanguageQCComment] = []
|
||||
|
||||
|
||||
class QCAssignment(BaseModel):
|
||||
|
|
|
|||
|
|
@ -126,7 +126,10 @@ class AuditLogger:
|
|||
if query.user_id:
|
||||
mongo_query["user_id"] = query.user_id
|
||||
if query.user_email:
|
||||
mongo_query["user_email"] = query.user_email
|
||||
import re as _re
|
||||
mongo_query["user_email"] = _re.compile(
|
||||
f"^{_re.escape(query.user_email)}$", _re.IGNORECASE
|
||||
)
|
||||
if query.resource_type:
|
||||
mongo_query["resource_type"] = query.resource_type
|
||||
if query.resource_id:
|
||||
|
|
|
|||
|
|
@ -144,6 +144,178 @@ class EmailService:
|
|||
""").render(full_name=full_name, reset_url=reset_url)
|
||||
return await self._send(to_email, "Reset your password", html)
|
||||
|
||||
async def send_language_assignment_email(
|
||||
self,
|
||||
to_email: str,
|
||||
full_name: str,
|
||||
job_title: str,
|
||||
lang: str,
|
||||
role: str,
|
||||
deep_link: str,
|
||||
) -> bool:
|
||||
html = Template("""
|
||||
<!DOCTYPE html><html><head><meta charset="utf-8">
|
||||
<style>
|
||||
body{font-family:Arial,sans-serif;line-height:1.6;color:#333}
|
||||
.container{max-width:600px;margin:0 auto;padding:20px}
|
||||
.header{background:#4f46e5;color:#fff;padding:20px;text-align:center;border-radius:8px 8px 0 0}
|
||||
.content{padding:24px;border:1px solid #e5e7eb;border-top:none;border-radius:0 0 8px 8px}
|
||||
.btn{display:inline-block;padding:12px 28px;background:#4f46e5;color:#fff;text-decoration:none;border-radius:6px;font-weight:bold;margin:16px 0}
|
||||
.footer{text-align:center;padding:16px;color:#9ca3af;font-size:12px}
|
||||
</style>
|
||||
</head><body>
|
||||
<div class="container">
|
||||
<div class="header"><h1>New QC assignment</h1></div>
|
||||
<div class="content">
|
||||
<p>Hi {{ full_name or 'there' }},</p>
|
||||
<p>You have been assigned as <strong>{{ role }}</strong> for language <strong>{{ lang }}</strong> on job <strong>{{ job_title }}</strong>.</p>
|
||||
<p><a href="{{ deep_link }}" class="btn">Open QC Review</a></p>
|
||||
</div>
|
||||
<div class="footer">Accessible Video Platform</div>
|
||||
</div>
|
||||
</body></html>
|
||||
""").render(full_name=full_name, role=role, lang=lang, job_title=job_title, deep_link=deep_link)
|
||||
subject = f"[{job_title}] You've been assigned as {role} for {lang}"
|
||||
return await self._send(to_email, subject, html)
|
||||
|
||||
async def send_language_submitted_email(
|
||||
self,
|
||||
to_email: str,
|
||||
full_name: str,
|
||||
job_title: str,
|
||||
lang: str,
|
||||
linguist_name: str,
|
||||
deep_link: str,
|
||||
) -> bool:
|
||||
html = Template("""
|
||||
<!DOCTYPE html><html><head><meta charset="utf-8">
|
||||
<style>
|
||||
body{font-family:Arial,sans-serif;line-height:1.6;color:#333}
|
||||
.container{max-width:600px;margin:0 auto;padding:20px}
|
||||
.header{background:#0891b2;color:#fff;padding:20px;text-align:center;border-radius:8px 8px 0 0}
|
||||
.content{padding:24px;border:1px solid #e5e7eb;border-top:none;border-radius:0 0 8px 8px}
|
||||
.btn{display:inline-block;padding:12px 28px;background:#0891b2;color:#fff;text-decoration:none;border-radius:6px;font-weight:bold;margin:16px 0}
|
||||
.footer{text-align:center;padding:16px;color:#9ca3af;font-size:12px}
|
||||
</style>
|
||||
</head><body>
|
||||
<div class="container">
|
||||
<div class="header"><h1>Ready for your review</h1></div>
|
||||
<div class="content">
|
||||
<p>Hi {{ full_name or 'there' }},</p>
|
||||
<p><strong>{{ linguist_name or 'The linguist' }}</strong> has submitted language <strong>{{ lang }}</strong> on job <strong>{{ job_title }}</strong> for your review.</p>
|
||||
<p><a href="{{ deep_link }}" class="btn">Open Review</a></p>
|
||||
</div>
|
||||
<div class="footer">Accessible Video Platform</div>
|
||||
</div>
|
||||
</body></html>
|
||||
""").render(full_name=full_name, linguist_name=linguist_name, lang=lang, job_title=job_title, deep_link=deep_link)
|
||||
return await self._send(to_email, f"[{job_title}] {lang} ready for review", html)
|
||||
|
||||
async def send_qc_comment_email(
|
||||
self,
|
||||
to_email: str,
|
||||
full_name: str,
|
||||
job_title: str,
|
||||
lang: str,
|
||||
author_name: str,
|
||||
comment_body: str,
|
||||
deep_link: str,
|
||||
) -> bool:
|
||||
html = Template("""
|
||||
<!DOCTYPE html><html><head><meta charset="utf-8">
|
||||
<style>
|
||||
body{font-family:Arial,sans-serif;line-height:1.6;color:#333}
|
||||
.container{max-width:600px;margin:0 auto;padding:20px}
|
||||
.header{background:#7c3aed;color:#fff;padding:20px;text-align:center;border-radius:8px 8px 0 0}
|
||||
.content{padding:24px;border:1px solid #e5e7eb;border-top:none;border-radius:0 0 8px 8px}
|
||||
.comment{background:#f3f4f6;border-left:4px solid #7c3aed;padding:12px 16px;margin:12px 0;border-radius:0 6px 6px 0}
|
||||
.btn{display:inline-block;padding:12px 28px;background:#7c3aed;color:#fff;text-decoration:none;border-radius:6px;font-weight:bold;margin:16px 0}
|
||||
.footer{text-align:center;padding:16px;color:#9ca3af;font-size:12px}
|
||||
</style>
|
||||
</head><body>
|
||||
<div class="container">
|
||||
<div class="header"><h1>New comment</h1></div>
|
||||
<div class="content">
|
||||
<p>Hi {{ full_name or 'there' }},</p>
|
||||
<p><strong>{{ author_name }}</strong> commented on <strong>{{ lang }}</strong> · <em>{{ job_title }}</em>:</p>
|
||||
<div class="comment">{{ comment_body }}</div>
|
||||
<p><a href="{{ deep_link }}" class="btn">View Comment</a></p>
|
||||
</div>
|
||||
<div class="footer">Accessible Video Platform</div>
|
||||
</div>
|
||||
</body></html>
|
||||
""").render(full_name=full_name, author_name=author_name, lang=lang, job_title=job_title, comment_body=comment_body, deep_link=deep_link)
|
||||
return await self._send(to_email, f"[{job_title}] New comment on {lang}", html)
|
||||
|
||||
async def send_qc_approved_email(
|
||||
self,
|
||||
to_email: str,
|
||||
full_name: str,
|
||||
job_title: str,
|
||||
lang: str,
|
||||
approver_name: str,
|
||||
deep_link: str,
|
||||
) -> bool:
|
||||
html = Template("""
|
||||
<!DOCTYPE html><html><head><meta charset="utf-8">
|
||||
<style>
|
||||
body{font-family:Arial,sans-serif;line-height:1.6;color:#333}
|
||||
.container{max-width:600px;margin:0 auto;padding:20px}
|
||||
.header{background:#16a34a;color:#fff;padding:20px;text-align:center;border-radius:8px 8px 0 0}
|
||||
.content{padding:24px;border:1px solid #e5e7eb;border-top:none;border-radius:0 0 8px 8px}
|
||||
.btn{display:inline-block;padding:12px 28px;background:#16a34a;color:#fff;text-decoration:none;border-radius:6px;font-weight:bold;margin:16px 0}
|
||||
.footer{text-align:center;padding:16px;color:#9ca3af;font-size:12px}
|
||||
</style>
|
||||
</head><body>
|
||||
<div class="container">
|
||||
<div class="header"><h1>Language approved ✓</h1></div>
|
||||
<div class="content">
|
||||
<p>Hi {{ full_name or 'there' }},</p>
|
||||
<p><strong>{{ lang }}</strong> has been <strong>approved</strong> by {{ approver_name }} on job <strong>{{ job_title }}</strong>.</p>
|
||||
<p><a href="{{ deep_link }}" class="btn">View Details</a></p>
|
||||
</div>
|
||||
<div class="footer">Accessible Video Platform</div>
|
||||
</div>
|
||||
</body></html>
|
||||
""").render(full_name=full_name, lang=lang, approver_name=approver_name, job_title=job_title, deep_link=deep_link)
|
||||
return await self._send(to_email, f"[{job_title}] {lang} approved", html)
|
||||
|
||||
async def send_qc_rejected_email(
|
||||
self,
|
||||
to_email: str,
|
||||
full_name: str,
|
||||
job_title: str,
|
||||
lang: str,
|
||||
reviewer_name: str,
|
||||
reason: str,
|
||||
deep_link: str,
|
||||
) -> bool:
|
||||
html = Template("""
|
||||
<!DOCTYPE html><html><head><meta charset="utf-8">
|
||||
<style>
|
||||
body{font-family:Arial,sans-serif;line-height:1.6;color:#333}
|
||||
.container{max-width:600px;margin:0 auto;padding:20px}
|
||||
.header{background:#dc2626;color:#fff;padding:20px;text-align:center;border-radius:8px 8px 0 0}
|
||||
.content{padding:24px;border:1px solid #e5e7eb;border-top:none;border-radius:0 0 8px 8px}
|
||||
.reason{background:#fef2f2;border-left:4px solid #dc2626;padding:12px 16px;margin:12px 0;border-radius:0 6px 6px 0}
|
||||
.btn{display:inline-block;padding:12px 28px;background:#dc2626;color:#fff;text-decoration:none;border-radius:6px;font-weight:bold;margin:16px 0}
|
||||
.footer{text-align:center;padding:16px;color:#9ca3af;font-size:12px}
|
||||
</style>
|
||||
</head><body>
|
||||
<div class="container">
|
||||
<div class="header"><h1>Changes requested</h1></div>
|
||||
<div class="content">
|
||||
<p>Hi {{ full_name or 'there' }},</p>
|
||||
<p><strong>{{ lang }}</strong> on job <strong>{{ job_title }}</strong> has been sent back for changes by {{ reviewer_name }}.</p>
|
||||
<div class="reason"><strong>Feedback:</strong><br>{{ reason }}</div>
|
||||
<p><a href="{{ deep_link }}" class="btn">Open and Revise</a></p>
|
||||
</div>
|
||||
<div class="footer">Accessible Video Platform</div>
|
||||
</div>
|
||||
</body></html>
|
||||
""").render(full_name=full_name, lang=lang, reviewer_name=reviewer_name, reason=reason, job_title=job_title, deep_link=deep_link)
|
||||
return await self._send(to_email, f"[{job_title}] {lang} rejected — needs changes", html)
|
||||
|
||||
async def send_completion_email(
|
||||
self,
|
||||
recipient_email: str,
|
||||
|
|
|
|||
|
|
@ -1,16 +1,23 @@
|
|||
"""Per-language QC service — assignment, approval, rejection, and auto-advancement."""
|
||||
"""Per-language QC service — two-stage (linguist → reviewer) assignment, approval, rejection, comments."""
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
from uuid import uuid4
|
||||
|
||||
from bson import ObjectId
|
||||
from fastapi import HTTPException, status
|
||||
from fastapi import HTTPException
|
||||
from motor.motor_asyncio import AsyncIOMotorDatabase
|
||||
|
||||
from ..core.logging import get_logger
|
||||
from ..models.audit_log import AuditAction, AuditLogSeverity
|
||||
from ..models.job import JobStatus, LanguageQCEvent, LanguageQCState, LanguageQCStatus, QCAssignment
|
||||
from ..models.user import User
|
||||
from ..models.job import (
|
||||
JobStatus,
|
||||
LanguageQCComment,
|
||||
LanguageQCEvent,
|
||||
LanguageQCState,
|
||||
LanguageQCStatus,
|
||||
)
|
||||
from ..models.user import User, UserRole
|
||||
from ..services.audit_logger import audit_logger
|
||||
from ..services.websocket import connection_manager
|
||||
|
||||
|
|
@ -62,6 +69,31 @@ def _rebuild_qc_assignments(language_qc: dict) -> list[dict]:
|
|||
return assignments
|
||||
|
||||
|
||||
def _qc_recipients(
|
||||
job_doc: dict,
|
||||
lang_state: dict,
|
||||
exclude_user_id: Optional[str],
|
||||
) -> list[tuple[str, str]]:
|
||||
"""Return [(email, full_name)] for linguist + reviewer assigned to a language, minus the actor."""
|
||||
seen: set[str] = set()
|
||||
result: list[tuple[str, str]] = []
|
||||
|
||||
def _add(email: Optional[str], name: Optional[str]) -> None:
|
||||
if email and email not in seen and email != exclude_user_id:
|
||||
seen.add(email)
|
||||
result.append((email, name or email.split("@")[0]))
|
||||
|
||||
_add(lang_state.get("assigned_linguist_email"), lang_state.get("assigned_linguist_name"))
|
||||
_add(lang_state.get("assigned_reviewer_email"), lang_state.get("assigned_reviewer_name"))
|
||||
return result
|
||||
|
||||
|
||||
def _deep_link(job_id: str, lang: str) -> str:
|
||||
from ..core.config import settings
|
||||
base = getattr(settings, "app_url", "https://ai-sandbox.oliver.solutions/video-accessibility")
|
||||
return f"{base}/admin/qc/{job_id}#lang-{lang}"
|
||||
|
||||
|
||||
# ── Core mutations ────────────────────────────────────────────────────────────
|
||||
|
||||
async def get_state(db: AsyncIOMotorDatabase, job_id: str, lang: str) -> Optional[LanguageQCState]:
|
||||
|
|
@ -84,6 +116,8 @@ async def get_all_states(db: AsyncIOMotorDatabase, job_id: str) -> dict[str, Lan
|
|||
return result
|
||||
|
||||
|
||||
# ── Linguist assignment ────────────────────────────────────────────────────────
|
||||
|
||||
async def assign_linguist(
|
||||
db: AsyncIOMotorDatabase,
|
||||
job_id: str,
|
||||
|
|
@ -93,6 +127,7 @@ async def assign_linguist(
|
|||
*,
|
||||
http_request=None,
|
||||
notes: Optional[str] = None,
|
||||
deadline: Optional[datetime] = None,
|
||||
) -> LanguageQCState:
|
||||
"""PM/PROD/ADMIN assigns a linguist to a language. Creates per-lang state if missing."""
|
||||
job_doc = await db[_JOBS].find_one({"_id": job_id})
|
||||
|
|
@ -119,19 +154,17 @@ async def assign_linguist(
|
|||
)
|
||||
|
||||
updated_state = {
|
||||
**(current_state_raw if isinstance(current_state_raw, dict) else {}),
|
||||
"status": current_state_raw.get("status", LanguageQCStatus.PENDING.value) if isinstance(current_state_raw, dict) else LanguageQCStatus.PENDING.value,
|
||||
"assigned_linguist_id": linguist_user_id,
|
||||
"assigned_linguist_email": linguist_doc["email"],
|
||||
"assigned_linguist_name": linguist_doc.get("full_name", ""),
|
||||
"assigned_at": now,
|
||||
"assigned_by_user_id": str(actor.id),
|
||||
"reviewed_at": current_state_raw.get("reviewed_at") if isinstance(current_state_raw, dict) else None,
|
||||
"reviewed_by_user_id": current_state_raw.get("reviewed_by_user_id") if isinstance(current_state_raw, dict) else None,
|
||||
"reviewed_by_email": current_state_raw.get("reviewed_by_email") if isinstance(current_state_raw, dict) else None,
|
||||
"notes": current_state_raw.get("notes") if isinstance(current_state_raw, dict) else None,
|
||||
"linguist_deadline": deadline,
|
||||
"history": (current_state_raw.get("history", []) if isinstance(current_state_raw, dict) else []) + [event.model_dump()],
|
||||
}
|
||||
|
||||
# Rebuild full language_qc for denormalization
|
||||
full_language_qc = {**(job_doc.get("language_qc") or {}), lang: updated_state}
|
||||
qc_assignments = _rebuild_qc_assignments(full_language_qc)
|
||||
|
||||
|
|
@ -147,7 +180,7 @@ async def assign_linguist(
|
|||
audit_action = AuditAction.LANGUAGE_QC_REASSIGN if is_reassignment else AuditAction.LANGUAGE_QC_ASSIGN
|
||||
await audit_logger.log_action(
|
||||
audit_action,
|
||||
f"Language QC {'reassigned' if is_reassignment else 'assigned'}: {lang} on job {job_id} → {linguist_doc['email']}",
|
||||
f"Language QC linguist {'reassigned' if is_reassignment else 'assigned'}: {lang} on job {job_id} → {linguist_doc['email']}",
|
||||
user=actor,
|
||||
request=http_request,
|
||||
resource_type="job_language",
|
||||
|
|
@ -155,7 +188,20 @@ async def assign_linguist(
|
|||
details={"lang": lang, "linguist_id": linguist_user_id, "linguist_email": linguist_doc["email"]},
|
||||
)
|
||||
|
||||
# Notify linguist via websocket
|
||||
# Email the new linguist
|
||||
try:
|
||||
from ..services.emailer import email_service
|
||||
await email_service.send_language_assignment_email(
|
||||
to_email=linguist_doc["email"],
|
||||
full_name=linguist_doc.get("full_name", ""),
|
||||
job_title=job_doc.get("title", job_id),
|
||||
lang=lang,
|
||||
role="linguist",
|
||||
deep_link=_deep_link(job_id, lang),
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to send linguist assignment email")
|
||||
|
||||
try:
|
||||
await connection_manager.broadcast_to_user(
|
||||
linguist_user_id,
|
||||
|
|
@ -176,6 +222,7 @@ async def reassign_linguist(
|
|||
*,
|
||||
http_request=None,
|
||||
notes: Optional[str] = None,
|
||||
deadline: Optional[datetime] = None,
|
||||
) -> LanguageQCState:
|
||||
"""Currently-assigned linguist OR PM/PROD/ADMIN hands off to a colleague."""
|
||||
job_doc = await db[_JOBS].find_one({"_id": job_id})
|
||||
|
|
@ -188,20 +235,123 @@ async def reassign_linguist(
|
|||
|
||||
current_assignee = current_state_raw.get("assigned_linguist_id") if isinstance(current_state_raw, dict) else None
|
||||
if current_assignee != str(actor.id):
|
||||
from ..models.user import UserRole
|
||||
if actor.role not in (UserRole.PRODUCTION, UserRole.ADMIN, UserRole.PROJECT_MANAGER):
|
||||
raise HTTPException(status_code=403, detail="Not authorized to reassign this language")
|
||||
|
||||
return await assign_linguist(db, job_id, lang, new_linguist_user_id, actor, http_request=http_request, notes=notes)
|
||||
return await assign_linguist(db, job_id, lang, new_linguist_user_id, actor, http_request=http_request, notes=notes, deadline=deadline)
|
||||
|
||||
|
||||
async def start_review(
|
||||
# ── Reviewer assignment ────────────────────────────────────────────────────────
|
||||
|
||||
async def assign_reviewer(
|
||||
db: AsyncIOMotorDatabase,
|
||||
job_id: str,
|
||||
lang: str,
|
||||
reviewer_user_id: str,
|
||||
actor: User,
|
||||
*,
|
||||
http_request=None,
|
||||
notes: Optional[str] = None,
|
||||
deadline: Optional[datetime] = None,
|
||||
) -> LanguageQCState:
|
||||
"""PM/PROD/ADMIN assigns a reviewer to a language."""
|
||||
job_doc = await db[_JOBS].find_one({"_id": job_id})
|
||||
if not job_doc:
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
|
||||
reviewer_doc = await db.users.find_one({"_id": reviewer_user_id})
|
||||
if not reviewer_doc:
|
||||
raise HTTPException(status_code=404, detail="Reviewer not found")
|
||||
|
||||
now = datetime.utcnow()
|
||||
current_state_raw = (job_doc.get("language_qc") or {}).get(lang, {})
|
||||
prev_reviewer = current_state_raw.get("assigned_reviewer_id") if isinstance(current_state_raw, dict) else None
|
||||
is_reassignment = prev_reviewer is not None and prev_reviewer != reviewer_user_id
|
||||
action_label = "reviewer_reassigned" if is_reassignment else "reviewer_assigned"
|
||||
|
||||
event = LanguageQCEvent(
|
||||
at=now,
|
||||
actor_user_id=str(actor.id),
|
||||
actor_email=actor.email,
|
||||
action=action_label,
|
||||
notes=notes,
|
||||
previous_assignee_id=prev_reviewer if is_reassignment else None,
|
||||
)
|
||||
|
||||
updated_state = {
|
||||
**(current_state_raw if isinstance(current_state_raw, dict) else {}),
|
||||
"assigned_reviewer_id": reviewer_user_id,
|
||||
"assigned_reviewer_email": reviewer_doc["email"],
|
||||
"assigned_reviewer_name": reviewer_doc.get("full_name", ""),
|
||||
"assigned_reviewer_at": now,
|
||||
"reviewer_deadline": deadline,
|
||||
"history": (current_state_raw.get("history", []) if isinstance(current_state_raw, dict) else []) + [event.model_dump()],
|
||||
}
|
||||
|
||||
full_language_qc = {**(job_doc.get("language_qc") or {}), lang: updated_state}
|
||||
qc_assignments = _rebuild_qc_assignments(full_language_qc)
|
||||
|
||||
await db[_JOBS].update_one(
|
||||
{"_id": job_id},
|
||||
{"$set": {
|
||||
f"language_qc.{lang}": updated_state,
|
||||
"qc_assignments": qc_assignments,
|
||||
"updated_at": now,
|
||||
}}
|
||||
)
|
||||
|
||||
audit_action = AuditAction.LANGUAGE_QC_REVIEWER_REASSIGN if is_reassignment else AuditAction.LANGUAGE_QC_REVIEWER_ASSIGN
|
||||
await audit_logger.log_action(
|
||||
audit_action,
|
||||
f"Language QC reviewer {'reassigned' if is_reassignment else 'assigned'}: {lang} on job {job_id} → {reviewer_doc['email']}",
|
||||
user=actor,
|
||||
request=http_request,
|
||||
resource_type="job_language",
|
||||
resource_id=f"{job_id}:{lang}",
|
||||
details={"lang": lang, "reviewer_id": reviewer_user_id, "reviewer_email": reviewer_doc["email"]},
|
||||
)
|
||||
|
||||
try:
|
||||
from ..services.emailer import email_service
|
||||
await email_service.send_language_assignment_email(
|
||||
to_email=reviewer_doc["email"],
|
||||
full_name=reviewer_doc.get("full_name", ""),
|
||||
job_title=job_doc.get("title", job_id),
|
||||
lang=lang,
|
||||
role="reviewer",
|
||||
deep_link=_deep_link(job_id, lang),
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to send reviewer assignment email")
|
||||
|
||||
return LanguageQCState(**updated_state)
|
||||
|
||||
|
||||
async def reassign_reviewer(
|
||||
db: AsyncIOMotorDatabase,
|
||||
job_id: str,
|
||||
lang: str,
|
||||
new_reviewer_user_id: str,
|
||||
actor: User,
|
||||
*,
|
||||
http_request=None,
|
||||
notes: Optional[str] = None,
|
||||
deadline: Optional[datetime] = None,
|
||||
) -> LanguageQCState:
|
||||
if actor.role not in (UserRole.PRODUCTION, UserRole.ADMIN, UserRole.PROJECT_MANAGER):
|
||||
raise HTTPException(status_code=403, detail="Only PM/PROD/ADMIN can reassign reviewer")
|
||||
return await assign_reviewer(db, job_id, lang, new_reviewer_user_id, actor, http_request=http_request, notes=notes, deadline=deadline)
|
||||
|
||||
|
||||
# ── Workflow transitions ──────────────────────────────────────────────────────
|
||||
|
||||
async def start_linguist_work(
|
||||
db: AsyncIOMotorDatabase,
|
||||
job_id: str,
|
||||
lang: str,
|
||||
actor: User,
|
||||
) -> LanguageQCState:
|
||||
"""Transition pending → in_review when the assigned linguist opens the language for review."""
|
||||
"""Linguist opens the language — transitions pending → in_progress."""
|
||||
job_doc = await db[_JOBS].find_one({"_id": job_id})
|
||||
if not job_doc:
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
|
|
@ -209,26 +359,175 @@ async def start_review(
|
|||
current_state_raw = (job_doc.get("language_qc") or {}).get(lang, {})
|
||||
current_status = current_state_raw.get("status", LanguageQCStatus.PENDING.value) if isinstance(current_state_raw, dict) else LanguageQCStatus.PENDING.value
|
||||
|
||||
if current_status != LanguageQCStatus.PENDING.value:
|
||||
if current_status not in (LanguageQCStatus.PENDING.value, LanguageQCStatus.REJECTED.value):
|
||||
return LanguageQCState(**(current_state_raw if isinstance(current_state_raw, dict) else {}))
|
||||
|
||||
assigned = current_state_raw.get("assigned_linguist_id") if isinstance(current_state_raw, dict) else None
|
||||
if assigned != str(actor.id) and actor.role not in (UserRole.PRODUCTION, UserRole.ADMIN):
|
||||
raise HTTPException(status_code=403, detail="Not the assigned linguist")
|
||||
|
||||
now = datetime.utcnow()
|
||||
event = LanguageQCEvent(at=now, actor_user_id=str(actor.id), actor_email=actor.email, action="start_review")
|
||||
updated_status = LanguageQCStatus.IN_REVIEW.value
|
||||
event = LanguageQCEvent(at=now, actor_user_id=str(actor.id), actor_email=actor.email, action="start_work")
|
||||
history = (current_state_raw.get("history", []) if isinstance(current_state_raw, dict) else []) + [event.model_dump()]
|
||||
|
||||
updated_state = {
|
||||
**(current_state_raw if isinstance(current_state_raw, dict) else {}),
|
||||
"status": LanguageQCStatus.IN_PROGRESS.value,
|
||||
"submitted_for_review_at": None,
|
||||
"history": history,
|
||||
}
|
||||
|
||||
await db[_JOBS].update_one(
|
||||
{"_id": job_id},
|
||||
{"$set": {
|
||||
f"language_qc.{lang}.status": updated_status,
|
||||
f"language_qc.{lang}.status": LanguageQCStatus.IN_PROGRESS.value,
|
||||
f"language_qc.{lang}.submitted_for_review_at": None,
|
||||
f"language_qc.{lang}.history": history,
|
||||
"updated_at": now,
|
||||
}}
|
||||
)
|
||||
updated = {**(current_state_raw if isinstance(current_state_raw, dict) else {}), "status": updated_status, "history": history}
|
||||
return LanguageQCState(**updated)
|
||||
return LanguageQCState(**updated_state)
|
||||
|
||||
|
||||
# Keep old name as alias so any existing callers don't break immediately
|
||||
start_review = start_linguist_work
|
||||
|
||||
|
||||
async def submit_for_review(
|
||||
db: AsyncIOMotorDatabase,
|
||||
job_id: str,
|
||||
lang: str,
|
||||
actor: User,
|
||||
*,
|
||||
http_request=None,
|
||||
) -> LanguageQCState:
|
||||
"""Linguist submits work — transitions in_progress → pending_review."""
|
||||
job_doc = await db[_JOBS].find_one({"_id": job_id})
|
||||
if not job_doc:
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
|
||||
current_state_raw = (job_doc.get("language_qc") or {}).get(lang, {})
|
||||
current_status = current_state_raw.get("status", LanguageQCStatus.PENDING.value) if isinstance(current_state_raw, dict) else LanguageQCStatus.PENDING.value
|
||||
assigned_linguist = current_state_raw.get("assigned_linguist_id") if isinstance(current_state_raw, dict) else None
|
||||
|
||||
if actor.role not in (UserRole.PRODUCTION, UserRole.ADMIN) and assigned_linguist != str(actor.id):
|
||||
raise HTTPException(status_code=403, detail="Not the assigned linguist")
|
||||
|
||||
if current_status not in (LanguageQCStatus.IN_PROGRESS.value, LanguageQCStatus.PENDING.value):
|
||||
raise HTTPException(status_code=400, detail=f"Cannot submit from status '{current_status}'")
|
||||
|
||||
now = datetime.utcnow()
|
||||
event = LanguageQCEvent(at=now, actor_user_id=str(actor.id), actor_email=actor.email, action="submit_for_review")
|
||||
history = (current_state_raw.get("history", []) if isinstance(current_state_raw, dict) else []) + [event.model_dump()]
|
||||
|
||||
updated_state = {
|
||||
**(current_state_raw if isinstance(current_state_raw, dict) else {}),
|
||||
"status": LanguageQCStatus.PENDING_REVIEW.value,
|
||||
"submitted_for_review_at": now,
|
||||
"history": history,
|
||||
}
|
||||
|
||||
full_language_qc = {**(job_doc.get("language_qc") or {}), lang: updated_state}
|
||||
qc_assignments = _rebuild_qc_assignments(full_language_qc)
|
||||
|
||||
await db[_JOBS].update_one(
|
||||
{"_id": job_id},
|
||||
{"$set": {
|
||||
f"language_qc.{lang}": updated_state,
|
||||
"qc_assignments": qc_assignments,
|
||||
"updated_at": now,
|
||||
}}
|
||||
)
|
||||
|
||||
await audit_logger.log_action(
|
||||
AuditAction.LANGUAGE_QC_SUBMIT,
|
||||
f"Language QC submitted for review: {lang} on job {job_id}",
|
||||
user=actor,
|
||||
request=http_request,
|
||||
resource_type="job_language",
|
||||
resource_id=f"{job_id}:{lang}",
|
||||
details={"lang": lang},
|
||||
)
|
||||
|
||||
# Notify reviewer
|
||||
reviewer_email = updated_state.get("assigned_reviewer_email")
|
||||
reviewer_name = updated_state.get("assigned_reviewer_name", "")
|
||||
if reviewer_email:
|
||||
try:
|
||||
from ..services.emailer import email_service
|
||||
await email_service.send_language_submitted_email(
|
||||
to_email=reviewer_email,
|
||||
full_name=reviewer_name,
|
||||
job_title=job_doc.get("title", job_id),
|
||||
lang=lang,
|
||||
linguist_name=updated_state.get("assigned_linguist_name", ""),
|
||||
deep_link=_deep_link(job_id, lang),
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to send submission notification email")
|
||||
|
||||
return LanguageQCState(**updated_state)
|
||||
|
||||
|
||||
async def open_review(
|
||||
db: AsyncIOMotorDatabase,
|
||||
job_id: str,
|
||||
lang: str,
|
||||
actor: User,
|
||||
*,
|
||||
http_request=None,
|
||||
) -> LanguageQCState:
|
||||
"""Reviewer opens the language — transitions pending_review → in_review."""
|
||||
job_doc = await db[_JOBS].find_one({"_id": job_id})
|
||||
if not job_doc:
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
|
||||
current_state_raw = (job_doc.get("language_qc") or {}).get(lang, {})
|
||||
current_status = current_state_raw.get("status", LanguageQCStatus.PENDING.value) if isinstance(current_state_raw, dict) else LanguageQCStatus.PENDING.value
|
||||
assigned_reviewer = current_state_raw.get("assigned_reviewer_id") if isinstance(current_state_raw, dict) else None
|
||||
|
||||
if actor.role not in (UserRole.PRODUCTION, UserRole.ADMIN) and assigned_reviewer != str(actor.id):
|
||||
raise HTTPException(status_code=403, detail="Not the assigned reviewer")
|
||||
|
||||
if current_status != LanguageQCStatus.PENDING_REVIEW.value:
|
||||
return LanguageQCState(**(current_state_raw if isinstance(current_state_raw, dict) else {}))
|
||||
|
||||
now = datetime.utcnow()
|
||||
event = LanguageQCEvent(at=now, actor_user_id=str(actor.id), actor_email=actor.email, action="open_review")
|
||||
history = (current_state_raw.get("history", []) if isinstance(current_state_raw, dict) else []) + [event.model_dump()]
|
||||
|
||||
updated_state = {
|
||||
**(current_state_raw if isinstance(current_state_raw, dict) else {}),
|
||||
"status": LanguageQCStatus.IN_REVIEW.value,
|
||||
"review_started_at": now,
|
||||
"history": history,
|
||||
}
|
||||
|
||||
await db[_JOBS].update_one(
|
||||
{"_id": job_id},
|
||||
{"$set": {
|
||||
f"language_qc.{lang}.status": LanguageQCStatus.IN_REVIEW.value,
|
||||
f"language_qc.{lang}.review_started_at": now,
|
||||
f"language_qc.{lang}.history": history,
|
||||
"updated_at": now,
|
||||
}}
|
||||
)
|
||||
|
||||
await audit_logger.log_action(
|
||||
AuditAction.LANGUAGE_QC_OPEN_REVIEW,
|
||||
f"Language QC review opened: {lang} on job {job_id}",
|
||||
user=actor,
|
||||
request=http_request,
|
||||
resource_type="job_language",
|
||||
resource_id=f"{job_id}:{lang}",
|
||||
details={"lang": lang},
|
||||
)
|
||||
|
||||
return LanguageQCState(**updated_state)
|
||||
|
||||
|
||||
# ── Approve / Reject ──────────────────────────────────────────────────────────
|
||||
|
||||
async def approve_language(
|
||||
db: AsyncIOMotorDatabase,
|
||||
job_id: str,
|
||||
|
|
@ -245,7 +544,7 @@ async def approve_language(
|
|||
if job_doc["status"] not in (JobStatus.PENDING_QC.value, JobStatus.QC_FEEDBACK.value):
|
||||
raise HTTPException(status_code=400, detail="Job is not in QC status")
|
||||
|
||||
_assert_can_act(job_doc, lang, actor)
|
||||
_assert_can_approve(job_doc, lang, actor)
|
||||
|
||||
now = datetime.utcnow()
|
||||
event = LanguageQCEvent(at=now, actor_user_id=str(actor.id), actor_email=actor.email, action="approve", notes=notes)
|
||||
|
|
@ -284,7 +583,23 @@ async def approve_language(
|
|||
details={"lang": lang, "notes": notes},
|
||||
)
|
||||
|
||||
# Re-fetch to check if we should advance the job
|
||||
# Notify linguist + any other recipients
|
||||
recipients = _qc_recipients(job_doc, current_state_raw if isinstance(current_state_raw, dict) else {}, exclude_user_id=actor.email)
|
||||
if recipients:
|
||||
try:
|
||||
from ..services.emailer import email_service
|
||||
await asyncio.gather(*[
|
||||
email_service.send_qc_approved_email(
|
||||
to_email=email, full_name=name,
|
||||
job_title=job_doc.get("title", job_id), lang=lang,
|
||||
approver_name=actor.full_name or actor.email,
|
||||
deep_link=_deep_link(job_id, lang),
|
||||
)
|
||||
for email, name in recipients
|
||||
], return_exceptions=True)
|
||||
except Exception:
|
||||
logger.exception("Failed to send approval emails")
|
||||
|
||||
refreshed = await db[_JOBS].find_one({"_id": job_id})
|
||||
await _maybe_advance_job(db, refreshed)
|
||||
|
||||
|
|
@ -310,7 +625,7 @@ async def reject_language(
|
|||
if job_doc["status"] not in (JobStatus.PENDING_QC.value, JobStatus.QC_FEEDBACK.value):
|
||||
raise HTTPException(status_code=400, detail="Job is not in QC status")
|
||||
|
||||
_assert_can_act(job_doc, lang, actor)
|
||||
_assert_can_approve(job_doc, lang, actor)
|
||||
|
||||
now = datetime.utcnow()
|
||||
event = LanguageQCEvent(at=now, actor_user_id=str(actor.id), actor_email=actor.email, action="reject", notes=notes)
|
||||
|
|
@ -319,11 +634,12 @@ async def reject_language(
|
|||
|
||||
updated_state = {
|
||||
**(current_state_raw if isinstance(current_state_raw, dict) else {}),
|
||||
"status": LanguageQCStatus.REJECTED.value,
|
||||
"status": LanguageQCStatus.IN_PROGRESS.value, # send back to linguist
|
||||
"reviewed_at": now,
|
||||
"reviewed_by_user_id": str(actor.id),
|
||||
"reviewed_by_email": actor.email,
|
||||
"notes": notes,
|
||||
"submitted_for_review_at": None,
|
||||
"history": history,
|
||||
}
|
||||
|
||||
|
|
@ -339,7 +655,6 @@ async def reject_language(
|
|||
}}
|
||||
)
|
||||
|
||||
# Move job to qc_feedback
|
||||
await db[_JOBS].update_one(
|
||||
{"_id": job_id},
|
||||
{
|
||||
|
|
@ -359,6 +674,23 @@ async def reject_language(
|
|||
details={"lang": lang, "notes": notes},
|
||||
)
|
||||
|
||||
recipients = _qc_recipients(job_doc, current_state_raw if isinstance(current_state_raw, dict) else {}, exclude_user_id=actor.email)
|
||||
if recipients:
|
||||
try:
|
||||
from ..services.emailer import email_service
|
||||
await asyncio.gather(*[
|
||||
email_service.send_qc_rejected_email(
|
||||
to_email=email, full_name=name,
|
||||
job_title=job_doc.get("title", job_id), lang=lang,
|
||||
reviewer_name=actor.full_name or actor.email,
|
||||
reason=notes,
|
||||
deep_link=_deep_link(job_id, lang),
|
||||
)
|
||||
for email, name in recipients
|
||||
], return_exceptions=True)
|
||||
except Exception:
|
||||
logger.exception("Failed to send rejection emails")
|
||||
|
||||
return LanguageQCState(**updated_state)
|
||||
|
||||
|
||||
|
|
@ -372,7 +704,6 @@ async def reopen_language(
|
|||
notes: Optional[str] = None,
|
||||
) -> LanguageQCState:
|
||||
"""PROD/ADMIN only — resets an approved language back to pending for re-review."""
|
||||
from ..models.user import UserRole
|
||||
if actor.role not in (UserRole.PRODUCTION, UserRole.ADMIN):
|
||||
raise HTTPException(status_code=403, detail="Only PRODUCTION or ADMIN can reopen a language")
|
||||
|
||||
|
|
@ -391,6 +722,8 @@ async def reopen_language(
|
|||
"reviewed_at": None,
|
||||
"reviewed_by_user_id": None,
|
||||
"reviewed_by_email": None,
|
||||
"submitted_for_review_at": None,
|
||||
"review_started_at": None,
|
||||
"notes": notes,
|
||||
"history": history,
|
||||
}
|
||||
|
|
@ -407,7 +740,6 @@ async def reopen_language(
|
|||
}}
|
||||
)
|
||||
|
||||
# If the job had advanced to pending_final_review, pull it back to pending_qc
|
||||
if job_doc["status"] == JobStatus.PENDING_FINAL_REVIEW.value:
|
||||
await db[_JOBS].update_one(
|
||||
{"_id": job_id},
|
||||
|
|
@ -430,30 +762,84 @@ async def reopen_language(
|
|||
return LanguageQCState(**updated_state)
|
||||
|
||||
|
||||
async def reset_all_for_return_to_qc(db: AsyncIOMotorDatabase, job_id: str) -> None:
|
||||
"""Called by return_to_qc — resets statuses to pending while preserving assignments and history."""
|
||||
job_doc = await db[_JOBS].find_one({"_id": job_id}, {"language_qc": 1})
|
||||
# ── Comments ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async def add_comment(
|
||||
db: AsyncIOMotorDatabase,
|
||||
job_id: str,
|
||||
lang: str,
|
||||
actor: User,
|
||||
body: str,
|
||||
*,
|
||||
http_request=None,
|
||||
) -> LanguageQCComment:
|
||||
if not body or not body.strip():
|
||||
raise HTTPException(status_code=422, detail="Comment body cannot be empty")
|
||||
if len(body) > 4000:
|
||||
raise HTTPException(status_code=422, detail="Comment too long (max 4000 chars)")
|
||||
|
||||
job_doc = await db[_JOBS].find_one({"_id": job_id})
|
||||
if not job_doc:
|
||||
return
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
|
||||
lang_qc = job_doc.get("language_qc") or {}
|
||||
updates: dict[str, Any] = {}
|
||||
for lang, state in lang_qc.items():
|
||||
if isinstance(state, dict):
|
||||
updates[f"language_qc.{lang}.status"] = LanguageQCStatus.PENDING.value
|
||||
updates[f"language_qc.{lang}.reviewed_at"] = None
|
||||
updates[f"language_qc.{lang}.reviewed_by_user_id"] = None
|
||||
updates[f"language_qc.{lang}.reviewed_by_email"] = None
|
||||
# Gate: only assigned linguist, assigned reviewer, or PM/PROD/ADMIN
|
||||
current_state_raw = (job_doc.get("language_qc") or {}).get(lang, {})
|
||||
assigned_linguist = current_state_raw.get("assigned_linguist_id") if isinstance(current_state_raw, dict) else None
|
||||
assigned_reviewer = current_state_raw.get("assigned_reviewer_id") if isinstance(current_state_raw, dict) else None
|
||||
if actor.role not in (UserRole.PRODUCTION, UserRole.ADMIN, UserRole.PROJECT_MANAGER):
|
||||
if str(actor.id) not in (assigned_linguist, assigned_reviewer):
|
||||
raise HTTPException(status_code=403, detail="Not authorized to comment on this language")
|
||||
|
||||
if updates:
|
||||
# Rebuild qc_assignments with reset statuses
|
||||
updated_lang_qc = {}
|
||||
for lang, state in lang_qc.items():
|
||||
updated_lang_qc[lang] = {**(state if isinstance(state, dict) else {}), "status": LanguageQCStatus.PENDING.value}
|
||||
now = datetime.utcnow()
|
||||
comment = LanguageQCComment(
|
||||
id=str(uuid4()),
|
||||
author_id=str(actor.id),
|
||||
author_name=actor.full_name or "",
|
||||
author_email=actor.email,
|
||||
body=body.strip(),
|
||||
created_at=now,
|
||||
)
|
||||
|
||||
updates["qc_assignments"] = _rebuild_qc_assignments(updated_lang_qc)
|
||||
await db[_JOBS].update_one({"_id": job_id}, {"$set": updates})
|
||||
await db[_JOBS].update_one(
|
||||
{"_id": job_id},
|
||||
{
|
||||
"$push": {f"language_qc.{lang}.comments": comment.model_dump()},
|
||||
"$set": {"updated_at": now},
|
||||
}
|
||||
)
|
||||
|
||||
await audit_logger.log_action(
|
||||
AuditAction.LANGUAGE_QC_COMMENT,
|
||||
f"Comment added to language {lang} on job {job_id}",
|
||||
user=actor,
|
||||
request=http_request,
|
||||
resource_type="job_language",
|
||||
resource_id=f"{job_id}:{lang}",
|
||||
details={"lang": lang},
|
||||
)
|
||||
|
||||
# Fan-out to all other assignees
|
||||
recipients = _qc_recipients(job_doc, current_state_raw if isinstance(current_state_raw, dict) else {}, exclude_user_id=actor.email)
|
||||
if recipients:
|
||||
try:
|
||||
from ..services.emailer import email_service
|
||||
await asyncio.gather(*[
|
||||
email_service.send_qc_comment_email(
|
||||
to_email=email, full_name=name,
|
||||
job_title=job_doc.get("title", job_id), lang=lang,
|
||||
author_name=actor.full_name or actor.email,
|
||||
comment_body=body.strip(),
|
||||
deep_link=_deep_link(job_id, lang),
|
||||
)
|
||||
for email, name in recipients
|
||||
], return_exceptions=True)
|
||||
except Exception:
|
||||
logger.exception("Failed to send comment notification emails")
|
||||
|
||||
return comment
|
||||
|
||||
|
||||
# ── Queue / list ──────────────────────────────────────────────────────────────
|
||||
|
||||
async def list_for_linguist(
|
||||
db: AsyncIOMotorDatabase,
|
||||
|
|
@ -471,7 +857,6 @@ async def list_for_linguist(
|
|||
cursor = db[_JOBS].find(query, {"title": 1, "status": 1, "language_qc": 1, "qc_assignments": 1, "created_at": 1, "updated_at": 1}).skip(skip).limit(limit).sort("updated_at", -1)
|
||||
jobs = await cursor.to_list(length=limit)
|
||||
|
||||
# Filter qc_assignments to only include this linguist's languages
|
||||
result = []
|
||||
for job in jobs:
|
||||
my_langs = [a for a in (job.get("qc_assignments") or []) if a.get("linguist_id") == linguist_id]
|
||||
|
|
@ -479,6 +864,38 @@ async def list_for_linguist(
|
|||
return result
|
||||
|
||||
|
||||
async def list_for_reviewer(
|
||||
db: AsyncIOMotorDatabase,
|
||||
reviewer_id: str,
|
||||
*,
|
||||
status_filter: Optional[str] = None,
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
) -> list[dict]:
|
||||
"""Return jobs where the reviewer is assigned to at least one language."""
|
||||
# language_qc is an embedded dict keyed by lang code; scan in Python
|
||||
all_jobs_cursor = db[_JOBS].find(
|
||||
{},
|
||||
{"title": 1, "status": 1, "language_qc": 1, "qc_assignments": 1, "created_at": 1, "updated_at": 1}
|
||||
).sort("updated_at", -1).skip(skip).limit(limit * 5) # over-fetch, filter in Python
|
||||
|
||||
all_jobs = await all_jobs_cursor.to_list(length=limit * 5)
|
||||
|
||||
result = []
|
||||
for job in all_jobs:
|
||||
my_langs = []
|
||||
for lang, state in (job.get("language_qc") or {}).items():
|
||||
if isinstance(state, dict) and state.get("assigned_reviewer_id") == reviewer_id:
|
||||
if not status_filter or state.get("status") == status_filter:
|
||||
my_langs.append({"lang": lang, "status": state.get("status", "pending")})
|
||||
if my_langs:
|
||||
result.append({**job, "_my_assignments": my_langs})
|
||||
if len(result) >= limit:
|
||||
break
|
||||
|
||||
return result
|
||||
|
||||
|
||||
async def seed_language_qc_for_job(db: AsyncIOMotorDatabase, job_doc: dict) -> None:
|
||||
"""Idempotently seed language_qc entries for all languages in a job's outputs."""
|
||||
job_id = str(job_doc["_id"])
|
||||
|
|
@ -498,8 +915,12 @@ async def seed_language_qc_for_job(db: AsyncIOMotorDatabase, job_doc: dict) -> N
|
|||
|
||||
for lang in all_langs:
|
||||
if lang in existing_qc:
|
||||
continue # already seeded
|
||||
state: dict[str, Any] = {"status": LanguageQCStatus.APPROVED.value if is_approved else LanguageQCStatus.PENDING.value, "history": []}
|
||||
continue
|
||||
state: dict[str, Any] = {
|
||||
"status": LanguageQCStatus.APPROVED.value if is_approved else LanguageQCStatus.PENDING.value,
|
||||
"history": [],
|
||||
"comments": [],
|
||||
}
|
||||
if is_approved:
|
||||
state["reviewed_by_user_id"] = job_doc.get("review", {}).get("reviewer_id")
|
||||
state["reviewed_at"] = job_doc.get("updated_at")
|
||||
|
|
@ -513,20 +934,55 @@ async def seed_language_qc_for_job(db: AsyncIOMotorDatabase, job_doc: dict) -> N
|
|||
await db[_JOBS].update_one({"_id": job_id}, {"$set": updates})
|
||||
|
||||
|
||||
async def reset_all_for_return_to_qc(db: AsyncIOMotorDatabase, job_id: str) -> None:
|
||||
"""Called by return_to_qc — resets statuses to pending while preserving assignments and history."""
|
||||
job_doc = await db[_JOBS].find_one({"_id": job_id}, {"language_qc": 1})
|
||||
if not job_doc:
|
||||
return
|
||||
|
||||
lang_qc = job_doc.get("language_qc") or {}
|
||||
updates: dict[str, Any] = {}
|
||||
for lang, state in lang_qc.items():
|
||||
if isinstance(state, dict):
|
||||
updates[f"language_qc.{lang}.status"] = LanguageQCStatus.PENDING.value
|
||||
updates[f"language_qc.{lang}.reviewed_at"] = None
|
||||
updates[f"language_qc.{lang}.reviewed_by_user_id"] = None
|
||||
updates[f"language_qc.{lang}.reviewed_by_email"] = None
|
||||
updates[f"language_qc.{lang}.submitted_for_review_at"] = None
|
||||
updates[f"language_qc.{lang}.review_started_at"] = None
|
||||
|
||||
if updates:
|
||||
updated_lang_qc = {}
|
||||
for lang, state in lang_qc.items():
|
||||
updated_lang_qc[lang] = {**(state if isinstance(state, dict) else {}), "status": LanguageQCStatus.PENDING.value}
|
||||
|
||||
updates["qc_assignments"] = _rebuild_qc_assignments(updated_lang_qc)
|
||||
await db[_JOBS].update_one({"_id": job_id}, {"$set": updates})
|
||||
|
||||
|
||||
# ── Internal ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def _assert_can_act(job_doc: dict, lang: str, actor: User) -> None:
|
||||
"""Raise 403 if actor is not the assigned linguist and not PROD/ADMIN."""
|
||||
from ..models.user import UserRole
|
||||
def _assert_can_approve(job_doc: dict, lang: str, actor: User) -> None:
|
||||
"""Raise 403 if actor is not the assigned reviewer (or PROD/ADMIN)."""
|
||||
if actor.role in (UserRole.PRODUCTION, UserRole.ADMIN):
|
||||
return
|
||||
|
||||
state = (job_doc.get("language_qc") or {}).get(lang, {})
|
||||
assigned = state.get("assigned_linguist_id") if isinstance(state, dict) else None
|
||||
if assigned is None:
|
||||
raise HTTPException(status_code=403, detail=f"Language '{lang}' has no assigned linguist")
|
||||
if assigned != str(actor.id):
|
||||
raise HTTPException(status_code=403, detail=f"You are not assigned to language '{lang}'")
|
||||
assigned_reviewer = state.get("assigned_reviewer_id") if isinstance(state, dict) else None
|
||||
|
||||
if assigned_reviewer is None:
|
||||
# Fallback: allow assigned linguist to approve if no reviewer assigned (backward compat)
|
||||
assigned_linguist = state.get("assigned_linguist_id") if isinstance(state, dict) else None
|
||||
if assigned_linguist == str(actor.id):
|
||||
return
|
||||
raise HTTPException(status_code=403, detail=f"Language '{lang}' has no assigned reviewer")
|
||||
|
||||
if assigned_reviewer != str(actor.id):
|
||||
raise HTTPException(status_code=403, detail=f"You are not the assigned reviewer for language '{lang}'")
|
||||
|
||||
|
||||
# Keep old name for any remaining callers
|
||||
_assert_can_act = _assert_can_approve
|
||||
|
||||
|
||||
async def _maybe_advance_job(db: AsyncIOMotorDatabase, job_doc: dict) -> None:
|
||||
|
|
|
|||
|
|
@ -2,14 +2,15 @@
|
|||
Celery task: compute and store Gemini embeddings for all terms in a glossary version.
|
||||
|
||||
Runs as a background job after glossary ingestion so the API response is fast.
|
||||
Processes terms in batches of 100 and updates embedded_count incrementally.
|
||||
Processes terms in concurrent batches of 250 (5 batches in parallel).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
from bson import ObjectId
|
||||
from motor.motor_asyncio import AsyncIOMotorClient
|
||||
from motor.motor_asyncio import AsyncIOMotorClient, AsyncIOMotorDatabase
|
||||
|
||||
from ..core.config import settings
|
||||
from ..core.logging import get_logger
|
||||
|
|
@ -18,15 +19,12 @@ from . import celery_app
|
|||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
_BATCH_SIZE = 100
|
||||
_BATCH_SIZE = 250
|
||||
_CONCURRENCY = 5
|
||||
|
||||
|
||||
@celery_app.task(name="embed_glossary_version", bind=True, max_retries=3)
|
||||
def embed_glossary_version_task(self, version_id: str) -> dict:
|
||||
"""
|
||||
Compute embeddings for all GlossaryTerms of `version_id`.
|
||||
Updates embedded_count and embedding_status on the GlossaryVersion doc.
|
||||
"""
|
||||
try:
|
||||
result = asyncio.run(_async_embed_version(version_id))
|
||||
return result
|
||||
|
|
@ -35,53 +33,64 @@ def embed_glossary_version_task(self, version_id: str) -> dict:
|
|||
raise self.retry(exc=exc, countdown=60) from None
|
||||
|
||||
|
||||
async def _async_embed_version(version_id: str) -> dict:
|
||||
async def _embed_batch(
|
||||
db: AsyncIOMotorDatabase,
|
||||
version_id: str,
|
||||
batch: list[dict[str, Any]],
|
||||
sem: asyncio.Semaphore,
|
||||
counter: list[int],
|
||||
total: int,
|
||||
) -> None:
|
||||
from pymongo import UpdateOne
|
||||
from ..services.embedding_service import embedding_service
|
||||
|
||||
async with sem:
|
||||
texts = [t["source_term"] for t in batch]
|
||||
ids = [t["_id"] for t in batch]
|
||||
embeddings = await embedding_service.embed_texts(texts)
|
||||
|
||||
ops = [
|
||||
UpdateOne({"_id": tid}, {"$set": {"embedding": emb}})
|
||||
for tid, emb in zip(ids, embeddings, strict=False)
|
||||
]
|
||||
if ops:
|
||||
await db.glossary_terms.bulk_write(ops, ordered=False)
|
||||
|
||||
counter[0] += len(batch)
|
||||
await db.glossary_versions.update_one(
|
||||
{"_id": ObjectId(version_id)},
|
||||
{"$set": {"embedded_count": counter[0]}},
|
||||
)
|
||||
logger.info(f"Version {version_id}: embedded {counter[0]}/{total}")
|
||||
|
||||
|
||||
async def _async_embed_version(version_id: str) -> dict:
|
||||
mongo_client = AsyncIOMotorClient(settings.mongodb_uri)
|
||||
db = mongo_client[settings.mongodb_db]
|
||||
|
||||
try:
|
||||
# Mark in-progress
|
||||
await db.glossary_versions.update_one(
|
||||
{"_id": ObjectId(version_id)},
|
||||
{"$set": {"embedding_status": EmbeddingStatus.IN_PROGRESS.value}},
|
||||
)
|
||||
|
||||
# Fetch all terms without embeddings
|
||||
cursor = db.glossary_terms.find(
|
||||
{"version_id": version_id, "embedding": None},
|
||||
{"_id": 1, "source_term": 1},
|
||||
)
|
||||
terms = await cursor.to_list(length=None)
|
||||
total = len(terms)
|
||||
logger.info(f"Embedding {total} terms for version {version_id}")
|
||||
logger.info(f"Embedding {total} terms for version {version_id} (batch={_BATCH_SIZE}, concurrency={_CONCURRENCY})")
|
||||
|
||||
embedded_count = 0
|
||||
for i in range(0, total, _BATCH_SIZE):
|
||||
batch = terms[i: i + _BATCH_SIZE]
|
||||
texts = [t["source_term"] for t in batch]
|
||||
ids = [t["_id"] for t in batch]
|
||||
batches = [terms[i: i + _BATCH_SIZE] for i in range(0, total, _BATCH_SIZE)]
|
||||
sem = asyncio.Semaphore(_CONCURRENCY)
|
||||
counter = [0]
|
||||
|
||||
embeddings = await embedding_service.embed_texts(texts)
|
||||
await asyncio.gather(*[
|
||||
_embed_batch(db, version_id, batch, sem, counter, total)
|
||||
for batch in batches
|
||||
])
|
||||
|
||||
# Bulk update
|
||||
ops = []
|
||||
from pymongo import UpdateOne
|
||||
for term_id, embedding in zip(ids, embeddings, strict=False):
|
||||
ops.append(UpdateOne({"_id": term_id}, {"$set": {"embedding": embedding}}))
|
||||
|
||||
if ops:
|
||||
await db.glossary_terms.bulk_write(ops, ordered=False)
|
||||
|
||||
embedded_count += len(batch)
|
||||
await db.glossary_versions.update_one(
|
||||
{"_id": ObjectId(version_id)},
|
||||
{"$set": {"embedded_count": embedded_count}},
|
||||
)
|
||||
logger.info(f"Version {version_id}: embedded {embedded_count}/{total}")
|
||||
|
||||
# Mark done
|
||||
await db.glossary_versions.update_one(
|
||||
{"_id": ObjectId(version_id)},
|
||||
{"$set": {
|
||||
|
|
|
|||
|
|
@ -730,13 +730,38 @@ class ApiClient {
|
|||
return r.data;
|
||||
}
|
||||
|
||||
async assignLanguageQC(jobId: string, lang: string, linguistUserId: string, notes?: string): Promise<import('../types/api').LanguageQCStateResponse> {
|
||||
const r = await this.client.post(`/jobs/${jobId}/languages/${lang}/assign`, { linguist_user_id: linguistUserId, notes });
|
||||
async assignLanguageQC(jobId: string, lang: string, linguistUserId: string, notes?: string, deadline?: string): Promise<import('../types/api').LanguageQCStateResponse> {
|
||||
const r = await this.client.post(`/jobs/${jobId}/languages/${lang}/assign`, { linguist_user_id: linguistUserId, notes, deadline });
|
||||
return r.data;
|
||||
}
|
||||
|
||||
async reassignLanguageQC(jobId: string, lang: string, linguistUserId: string, notes?: string): Promise<import('../types/api').LanguageQCStateResponse> {
|
||||
const r = await this.client.post(`/jobs/${jobId}/languages/${lang}/reassign`, { linguist_user_id: linguistUserId, notes });
|
||||
async reassignLanguageQC(jobId: string, lang: string, linguistUserId: string, notes?: string, deadline?: string): Promise<import('../types/api').LanguageQCStateResponse> {
|
||||
const r = await this.client.post(`/jobs/${jobId}/languages/${lang}/reassign`, { linguist_user_id: linguistUserId, notes, deadline });
|
||||
return r.data;
|
||||
}
|
||||
|
||||
async assignReviewerQC(jobId: string, lang: string, reviewerUserId: string, notes?: string, deadline?: string): Promise<import('../types/api').LanguageQCStateResponse> {
|
||||
const r = await this.client.post(`/jobs/${jobId}/languages/${lang}/assign-reviewer`, { reviewer_user_id: reviewerUserId, notes, deadline });
|
||||
return r.data;
|
||||
}
|
||||
|
||||
async reassignReviewerQC(jobId: string, lang: string, reviewerUserId: string, notes?: string, deadline?: string): Promise<import('../types/api').LanguageQCStateResponse> {
|
||||
const r = await this.client.post(`/jobs/${jobId}/languages/${lang}/reassign-reviewer`, { reviewer_user_id: reviewerUserId, notes, deadline });
|
||||
return r.data;
|
||||
}
|
||||
|
||||
async startLinguistWork(jobId: string, lang: string): Promise<import('../types/api').LanguageQCStateResponse> {
|
||||
const r = await this.client.post(`/jobs/${jobId}/languages/${lang}/start-work`);
|
||||
return r.data;
|
||||
}
|
||||
|
||||
async submitForReview(jobId: string, lang: string): Promise<import('../types/api').LanguageQCStateResponse> {
|
||||
const r = await this.client.post(`/jobs/${jobId}/languages/${lang}/submit`);
|
||||
return r.data;
|
||||
}
|
||||
|
||||
async openReview(jobId: string, lang: string): Promise<import('../types/api').LanguageQCStateResponse> {
|
||||
const r = await this.client.post(`/jobs/${jobId}/languages/${lang}/open-review`);
|
||||
return r.data;
|
||||
}
|
||||
|
||||
|
|
@ -755,8 +780,18 @@ class ApiClient {
|
|||
return r.data;
|
||||
}
|
||||
|
||||
async getMyLanguageQCQueue(statusFilter?: string, skip = 0, limit = 50): Promise<import('../types/api').QueueResponse> {
|
||||
const params = new URLSearchParams();
|
||||
async addQCComment(jobId: string, lang: string, body: string): Promise<import('../types/api').LanguageQCComment> {
|
||||
const r = await this.client.post(`/jobs/${jobId}/languages/${lang}/comments`, { body });
|
||||
return r.data;
|
||||
}
|
||||
|
||||
async listQCComments(jobId: string, lang: string): Promise<import('../types/api').LanguageQCComment[]> {
|
||||
const r = await this.client.get(`/jobs/${jobId}/languages/${lang}/comments`);
|
||||
return r.data;
|
||||
}
|
||||
|
||||
async getMyLanguageQCQueue(role: 'linguist' | 'reviewer' = 'linguist', statusFilter?: string, skip = 0, limit = 50): Promise<import('../types/api').QueueResponse> {
|
||||
const params = new URLSearchParams({ role });
|
||||
if (statusFilter) params.append('qc_status', statusFilter);
|
||||
params.append('skip', String(skip));
|
||||
params.append('limit', String(limit));
|
||||
|
|
@ -824,6 +859,11 @@ class ApiClient {
|
|||
return r.data;
|
||||
}
|
||||
|
||||
async reembedGlossaryVersion(clientId: string, glossaryId: string, versionId: string): Promise<{ status: string; version_id: string }> {
|
||||
const r = await this.client.post(`/clients/${clientId}/glossaries/${glossaryId}/versions/${versionId}/reembed`);
|
||||
return r.data;
|
||||
}
|
||||
|
||||
async getGlossaryTerms(
|
||||
clientId: string,
|
||||
glossaryId: string,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useState, useCallback } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api } from '../../lib/api';
|
||||
import type { AuditLogEntry, AuditLogQuery, AuditSeverity } from '../../types/api';
|
||||
import type { AuditLogEntry, AuditLogQuery, AuditSeverity, User } from '../../types/api';
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
|
|
@ -137,9 +137,15 @@ export function AuditLog() {
|
|||
const [severityFilter, setSeverityFilter] = useState('');
|
||||
const [successFilter, setSuccessFilter] = useState('');
|
||||
const [securityHours, setSecurityHours] = useState(24);
|
||||
const [userIdInput, setUserIdInput] = useState('');
|
||||
const [activeUserId, setActiveUserId] = useState('');
|
||||
|
||||
const usersQuery = useQuery({
|
||||
queryKey: ['admin-users-all'],
|
||||
queryFn: () => api.listUsers({ size: 500, active_only: false }),
|
||||
enabled: tab === 'user',
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const buildQuery = useCallback((): AuditLogQuery => ({
|
||||
...filters,
|
||||
skip: page * PAGE_SIZE,
|
||||
|
|
@ -282,24 +288,23 @@ export function AuditLog() {
|
|||
|
||||
{/* User activity tab controls */}
|
||||
{tab === 'user' && (
|
||||
<form
|
||||
className="flex gap-3 mb-4"
|
||||
onSubmit={e => { e.preventDefault(); setActiveUserId(userIdInput.trim()); }}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="User ID or email"
|
||||
value={userIdInput}
|
||||
onChange={e => setUserIdInput(e.target.value)}
|
||||
className="px-3 py-1.5 text-sm border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:outline-none w-72"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-1.5 text-sm bg-blue-600 text-white rounded-md hover:bg-blue-700"
|
||||
<div className="flex gap-3 mb-4 items-center">
|
||||
<select
|
||||
value={activeUserId}
|
||||
onChange={e => setActiveUserId(e.target.value)}
|
||||
className="px-3 py-1.5 text-sm border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:outline-none w-96"
|
||||
>
|
||||
Load
|
||||
</button>
|
||||
</form>
|
||||
<option value="">— Select a user —</option>
|
||||
{(usersQuery.data?.users ?? []).map((u: User) => (
|
||||
<option key={u.id} value={u.email}>
|
||||
{u.full_name ? `${u.full_name} (${u.email})` : u.email}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{usersQuery.isLoading && (
|
||||
<span className="text-sm text-gray-400">Loading users…</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results */}
|
||||
|
|
@ -340,7 +345,7 @@ export function AuditLog() {
|
|||
{tab === 'user' && (
|
||||
activeUserId
|
||||
? <AuditTable logs={userQuery.data ?? []} isLoading={userQuery.isLoading} />
|
||||
: <p className="text-gray-500 text-center py-8">Enter a user ID above and click Load.</p>
|
||||
: <p className="text-gray-500 text-center py-8">Select a user above to view their activity.</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -18,18 +18,24 @@ import { RerenderControls } from '../../components/RerenderControls';
|
|||
import { useToastContext } from '../../contexts/ToastContext';
|
||||
import { useAuthStore } from '../../lib/auth';
|
||||
import { apiClient } from '../../lib/api';
|
||||
import type { TTSPreferences, VideoSegmentMetadata, PausePointData, LanguageQCStatus } from '../../types/api';
|
||||
import type { TTSPreferences, VideoSegmentMetadata, PausePointData, LanguageQCStatus, LanguageQCComment } from '../../types/api';
|
||||
|
||||
// ── Status display helpers ────────────────────────────────────────────────────
|
||||
|
||||
const LANG_QC_BADGE: Record<LanguageQCStatus, string> = {
|
||||
pending: 'bg-gray-100 text-gray-500',
|
||||
in_progress: 'bg-yellow-100 text-yellow-700',
|
||||
pending_review: 'bg-orange-100 text-orange-700',
|
||||
in_review: 'bg-blue-100 text-blue-700',
|
||||
approved: 'bg-green-100 text-green-700',
|
||||
rejected: 'bg-red-100 text-red-700',
|
||||
};
|
||||
const LANG_QC_LABEL: Record<LanguageQCStatus, string> = {
|
||||
pending: 'Pending', in_progress: 'In progress', pending_review: 'Pending review',
|
||||
in_review: 'In review', approved: 'Approved', rejected: 'Rejected',
|
||||
};
|
||||
const LANG_QC_ICON: Record<LanguageQCStatus, string> = {
|
||||
pending: '⏳', in_review: '🔍', approved: '✓', rejected: '✕',
|
||||
pending: '⏳', in_progress: '✏️', pending_review: '📤', in_review: '🔍', approved: '✓', rejected: '✕',
|
||||
};
|
||||
|
||||
export function QCDetail() {
|
||||
|
|
@ -105,17 +111,28 @@ export function QCDetail() {
|
|||
|
||||
const [showLangRejectModal, setShowLangRejectModal] = useState(false);
|
||||
const [langRejectNotes, setLangRejectNotes] = useState('');
|
||||
|
||||
// Unified assign modal state — slot: 'linguist' | 'reviewer'
|
||||
const [showAssignModal, setShowAssignModal] = useState(false);
|
||||
const [assignLanguage, setAssignLanguage] = useState('');
|
||||
const [assigningLinguistId, setAssigningLinguistId] = useState('');
|
||||
const [assignSlot, setAssignSlot] = useState<'linguist' | 'reviewer'>('linguist');
|
||||
const [assigningUserId, setAssigningUserId] = useState('');
|
||||
const [assignDeadline, setAssignDeadline] = useState('');
|
||||
|
||||
// Load linguist users for assignment dropdown
|
||||
const { data: usersData } = useQuery({
|
||||
queryKey: ['users-list-linguists'],
|
||||
queryFn: () => apiClient.listUsers({ role: 'linguist', active_only: true, size: 100 }),
|
||||
// Comments panel per language
|
||||
const [openCommentLang, setOpenCommentLang] = useState<string | null>(null);
|
||||
const [commentDraft, setCommentDraft] = useState('');
|
||||
|
||||
const canAssign = authUser?.role === 'project_manager' || authUser?.role === 'production' || authUser?.role === 'admin';
|
||||
const canApproveAll = authUser?.role === 'production' || authUser?.role === 'admin';
|
||||
|
||||
// Load users for assignment dropdown (linguists or reviewers depending on slot)
|
||||
const { data: assignableUsersData } = useQuery({
|
||||
queryKey: ['users-list', assignSlot],
|
||||
queryFn: () => apiClient.listUsers({ role: assignSlot === 'linguist' ? 'linguist' : 'reviewer', active_only: true, size: 100 }),
|
||||
enabled: showAssignModal,
|
||||
});
|
||||
const linguistUsers = usersData?.users ?? [];
|
||||
const assignableUsers = assignableUsersData?.users ?? [];
|
||||
|
||||
const approveLanguageMutation = useMutation({
|
||||
mutationFn: ({ lang, notes }: { lang: string; notes?: string }) =>
|
||||
|
|
@ -138,26 +155,58 @@ export function QCDetail() {
|
|||
setShowLangRejectModal(false);
|
||||
setLangRejectNotes('');
|
||||
refetchLangQc();
|
||||
toast.toastOnly.success('Language rejected');
|
||||
toast.toastOnly.success('Language sent back for changes');
|
||||
},
|
||||
onError: (e: any) => toast.toastOnly.error(e?.response?.data?.detail || 'Rejection failed'),
|
||||
});
|
||||
|
||||
const assignLinguistMutation = useMutation({
|
||||
mutationFn: ({ lang, linguistId }: { lang: string; linguistId: string }) =>
|
||||
apiClient.assignLanguageQC(id!, lang, linguistId),
|
||||
onSuccess: () => {
|
||||
const assignMutation = useMutation({
|
||||
mutationFn: ({ lang, userId, slot, deadline }: { lang: string; userId: string; slot: 'linguist' | 'reviewer'; deadline?: string }) =>
|
||||
slot === 'linguist'
|
||||
? apiClient.assignLanguageQC(id!, lang, userId, undefined, deadline || undefined)
|
||||
: apiClient.assignReviewerQC(id!, lang, userId, undefined, deadline || undefined),
|
||||
onSuccess: (_, vars) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['language-qc', id] });
|
||||
refetchLangQc();
|
||||
setShowAssignModal(false);
|
||||
setAssigningLinguistId('');
|
||||
toast.toastOnly.success('Linguist assigned');
|
||||
setAssigningUserId('');
|
||||
setAssignDeadline('');
|
||||
toast.toastOnly.success(`${vars.slot === 'linguist' ? 'Linguist' : 'Reviewer'} assigned`);
|
||||
},
|
||||
onError: (e: any) => toast.toastOnly.error(e?.response?.data?.detail || 'Assignment failed'),
|
||||
});
|
||||
|
||||
const canAssign = authUser?.role === 'project_manager' || authUser?.role === 'production' || authUser?.role === 'admin';
|
||||
const canApproveAll = authUser?.role === 'production' || authUser?.role === 'admin';
|
||||
const submitForReviewMutation = useMutation({
|
||||
mutationFn: (lang: string) => apiClient.submitForReview(id!, lang),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['language-qc', id] });
|
||||
refetchLangQc();
|
||||
toast.toastOnly.success('Submitted for review — reviewer notified');
|
||||
},
|
||||
onError: (e: any) => toast.toastOnly.error(e?.response?.data?.detail || 'Submit failed'),
|
||||
});
|
||||
|
||||
const openReviewMutation = useMutation({
|
||||
mutationFn: (lang: string) => apiClient.openReview(id!, lang),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['language-qc', id] });
|
||||
refetchLangQc();
|
||||
toast.toastOnly.success('Review opened');
|
||||
},
|
||||
onError: (e: any) => toast.toastOnly.error(e?.response?.data?.detail || 'Open failed'),
|
||||
});
|
||||
|
||||
const addCommentMutation = useMutation({
|
||||
mutationFn: ({ lang, body }: { lang: string; body: string }) => apiClient.addQCComment(id!, lang, body),
|
||||
onSuccess: (newComment, vars) => {
|
||||
queryClient.setQueryData(['qc-comments', id, vars.lang], (prev: LanguageQCComment[] | undefined) =>
|
||||
[...(prev ?? []), newComment],
|
||||
);
|
||||
setCommentDraft('');
|
||||
toast.toastOnly.success('Comment added');
|
||||
},
|
||||
onError: (e: any) => toast.toastOnly.error(e?.response?.data?.detail || 'Comment failed'),
|
||||
});
|
||||
|
||||
// Total QC progress
|
||||
const totalLangs = availableLanguages.length;
|
||||
|
|
@ -720,83 +769,209 @@ export function QCDetail() {
|
|||
</span>
|
||||
)}
|
||||
</div>
|
||||
{canAssign && (
|
||||
<button
|
||||
onClick={() => { setAssignLanguage(selectedLanguage); setShowAssignModal(true); }}
|
||||
className="text-xs text-blue-600 hover:underline"
|
||||
>
|
||||
+ Assign linguist
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
{totalLangs > 1 && (
|
||||
<div className="h-1.5 bg-gray-200 rounded-full mb-3 overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-green-500 rounded-full transition-all"
|
||||
style={{ width: `${totalLangs > 0 ? (approvedLangs / totalLangs) * 100 : 0}%` }}
|
||||
/>
|
||||
<div className="h-full bg-green-500 rounded-full transition-all"
|
||||
style={{ width: `${totalLangs > 0 ? (approvedLangs / totalLangs) * 100 : 0}%` }} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{/* Language cards */}
|
||||
<div className="space-y-3">
|
||||
{availableLanguages.map((lang) => {
|
||||
const qcState = langQcMap[lang];
|
||||
const qcStatus = (qcState?.status ?? 'pending') as LanguageQCStatus;
|
||||
const isActive = selectedLanguage === lang;
|
||||
const isMyLang = authUser?.role === 'linguist' && qcState?.assigned_linguist_id === authUser?.id;
|
||||
const canActOnThis = isMyLang || canApproveAll;
|
||||
const myId = authUser?.id;
|
||||
|
||||
const isAssignedLinguist = qcState?.assigned_linguist_id === myId;
|
||||
const isAssignedReviewer = qcState?.assigned_reviewer_id === myId;
|
||||
|
||||
// Linguist sees: start-work, submit
|
||||
const canStartWork = isAssignedLinguist && (qcStatus === 'pending' || qcStatus === 'rejected');
|
||||
const canSubmit = isAssignedLinguist && qcStatus === 'in_progress';
|
||||
// Reviewer sees: open-review, approve, reject/request-changes
|
||||
const canOpenReview = (isAssignedReviewer || canApproveAll) && qcStatus === 'pending_review';
|
||||
const canApproveThis = (isAssignedReviewer || canApproveAll) && (qcStatus === 'in_review' || (canApproveAll && qcStatus !== 'approved'));
|
||||
const canRejectThis = (isAssignedReviewer || canApproveAll) && (qcStatus === 'in_review' || (canApproveAll && qcStatus !== 'rejected'));
|
||||
|
||||
const isCommentsOpen = openCommentLang === lang;
|
||||
|
||||
// Comments for this language
|
||||
const commentsQuery = isCommentsOpen
|
||||
? { data: qcState?.comments ?? [] }
|
||||
: null;
|
||||
|
||||
// Deadline formatting
|
||||
const linguistDeadline = qcState?.linguist_deadline ? new Date(qcState.linguist_deadline).toLocaleDateString() : null;
|
||||
const reviewerDeadline = qcState?.reviewer_deadline ? new Date(qcState.reviewer_deadline).toLocaleDateString() : null;
|
||||
|
||||
return (
|
||||
<div key={lang} className="flex flex-col">
|
||||
<div key={lang} className={`rounded-xl border transition-colors ${isActive ? 'border-indigo-300 bg-indigo-50/50' : 'border-gray-200 bg-white'}`}>
|
||||
{/* Card header — language selector + status */}
|
||||
<button
|
||||
onClick={() => setSelectedLanguage(lang)}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-lg border transition-colors ${
|
||||
isActive
|
||||
? 'border-indigo-400 bg-indigo-50 text-indigo-700'
|
||||
: 'border-gray-200 bg-white text-gray-700 hover:bg-gray-50'
|
||||
}`}
|
||||
className="w-full flex items-center justify-between px-4 py-3 text-left"
|
||||
>
|
||||
<span className={`inline-flex items-center justify-center w-4 h-4 text-xs rounded-full ${LANG_QC_BADGE[qcStatus]}`}>
|
||||
{LANG_QC_ICON[qcStatus]}
|
||||
</span>
|
||||
<span>{lang.toUpperCase()}</span>
|
||||
{lang === sourceLanguage && <span className="text-xs opacity-60">(src)</span>}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`inline-flex items-center justify-center w-5 h-5 text-xs rounded-full ${LANG_QC_BADGE[qcStatus]}`}>
|
||||
{LANG_QC_ICON[qcStatus]}
|
||||
</span>
|
||||
<span className="font-medium text-sm">{lang.toUpperCase()}</span>
|
||||
{lang === sourceLanguage && <span className="text-xs text-gray-400">(source)</span>}
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${LANG_QC_BADGE[qcStatus]}`}>{LANG_QC_LABEL[qcStatus]}</span>
|
||||
</div>
|
||||
<span className="text-gray-400 text-xs">{isActive ? '▲' : '▼'}</span>
|
||||
</button>
|
||||
{qcState?.assigned_linguist_email && (
|
||||
<span className="text-xs text-gray-400 truncate max-w-[100px] px-1 mt-0.5" title={qcState.assigned_linguist_email}>
|
||||
{qcState.assigned_linguist_email.split('@')[0]}
|
||||
</span>
|
||||
)}
|
||||
{/* Per-language action buttons, shown inline under the active language */}
|
||||
{isActive && canActOnThis && (
|
||||
<div className="flex gap-1 mt-1">
|
||||
{qcStatus !== 'approved' && (
|
||||
|
||||
{/* Card body — only shown when active */}
|
||||
{isActive && (
|
||||
<div className="px-4 pb-4 space-y-3 border-t border-gray-100">
|
||||
{/* Two-slot assignment row */}
|
||||
<div className="grid grid-cols-2 gap-3 pt-3">
|
||||
{/* Linguist slot */}
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs font-medium text-gray-500 uppercase tracking-wide">Linguist</div>
|
||||
{qcState?.assigned_linguist_name || qcState?.assigned_linguist_email ? (
|
||||
<div className="text-sm text-gray-800">
|
||||
{qcState.assigned_linguist_name || qcState.assigned_linguist_email}
|
||||
{linguistDeadline && <span className="ml-1.5 text-xs text-orange-600">due {linguistDeadline}</span>}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-gray-400 italic">Unassigned</div>
|
||||
)}
|
||||
{canAssign && (
|
||||
<button
|
||||
onClick={() => { setAssignLanguage(lang); setAssignSlot('linguist'); setAssigningUserId(''); setAssignDeadline(''); setShowAssignModal(true); }}
|
||||
className="text-xs text-blue-600 hover:underline"
|
||||
>
|
||||
{qcState?.assigned_linguist_id ? 'Reassign' : 'Assign'} linguist
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Reviewer slot */}
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs font-medium text-gray-500 uppercase tracking-wide">Reviewer</div>
|
||||
{qcState?.assigned_reviewer_name || qcState?.assigned_reviewer_email ? (
|
||||
<div className="text-sm text-gray-800">
|
||||
{qcState.assigned_reviewer_name || qcState.assigned_reviewer_email}
|
||||
{reviewerDeadline && <span className="ml-1.5 text-xs text-orange-600">due {reviewerDeadline}</span>}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-gray-400 italic">Unassigned</div>
|
||||
)}
|
||||
{canAssign && (
|
||||
<button
|
||||
onClick={() => { setAssignLanguage(lang); setAssignSlot('reviewer'); setAssigningUserId(''); setAssignDeadline(''); setShowAssignModal(true); }}
|
||||
className="text-xs text-blue-600 hover:underline"
|
||||
>
|
||||
{qcState?.assigned_reviewer_id ? 'Reassign' : 'Assign'} reviewer
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Workflow action buttons */}
|
||||
<div className="flex flex-wrap gap-2 pt-1">
|
||||
{canStartWork && (
|
||||
<button
|
||||
onClick={() => apiClient.startLinguistWork(id!, lang).then(() => { queryClient.invalidateQueries({ queryKey: ['language-qc', id] }); refetchLangQc(); })}
|
||||
className="text-xs px-3 py-1.5 bg-yellow-500 text-white rounded-lg hover:bg-yellow-600"
|
||||
>
|
||||
Start work
|
||||
</button>
|
||||
)}
|
||||
{canSubmit && (
|
||||
<button
|
||||
onClick={() => submitForReviewMutation.mutate(lang)}
|
||||
disabled={submitForReviewMutation.isPending}
|
||||
className="text-xs px-3 py-1.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{submitForReviewMutation.isPending ? 'Submitting…' : '↑ Submit for review'}
|
||||
</button>
|
||||
)}
|
||||
{canOpenReview && (
|
||||
<button
|
||||
onClick={() => openReviewMutation.mutate(lang)}
|
||||
disabled={openReviewMutation.isPending}
|
||||
className="text-xs px-3 py-1.5 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50"
|
||||
>
|
||||
Open review
|
||||
</button>
|
||||
)}
|
||||
{canApproveThis && (
|
||||
<button
|
||||
onClick={() => approveLanguageMutation.mutate({ lang })}
|
||||
disabled={approveLanguageMutation.isPending}
|
||||
className="text-xs px-3 py-1.5 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50"
|
||||
>
|
||||
✓ Approve
|
||||
</button>
|
||||
)}
|
||||
{canRejectThis && (
|
||||
<button
|
||||
onClick={() => { setAssignLanguage(lang); setShowLangRejectModal(true); }}
|
||||
className="text-xs px-3 py-1.5 bg-red-500 text-white rounded-lg hover:bg-red-600"
|
||||
>
|
||||
✕ Request changes
|
||||
</button>
|
||||
)}
|
||||
{canApproveAll && qcStatus === 'approved' && (
|
||||
<button
|
||||
onClick={() => apiClient.reopenLanguageQC(id!, lang).then(() => { queryClient.invalidateQueries({ queryKey: ['language-qc', id] }); refetchLangQc(); })}
|
||||
className="text-xs px-3 py-1.5 border border-gray-300 text-gray-500 rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
Reopen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Comments toggle */}
|
||||
<div>
|
||||
<button
|
||||
onClick={() => approveLanguageMutation.mutate({ lang })}
|
||||
disabled={approveLanguageMutation.isPending}
|
||||
className="text-xs px-2 py-0.5 bg-green-600 text-white rounded hover:bg-green-700 disabled:opacity-50"
|
||||
onClick={() => setOpenCommentLang(isCommentsOpen ? null : lang)}
|
||||
className="text-xs text-gray-500 hover:text-gray-800 flex items-center gap-1"
|
||||
>
|
||||
✓ OK
|
||||
💬 {isCommentsOpen ? 'Hide comments' : `Comments (${qcState?.comments?.length ?? 0})`}
|
||||
</button>
|
||||
)}
|
||||
{qcStatus !== 'rejected' && (
|
||||
<button
|
||||
onClick={() => { setAssignLanguage(lang); setShowLangRejectModal(true); }}
|
||||
className="text-xs px-2 py-0.5 bg-red-500 text-white rounded hover:bg-red-600"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
{canApproveAll && qcStatus === 'approved' && (
|
||||
<button
|
||||
onClick={() => apiClient.reopenLanguageQC(id!, lang).then(() => { queryClient.invalidateQueries({ queryKey: ['language-qc', id] }); refetchLangQc(); })}
|
||||
className="text-xs px-2 py-0.5 border border-gray-300 text-gray-500 rounded hover:bg-gray-50"
|
||||
>
|
||||
Reopen
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isCommentsOpen && (
|
||||
<div className="mt-2 space-y-2">
|
||||
{(qcState?.comments ?? []).length === 0 && (
|
||||
<p className="text-xs text-gray-400 py-2">No comments yet.</p>
|
||||
)}
|
||||
{(qcState?.comments ?? []).map((c) => (
|
||||
<div key={c.id} className="bg-gray-50 rounded-lg p-3">
|
||||
<div className="flex items-baseline gap-2 mb-1">
|
||||
<span className="text-xs font-medium text-gray-700">{c.author_name || c.author_email}</span>
|
||||
<span className="text-xs text-gray-400">{new Date(c.created_at).toLocaleString()}</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-700 whitespace-pre-wrap">{c.body}</p>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex gap-2 mt-2">
|
||||
<textarea
|
||||
value={commentDraft}
|
||||
onChange={e => setCommentDraft(e.target.value)}
|
||||
rows={2}
|
||||
placeholder="Add a comment…"
|
||||
className="flex-1 text-sm border border-gray-300 rounded-lg px-3 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none"
|
||||
/>
|
||||
<button
|
||||
onClick={() => addCommentMutation.mutate({ lang, body: commentDraft })}
|
||||
disabled={!commentDraft.trim() || addCommentMutation.isPending}
|
||||
className="self-end px-3 py-1.5 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -805,17 +980,17 @@ export function QCDetail() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reject language modal */}
|
||||
{/* Reject / Request-changes modal */}
|
||||
{showLangRejectModal && (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg shadow-xl p-6 max-w-md w-full mx-4">
|
||||
<h3 className="text-lg font-medium mb-2">Reject {assignLanguage.toUpperCase()}?</h3>
|
||||
<p className="text-sm text-gray-500 mb-3">Please describe what needs to be corrected.</p>
|
||||
<h3 className="text-lg font-medium mb-2">Request changes — {assignLanguage.toUpperCase()}</h3>
|
||||
<p className="text-sm text-gray-500 mb-3">Describe what the linguist needs to correct. This will be sent to them by email.</p>
|
||||
<textarea
|
||||
value={langRejectNotes}
|
||||
onChange={e => setLangRejectNotes(e.target.value)}
|
||||
rows={3}
|
||||
placeholder="Required rejection notes..."
|
||||
placeholder="Required feedback…"
|
||||
className="w-full text-sm border border-gray-300 rounded p-2 focus:outline-none focus:ring-2 focus:ring-red-400"
|
||||
/>
|
||||
<div className="flex gap-3 justify-end mt-3">
|
||||
|
|
@ -828,39 +1003,60 @@ export function QCDetail() {
|
|||
disabled={!langRejectNotes.trim() || rejectLanguageMutation.isPending}
|
||||
className="px-4 py-2 text-sm bg-red-600 text-white rounded hover:bg-red-700 disabled:opacity-50"
|
||||
>
|
||||
{rejectLanguageMutation.isPending ? 'Rejecting…' : 'Confirm Rejection'}
|
||||
{rejectLanguageMutation.isPending ? 'Sending…' : 'Send feedback'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Assign linguist modal */}
|
||||
{/* Unified assign modal — linguist or reviewer */}
|
||||
{showAssignModal && (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg shadow-xl p-6 max-w-sm w-full mx-4">
|
||||
<h3 className="text-lg font-medium mb-3">Assign linguist — {assignLanguage.toUpperCase()}</h3>
|
||||
<select
|
||||
value={assigningLinguistId}
|
||||
onChange={e => setAssigningLinguistId(e.target.value)}
|
||||
className="w-full text-sm border border-gray-300 rounded px-2 py-1.5 mb-4"
|
||||
>
|
||||
<option value="">Select linguist…</option>
|
||||
{linguistUsers.map((u) => (
|
||||
<option key={u.id} value={u.id}>{u.full_name} ({u.email})</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="bg-white rounded-lg shadow-xl p-6 max-w-sm w-full mx-4 space-y-4">
|
||||
<h3 className="text-lg font-medium">
|
||||
Assign {assignSlot} — {assignLanguage.toUpperCase()}
|
||||
</h3>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">
|
||||
{assignSlot === 'linguist' ? 'Linguist' : 'Reviewer'}
|
||||
</label>
|
||||
<select
|
||||
value={assigningUserId}
|
||||
onChange={e => setAssigningUserId(e.target.value)}
|
||||
className="w-full text-sm border border-gray-300 rounded px-2 py-1.5"
|
||||
>
|
||||
<option value="">Select {assignSlot}…</option>
|
||||
{assignableUsers.map((u) => (
|
||||
<option key={u.id} value={u.id}>{u.full_name} ({u.email})</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">Deadline (optional)</label>
|
||||
<input
|
||||
type="date"
|
||||
value={assignDeadline}
|
||||
onChange={e => setAssignDeadline(e.target.value)}
|
||||
className="w-full text-sm border border-gray-300 rounded px-2 py-1.5"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-3 justify-end">
|
||||
<button onClick={() => { setShowAssignModal(false); setAssigningLinguistId(''); }}
|
||||
<button onClick={() => { setShowAssignModal(false); setAssigningUserId(''); setAssignDeadline(''); }}
|
||||
className="px-4 py-2 text-sm border border-gray-300 rounded text-gray-700 hover:bg-gray-50">
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => assignLinguistMutation.mutate({ lang: assignLanguage, linguistId: assigningLinguistId })}
|
||||
disabled={!assigningLinguistId || assignLinguistMutation.isPending}
|
||||
onClick={() => assignMutation.mutate({
|
||||
lang: assignLanguage,
|
||||
userId: assigningUserId,
|
||||
slot: assignSlot,
|
||||
deadline: assignDeadline || undefined,
|
||||
})}
|
||||
disabled={!assigningUserId || assignMutation.isPending}
|
||||
className="px-4 py-2 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{assignLinguistMutation.isPending ? 'Assigning…' : 'Assign'}
|
||||
{assignMutation.isPending ? 'Assigning…' : 'Assign'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -72,6 +72,15 @@ export function GlossaryDetail() {
|
|||
onError: () => toast.error('Failed to activate version'),
|
||||
});
|
||||
|
||||
const reembedMut = useMutation({
|
||||
mutationFn: (versionId: string) => apiClient.reembedGlossaryVersion(clientId!, glossaryId!, versionId),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['glossary', clientId, glossaryId] });
|
||||
toast.success('Embedding re-queued');
|
||||
},
|
||||
onError: () => toast.error('Failed to re-queue embedding'),
|
||||
});
|
||||
|
||||
const uploadVersionMut = useMutation({
|
||||
mutationFn: () => apiClient.uploadGlossaryVersion(clientId!, glossaryId!, versionFile!, versionSourceCol.trim(), versionChangeNote.trim() || undefined),
|
||||
onSuccess: () => {
|
||||
|
|
@ -312,18 +321,35 @@ export function GlossaryDetail() {
|
|||
{v.change_note && <p className="text-xs text-gray-400 mt-0.5 italic">"{v.change_note}"</p>}
|
||||
<div className="mt-1"><EmbeddingPill v={v} /></div>
|
||||
</div>
|
||||
{(isAdmin || isPM) && !isActive && (
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm(`Activate version ${v.version_number}? AI translations will start using this version.`)) {
|
||||
activateMut.mutate(v.id);
|
||||
}
|
||||
}}
|
||||
disabled={activateMut.isPending}
|
||||
className="text-xs px-3 py-1.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 shrink-0"
|
||||
>
|
||||
Activate
|
||||
</button>
|
||||
{(isAdmin || isPM) && (
|
||||
<div className="flex gap-2 shrink-0">
|
||||
{v.embedding_status !== 'in_progress' && (
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm(`Re-run embedding for version ${v.version_number}? This will reset the current embedding progress.`)) {
|
||||
reembedMut.mutate(v.id);
|
||||
}
|
||||
}}
|
||||
disabled={reembedMut.isPending}
|
||||
className="text-xs px-3 py-1.5 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
↻ Re-embed
|
||||
</button>
|
||||
)}
|
||||
{!isActive && (
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm(`Activate version ${v.version_number}? AI translations will start using this version.`)) {
|
||||
activateMut.mutate(v.id);
|
||||
}
|
||||
}}
|
||||
disabled={activateMut.isPending}
|
||||
className="text-xs px-3 py-1.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
Activate
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -8,13 +8,17 @@ import type { LanguageQCStatus, QueueItem } from '../../types/api';
|
|||
const STATUS_TABS: { label: string; value: LanguageQCStatus | 'all' }[] = [
|
||||
{ label: 'All', value: 'all' },
|
||||
{ label: 'Pending', value: 'pending' },
|
||||
{ label: 'In Progress', value: 'in_progress' },
|
||||
{ label: 'Pending Review', value: 'pending_review' },
|
||||
{ label: 'In Review', value: 'in_review' },
|
||||
{ label: 'Rejected', value: 'rejected' },
|
||||
{ label: 'Approved', value: 'approved' },
|
||||
{ label: 'Rejected', value: 'rejected' },
|
||||
];
|
||||
|
||||
const STATUS_BADGE: Record<LanguageQCStatus, string> = {
|
||||
pending: 'bg-gray-100 text-gray-600',
|
||||
in_progress: 'bg-yellow-100 text-yellow-700',
|
||||
pending_review: 'bg-orange-100 text-orange-700',
|
||||
in_review: 'bg-blue-100 text-blue-700',
|
||||
approved: 'bg-green-100 text-green-700',
|
||||
rejected: 'bg-red-100 text-red-700',
|
||||
|
|
@ -22,6 +26,8 @@ const STATUS_BADGE: Record<LanguageQCStatus, string> = {
|
|||
|
||||
const STATUS_LABEL: Record<LanguageQCStatus, string> = {
|
||||
pending: 'Pending',
|
||||
in_progress: 'In Progress',
|
||||
pending_review: 'Pending Review',
|
||||
in_review: 'In Review',
|
||||
approved: 'Approved',
|
||||
rejected: 'Rejected',
|
||||
|
|
@ -35,7 +41,7 @@ const JOB_STATUS_LABEL: Record<string, string> = {
|
|||
rejected: 'Rejected',
|
||||
};
|
||||
|
||||
function QueueRow({ item }: { item: QueueItem }) {
|
||||
function QueueRow({ item, role }: { item: QueueItem; role: 'linguist' | 'reviewer' }) {
|
||||
const navigate = useNavigate();
|
||||
const qcStatus = item.lang_qc_status as LanguageQCStatus;
|
||||
|
||||
|
|
@ -51,8 +57,8 @@ function QueueRow({ item }: { item: QueueItem }) {
|
|||
<span className="px-2 py-0.5 text-xs font-mono bg-gray-100 rounded uppercase">{item.lang}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`px-2 py-0.5 text-xs rounded-full font-medium ${STATUS_BADGE[qcStatus]}`}>
|
||||
{STATUS_LABEL[qcStatus]}
|
||||
<span className={`px-2 py-0.5 text-xs rounded-full font-medium ${STATUS_BADGE[qcStatus] ?? 'bg-gray-100 text-gray-600'}`}>
|
||||
{STATUS_LABEL[qcStatus] ?? qcStatus}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-500">
|
||||
|
|
@ -61,9 +67,11 @@ function QueueRow({ item }: { item: QueueItem }) {
|
|||
<td className="px-4 py-3 text-xs text-gray-400">
|
||||
{item.assigned_at ? `${formatDistanceToNow(new Date(item.assigned_at))} ago` : '—'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-xs text-gray-400">
|
||||
{item.reviewed_at ? `${formatDistanceToNow(new Date(item.reviewed_at))} ago` : '—'}
|
||||
</td>
|
||||
{role === 'reviewer' && (
|
||||
<td className="px-4 py-3 text-xs text-gray-400">
|
||||
{item.reviewed_at ? `${formatDistanceToNow(new Date(item.reviewed_at))} ago` : '—'}
|
||||
</td>
|
||||
)}
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-xs text-blue-600 hover:underline">Open →</span>
|
||||
</td>
|
||||
|
|
@ -72,11 +80,15 @@ function QueueRow({ item }: { item: QueueItem }) {
|
|||
}
|
||||
|
||||
export function LinguistQueue() {
|
||||
const [activeRole, setActiveRole] = useState<'linguist' | 'reviewer'>('linguist');
|
||||
const [activeTab, setActiveTab] = useState<LanguageQCStatus | 'all'>('all');
|
||||
|
||||
const { data, isLoading, refetch } = useQuery({
|
||||
queryKey: ['linguist-queue', activeTab],
|
||||
queryFn: () => apiClient.getMyLanguageQCQueue(activeTab === 'all' ? undefined : activeTab),
|
||||
queryKey: ['linguist-queue', activeRole, activeTab],
|
||||
queryFn: () => apiClient.getMyLanguageQCQueue(
|
||||
activeRole,
|
||||
activeTab === 'all' ? undefined : activeTab,
|
||||
),
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
|
|
@ -97,13 +109,30 @@ 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>
|
||||
|
||||
{/* Status tabs */}
|
||||
<div className="flex gap-1 mb-4 border-b border-gray-200">
|
||||
<div className="flex gap-1 mb-4 border-b border-gray-200 overflow-x-auto">
|
||||
{STATUS_TABS.map(tab => (
|
||||
<button
|
||||
key={tab.value}
|
||||
onClick={() => setActiveTab(tab.value)}
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors -mb-px ${
|
||||
className={`px-3 py-2 text-sm font-medium border-b-2 transition-colors -mb-px whitespace-nowrap ${
|
||||
activeTab === tab.value
|
||||
? 'border-blue-600 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
|
|
@ -124,7 +153,9 @@ export function LinguistQueue() {
|
|||
<div className="text-center py-16 text-gray-400">
|
||||
<p className="text-4xl mb-3">📋</p>
|
||||
<p className="text-sm">
|
||||
{activeTab === 'all' ? 'No languages assigned to you yet.' : `No languages with status "${activeTab}".`}
|
||||
{activeTab === 'all'
|
||||
? `No languages assigned to you as ${activeRole} yet.`
|
||||
: `No languages with status "${activeTab}".`}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -139,13 +170,15 @@ export function LinguistQueue() {
|
|||
<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">Reviewed</th>
|
||||
{activeRole === 'reviewer' && (
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Reviewed</th>
|
||||
)}
|
||||
<th className="px-4 py-3" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{items.map((item, i) => (
|
||||
<QueueRow key={`${item.job_id}:${item.lang}:${i}`} item={item} />
|
||||
<QueueRow key={`${item.job_id}:${item.lang}:${i}`} item={item} role={activeRole} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
|
|
|||
|
|
@ -12,7 +12,8 @@ import { useCreateJob } from '../../hooks/useJob';
|
|||
import { useMultiUpload } from '../../hooks/useMultiUpload';
|
||||
import { useToastContext } from '../../contexts/ToastContext';
|
||||
import { generateTitleFromFilename } from '../../lib/fileUtils';
|
||||
import { useClients, useProjects } from '../../hooks/useClients';
|
||||
import { useClients, useProjects, useCreateProject } from '../../hooks/useClients';
|
||||
import { useUsers } from '../../hooks/useUsers';
|
||||
import type { JobCreateRequest, TTSPreferences, AccessibleVideoMethod } from '../../types/api';
|
||||
|
||||
const jobSchema = z.object({
|
||||
|
|
@ -42,7 +43,19 @@ export function NewJob() {
|
|||
const [selectedClientId, setSelectedClientId] = useState('');
|
||||
const [selectedProjectId, setSelectedProjectId] = useState('');
|
||||
const { data: clients = [] } = useClients();
|
||||
const { data: projects = [] } = useProjects(selectedClientId);
|
||||
const { data: projects = [], refetch: refetchProjects } = useProjects(selectedClientId);
|
||||
|
||||
// Inline create-project form state
|
||||
const [showCreateProject, setShowCreateProject] = useState(false);
|
||||
const [newProjectName, setNewProjectName] = useState('');
|
||||
const [newProjectLanguages, setNewProjectLanguages] = useState<string[]>([]);
|
||||
const [newProjectLinguistId, setNewProjectLinguistId] = useState('');
|
||||
const [newProjectReviewerId, setNewProjectReviewerId] = useState('');
|
||||
const createProjectMutation = useCreateProject(selectedClientId);
|
||||
const { data: linguistUsersData } = useUsers({ role: 'linguist', active_only: true });
|
||||
const { data: reviewerUsersData } = useUsers({ role: 'reviewer', active_only: true });
|
||||
const linguistUsers = linguistUsersData?.users ?? [];
|
||||
const reviewerUsers = reviewerUsersData?.users ?? [];
|
||||
const [showVoiceSettings, setShowVoiceSettings] = useState(false);
|
||||
const [ttsPreferences, setTtsPreferences] = useState<TTSPreferences>({
|
||||
provider: 'gemini',
|
||||
|
|
@ -271,6 +284,39 @@ export function NewJob() {
|
|||
setValue('title', '');
|
||||
};
|
||||
|
||||
const resetCreateProjectForm = () => {
|
||||
setNewProjectName('');
|
||||
setNewProjectLanguages([]);
|
||||
setNewProjectLinguistId('');
|
||||
setNewProjectReviewerId('');
|
||||
setShowCreateProject(false);
|
||||
};
|
||||
|
||||
const handleCreateProject = async () => {
|
||||
if (!newProjectName.trim()) {
|
||||
toast.toastOnly.error('Project name is required');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const created = await createProjectMutation.mutateAsync({
|
||||
name: newProjectName.trim(),
|
||||
default_languages: newProjectLanguages,
|
||||
default_linguist_id: newProjectLinguistId || undefined,
|
||||
default_reviewer_id: newProjectReviewerId || undefined,
|
||||
});
|
||||
setSelectedProjectId(created.id);
|
||||
// Pre-fill job languages if project has defaults
|
||||
if (created.default_languages?.length) {
|
||||
setValue('languages', created.default_languages);
|
||||
}
|
||||
await refetchProjects();
|
||||
resetCreateProjectForm();
|
||||
toast.toastOnly.success(`Project "${created.name}" created`);
|
||||
} catch {
|
||||
toast.toastOnly.error('Failed to create project');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRetryFailed = async () => {
|
||||
const data = watch();
|
||||
await multiUpload.retryFailed({
|
||||
|
|
@ -716,7 +762,7 @@ export function NewJob() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Project */}
|
||||
{/* Client + Project */}
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
|
|
@ -724,14 +770,19 @@ export function NewJob() {
|
|||
</label>
|
||||
<select
|
||||
value={selectedClientId}
|
||||
onChange={e => { setSelectedClientId(e.target.value); setSelectedProjectId(''); }}
|
||||
className={`w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 ${!selectedClientId ? 'border-gray-300' : 'border-gray-300'}`}
|
||||
onChange={e => {
|
||||
setSelectedClientId(e.target.value);
|
||||
setSelectedProjectId('');
|
||||
resetCreateProjectForm();
|
||||
}}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
disabled={isUploading}
|
||||
>
|
||||
<option value="">— Select client —</option>
|
||||
{clients.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{selectedClientId && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
|
|
@ -739,13 +790,107 @@ export function NewJob() {
|
|||
</label>
|
||||
<select
|
||||
value={selectedProjectId}
|
||||
onChange={e => setSelectedProjectId(e.target.value)}
|
||||
onChange={e => {
|
||||
if (e.target.value === '__create__') {
|
||||
setSelectedProjectId('');
|
||||
setShowCreateProject(true);
|
||||
} else {
|
||||
setSelectedProjectId(e.target.value);
|
||||
setShowCreateProject(false);
|
||||
}
|
||||
}}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
disabled={isUploading}
|
||||
>
|
||||
<option value="">— Select project —</option>
|
||||
{projects.filter(p => p.is_active).map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
|
||||
{projects.filter(p => p.is_active).map(p => (
|
||||
<option key={p.id} value={p.id}>{p.name}</option>
|
||||
))}
|
||||
<option value="__create__">+ Create new project…</option>
|
||||
</select>
|
||||
|
||||
{/* Inline create-project form */}
|
||||
{showCreateProject && (
|
||||
<div className="mt-3 border border-blue-200 bg-blue-50 rounded-lg p-4 space-y-4">
|
||||
<h3 className="text-sm font-semibold text-blue-900">New project</h3>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">
|
||||
Name <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newProjectName}
|
||||
onChange={e => setNewProjectName(e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Project name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-xs font-medium text-gray-700 mb-1">
|
||||
Default languages <span className="text-gray-400 font-normal">(optional — pre-fills languages on new jobs)</span>
|
||||
</p>
|
||||
<LanguageSelector
|
||||
selectedLanguages={newProjectLanguages}
|
||||
onAdd={l => setNewProjectLanguages(prev => prev.includes(l) ? prev : [...prev, l])}
|
||||
onRemove={l => setNewProjectLanguages(prev => prev.filter(x => x !== l))}
|
||||
disabled={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">
|
||||
Default linguist <span className="text-gray-400 font-normal">(optional)</span>
|
||||
</label>
|
||||
<select
|
||||
value={newProjectLinguistId}
|
||||
onChange={e => setNewProjectLinguistId(e.target.value)}
|
||||
className="w-full px-2 py-1.5 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">— None —</option>
|
||||
{linguistUsers.map(u => (
|
||||
<option key={u.id} value={u.id}>{u.full_name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">
|
||||
Default reviewer <span className="text-gray-400 font-normal">(optional)</span>
|
||||
</label>
|
||||
<select
|
||||
value={newProjectReviewerId}
|
||||
onChange={e => setNewProjectReviewerId(e.target.value)}
|
||||
className="w-full px-2 py-1.5 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">— None —</option>
|
||||
{reviewerUsers.map(u => (
|
||||
<option key={u.id} value={u.id}>{u.full_name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCreateProject}
|
||||
disabled={createProjectMutation.isPending || !newProjectName.trim()}
|
||||
className="px-4 py-2 text-sm bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{createProjectMutation.isPending ? 'Creating…' : 'Create project'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetCreateProjectForm}
|
||||
className="text-sm text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -192,28 +192,61 @@ export interface TTSRewriteItem {
|
|||
|
||||
// ── Per-language QC types ─────────────────────────────────────────────────────
|
||||
|
||||
export type LanguageQCStatus = 'pending' | 'in_review' | 'approved' | 'rejected';
|
||||
export type LanguageQCStatus =
|
||||
| 'pending'
|
||||
| 'in_progress'
|
||||
| 'pending_review'
|
||||
| 'in_review'
|
||||
| 'approved'
|
||||
| 'rejected';
|
||||
|
||||
export interface LanguageQCEvent {
|
||||
at: string;
|
||||
actor_user_id: string;
|
||||
actor_email: string;
|
||||
action: 'assign' | 'reassign' | 'start_review' | 'approve' | 'reject' | 'reopen';
|
||||
action:
|
||||
| 'assign' | 'reassign'
|
||||
| 'reviewer_assigned' | 'reviewer_reassigned'
|
||||
| 'start_work' | 'submit_for_review' | 'open_review'
|
||||
| 'approve' | 'reject' | 'reopen'
|
||||
| 'comment_added';
|
||||
notes?: string;
|
||||
previous_assignee_id?: string;
|
||||
}
|
||||
|
||||
export interface LanguageQCComment {
|
||||
id: string;
|
||||
author_id: string;
|
||||
author_name: string;
|
||||
author_email: string;
|
||||
body: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface LanguageQCState {
|
||||
status: LanguageQCStatus;
|
||||
// Linguist slot
|
||||
assigned_linguist_id?: string;
|
||||
assigned_linguist_email?: string;
|
||||
assigned_linguist_name?: string;
|
||||
assigned_at?: string;
|
||||
assigned_by_user_id?: string;
|
||||
submitted_for_review_at?: string;
|
||||
linguist_deadline?: string;
|
||||
// Reviewer slot
|
||||
assigned_reviewer_id?: string;
|
||||
assigned_reviewer_email?: string;
|
||||
assigned_reviewer_name?: string;
|
||||
assigned_reviewer_at?: string;
|
||||
review_started_at?: string;
|
||||
reviewer_deadline?: string;
|
||||
// Outcome
|
||||
reviewed_at?: string;
|
||||
reviewed_by_user_id?: string;
|
||||
reviewed_by_email?: string;
|
||||
notes?: string;
|
||||
history: LanguageQCEvent[];
|
||||
comments: LanguageQCComment[];
|
||||
}
|
||||
|
||||
export interface LanguageQCMapResponse {
|
||||
|
|
@ -522,6 +555,9 @@ export interface Project {
|
|||
name: string;
|
||||
client_id: string;
|
||||
is_active: boolean;
|
||||
default_languages: string[];
|
||||
default_linguist_id?: string;
|
||||
default_reviewer_id?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
|
@ -544,6 +580,9 @@ export interface TeamCreateRequest {
|
|||
|
||||
export interface ProjectCreateRequest {
|
||||
name: string;
|
||||
default_languages?: string[];
|
||||
default_linguist_id?: string;
|
||||
default_reviewer_id?: string;
|
||||
}
|
||||
|
||||
export interface ResetPasswordResponse {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue