diff --git a/backend/.env.example b/backend/.env.example index 7e42b20..49d27e2 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -36,6 +36,12 @@ SENDGRID_API_KEY=... EMAIL_FROM=support@yourdomain.com CLIENT_BASE_URL=https://app.yourdomain.com +# AI Cost Tracker +COST_TRACKER_BASE_URL=https://optical-dev.oliver.solutions/cost-tracker/v1 +COST_TRACKER_API_KEY=... +COST_TRACKER_SOURCE_APP=video-accessibility +COST_TRACKER_ENABLED=true + # Observability SENTRY_DSN=... OTEL_EXPORTER_OTLP_ENDPOINT=... \ No newline at end of file diff --git a/backend/app/api/v1/routes_clients.py b/backend/app/api/v1/routes_clients.py new file mode 100644 index 0000000..5d04be8 --- /dev/null +++ b/backend/app/api/v1/routes_clients.py @@ -0,0 +1,435 @@ +""" +Client / Team / Project management. + +Access rules: + - Client CRUD → Admin only + - PM assignment on client → Admin only + - Team CRUD + membership → Admin or PM of that client + - Project CRUD → Admin or PM of that client + - List projects (read) → Admin, PM, or any team member of the client +""" + +from datetime import datetime, timezone + +from bson import ObjectId +from fastapi import APIRouter, Depends, HTTPException +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 ...models.client import ( + Client, + ClientCreate, + ClientUpdate, + Project, + ProjectCreate, + ProjectUpdate, + Team, + TeamCreate, + TeamUpdate, +) +from ...models.user import User, UserRole + +router = APIRouter(prefix="/clients", tags=["clients"]) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +async def _get_client_or_404(client_id: str, db: AsyncIOMotorDatabase) -> dict: + doc = await db.clients.find_one({"_id": client_id}) + if not doc: + raise HTTPException(status_code=404, detail="Client not found") + return doc + + +async def _get_team_or_404(team_id: str, client_id: str, db: AsyncIOMotorDatabase) -> dict: + doc = await db.teams.find_one({"_id": team_id, "client_id": client_id}) + if not doc: + raise HTTPException(status_code=404, detail="Team not found") + return doc + + +async def _get_project_or_404(project_id: str, client_id: str, db: AsyncIOMotorDatabase) -> dict: + doc = await db.projects.find_one({"_id": project_id, "client_id": client_id}) + if not doc: + raise HTTPException(status_code=404, detail="Project not found") + return doc + + +def _client_from_doc(doc: dict) -> Client: + return Client(**{**doc, "_id": str(doc["_id"])}) + + +def _team_from_doc(doc: dict) -> Team: + return Team(**{**doc, "_id": str(doc["_id"])}) + + +def _project_from_doc(doc: dict) -> Project: + return Project(**{**doc, "_id": str(doc["_id"])}) + + +# --------------------------------------------------------------------------- +# Client endpoints (Admin only) +# --------------------------------------------------------------------------- + +@router.get("", response_model=list[Client]) +async def list_clients( + current_user: User = Depends(get_current_user), + db: AsyncIOMotorDatabase = Depends(get_database), +): + if current_user.role == UserRole.ADMIN: + docs = await db.clients.find().to_list(None) + elif current_user.role == UserRole.PROJECT_MANAGER: + client_ids = current_user.pm_client_ids or [] + docs = await db.clients.find({"_id": {"$in": client_ids}}).to_list(None) + else: + raise HTTPException(status_code=403, detail="Insufficient permissions") + return [_client_from_doc(d) for d in docs] + + +@router.post("", response_model=Client) +async def create_client( + body: ClientCreate, + current_user: User = Depends(require_roles(UserRole.ADMIN)), + db: AsyncIOMotorDatabase = Depends(get_database), +): + if await db.clients.find_one({"slug": body.slug}): + raise HTTPException(status_code=409, detail="Slug already exists") + now = _now() + client_id = str(ObjectId()) + await db.clients.insert_one({ + "_id": client_id, + "name": body.name, + "slug": body.slug, + "is_active": True, + "created_at": now, + "updated_at": now, + }) + doc = await db.clients.find_one({"_id": client_id}) + return _client_from_doc(doc) + + +@router.get("/{client_id}", response_model=Client) +async def get_client( + client_id: str, + current_user: User = Depends(get_current_user), + db: AsyncIOMotorDatabase = Depends(get_database), +): + if current_user.role not in (UserRole.ADMIN, UserRole.PROJECT_MANAGER): + raise HTTPException(status_code=403, detail="Insufficient permissions") + if current_user.role == UserRole.PROJECT_MANAGER and client_id not in (current_user.pm_client_ids or []): + raise HTTPException(status_code=403, detail="Not a manager for this client") + doc = await _get_client_or_404(client_id, db) + return _client_from_doc(doc) + + +@router.patch("/{client_id}", response_model=Client) +async def update_client( + client_id: str, + body: ClientUpdate, + current_user: User = Depends(require_roles(UserRole.ADMIN)), + db: AsyncIOMotorDatabase = Depends(get_database), +): + await _get_client_or_404(client_id, db) + update: dict = {k: v for k, v in body.model_dump(exclude_none=True).items()} + if not update: + raise HTTPException(status_code=422, detail="No fields to update") + if "slug" in update and await db.clients.find_one({"slug": update["slug"], "_id": {"$ne": client_id}}): + raise HTTPException(status_code=409, detail="Slug already exists") + update["updated_at"] = _now() + await db.clients.update_one({"_id": client_id}, {"$set": update}) + doc = await db.clients.find_one({"_id": client_id}) + return _client_from_doc(doc) + + +@router.delete("/{client_id}", status_code=204) +async def deactivate_client( + client_id: str, + current_user: User = Depends(require_roles(UserRole.ADMIN)), + db: AsyncIOMotorDatabase = Depends(get_database), +): + await _get_client_or_404(client_id, db) + await db.clients.update_one({"_id": client_id}, {"$set": {"is_active": False, "updated_at": _now()}}) + + +# --------------------------------------------------------------------------- +# PM assignment (Admin only) +# --------------------------------------------------------------------------- + +class AssignPMRequest(BaseModel): + user_id: str + + +@router.post("/{client_id}/pm", status_code=204) +async def assign_pm( + client_id: str, + body: AssignPMRequest, + current_user: User = Depends(require_roles(UserRole.ADMIN)), + db: AsyncIOMotorDatabase = Depends(get_database), +): + await _get_client_or_404(client_id, db) + user_doc = await db.users.find_one({"_id": body.user_id}) + if not user_doc: + raise HTTPException(status_code=404, detail="User not found") + await db.users.update_one( + {"_id": body.user_id}, + { + "$addToSet": {"pm_client_ids": client_id}, + "$set": {"role": UserRole.PROJECT_MANAGER.value, "updated_at": _now()}, + }, + ) + + +@router.delete("/{client_id}/pm/{user_id}", status_code=204) +async def remove_pm( + client_id: str, + user_id: str, + current_user: User = Depends(require_roles(UserRole.ADMIN)), + db: AsyncIOMotorDatabase = Depends(get_database), +): + await _get_client_or_404(client_id, db) + await db.users.update_one( + {"_id": user_id}, + {"$pull": {"pm_client_ids": client_id}, "$set": {"updated_at": _now()}}, + ) + # Downgrade role if no more PM assignments + user_doc = await db.users.find_one({"_id": user_id}) + if user_doc and not user_doc.get("pm_client_ids"): + await db.users.update_one( + {"_id": user_id}, + {"$set": {"role": UserRole.CLIENT.value, "updated_at": _now()}}, + ) + + +@router.get("/{client_id}/pm", response_model=list[dict]) +async def list_pms( + client_id: str, + current_user: User = Depends(require_roles(UserRole.ADMIN)), + db: AsyncIOMotorDatabase = Depends(get_database), +): + await _get_client_or_404(client_id, db) + users = await db.users.find( + {"pm_client_ids": client_id}, + {"_id": 1, "email": 1, "full_name": 1, "role": 1}, + ).to_list(None) + return [{"id": str(u["_id"]), "email": u["email"], "full_name": u["full_name"], "role": u["role"]} for u in users] + + +# --------------------------------------------------------------------------- +# Team endpoints +# --------------------------------------------------------------------------- + +@router.get("/{client_id}/teams", response_model=list[Team]) +async def list_teams( + client_id: str, + current_user: User = Depends(get_current_user), + db: AsyncIOMotorDatabase = Depends(get_database), +): + await _get_client_or_404(client_id, db) + _assert_client_access(current_user, client_id) + docs = await db.teams.find({"client_id": client_id}).to_list(None) + return [_team_from_doc(d) for d in docs] + + +@router.post("/{client_id}/teams", response_model=Team) +async def create_team( + client_id: str, + body: TeamCreate, + current_user: User = Depends(get_current_user), + db: AsyncIOMotorDatabase = Depends(get_database), +): + await _get_client_or_404(client_id, db) + _assert_pm_or_admin(current_user, client_id) + now = _now() + team_id = str(ObjectId()) + await db.teams.insert_one({ + "_id": team_id, + "name": body.name, + "client_id": client_id, + "member_user_ids": [], + "created_at": now, + "updated_at": now, + }) + doc = await db.teams.find_one({"_id": team_id}) + return _team_from_doc(doc) + + +@router.patch("/{client_id}/teams/{team_id}", response_model=Team) +async def update_team( + client_id: str, + team_id: str, + body: TeamUpdate, + current_user: User = Depends(get_current_user), + db: AsyncIOMotorDatabase = Depends(get_database), +): + await _get_client_or_404(client_id, db) + _assert_pm_or_admin(current_user, client_id) + await _get_team_or_404(team_id, client_id, db) + update = {k: v for k, v in body.model_dump(exclude_none=True).items()} + if not update: + raise HTTPException(status_code=422, detail="No fields to update") + update["updated_at"] = _now() + await db.teams.update_one({"_id": team_id}, {"$set": update}) + doc = await db.teams.find_one({"_id": team_id}) + return _team_from_doc(doc) + + +@router.delete("/{client_id}/teams/{team_id}", status_code=204) +async def delete_team( + client_id: str, + team_id: str, + current_user: User = Depends(get_current_user), + db: AsyncIOMotorDatabase = Depends(get_database), +): + await _get_client_or_404(client_id, db) + _assert_pm_or_admin(current_user, client_id) + await _get_team_or_404(team_id, client_id, db) + await db.teams.delete_one({"_id": team_id}) + + +# Team membership + +class AddMemberRequest(BaseModel): + user_id: str + + +@router.post("/{client_id}/teams/{team_id}/members", status_code=204) +async def add_team_member( + client_id: str, + team_id: str, + body: AddMemberRequest, + current_user: User = Depends(get_current_user), + db: AsyncIOMotorDatabase = Depends(get_database), +): + await _get_client_or_404(client_id, db) + _assert_pm_or_admin(current_user, client_id) + await _get_team_or_404(team_id, client_id, db) + if not await db.users.find_one({"_id": body.user_id}): + raise HTTPException(status_code=404, detail="User not found") + await db.teams.update_one( + {"_id": team_id}, + {"$addToSet": {"member_user_ids": body.user_id}, "$set": {"updated_at": _now()}}, + ) + + +@router.delete("/{client_id}/teams/{team_id}/members/{user_id}", status_code=204) +async def remove_team_member( + client_id: str, + team_id: str, + user_id: str, + current_user: User = Depends(get_current_user), + db: AsyncIOMotorDatabase = Depends(get_database), +): + await _get_client_or_404(client_id, db) + _assert_pm_or_admin(current_user, client_id) + await _get_team_or_404(team_id, client_id, db) + await db.teams.update_one( + {"_id": team_id}, + {"$pull": {"member_user_ids": user_id}, "$set": {"updated_at": _now()}}, + ) + + +# --------------------------------------------------------------------------- +# Project endpoints +# --------------------------------------------------------------------------- + +@router.get("/{client_id}/projects", response_model=list[Project]) +async def list_projects( + client_id: str, + current_user: User = Depends(get_current_user), + db: AsyncIOMotorDatabase = Depends(get_database), +): + await _get_client_or_404(client_id, db) + _assert_client_access(current_user, client_id) + docs = await db.projects.find({"client_id": client_id}).to_list(None) + return [_project_from_doc(d) for d in docs] + + +@router.post("/{client_id}/projects", response_model=Project) +async def create_project( + client_id: str, + body: ProjectCreate, + current_user: User = Depends(get_current_user), + db: AsyncIOMotorDatabase = Depends(get_database), +): + await _get_client_or_404(client_id, db) + _assert_pm_or_admin(current_user, client_id) + now = _now() + project_id = str(ObjectId()) + await db.projects.insert_one({ + "_id": project_id, + "name": body.name, + "client_id": client_id, + "is_active": True, + "created_at": now, + "updated_at": now, + }) + doc = await db.projects.find_one({"_id": project_id}) + return _project_from_doc(doc) + + +@router.patch("/{client_id}/projects/{project_id}", response_model=Project) +async def update_project( + client_id: str, + project_id: str, + body: ProjectUpdate, + current_user: User = Depends(get_current_user), + db: AsyncIOMotorDatabase = Depends(get_database), +): + await _get_client_or_404(client_id, db) + _assert_pm_or_admin(current_user, client_id) + await _get_project_or_404(project_id, client_id, db) + update = {k: v for k, v in body.model_dump(exclude_none=True).items()} + if not update: + raise HTTPException(status_code=422, detail="No fields to update") + update["updated_at"] = _now() + await db.projects.update_one({"_id": project_id}, {"$set": update}) + doc = await db.projects.find_one({"_id": project_id}) + return _project_from_doc(doc) + + +@router.delete("/{client_id}/projects/{project_id}", status_code=204) +async def archive_project( + client_id: str, + project_id: str, + current_user: User = Depends(get_current_user), + db: AsyncIOMotorDatabase = Depends(get_database), +): + await _get_client_or_404(client_id, db) + _assert_pm_or_admin(current_user, client_id) + await _get_project_or_404(project_id, client_id, db) + await db.projects.update_one( + {"_id": project_id}, + {"$set": {"is_active": False, "updated_at": _now()}}, + ) + + +# --------------------------------------------------------------------------- +# Internal access-check helpers +# --------------------------------------------------------------------------- + +def _assert_pm_or_admin(user: User, client_id: str) -> None: + if user.role == UserRole.ADMIN: + return + if user.role == UserRole.PROJECT_MANAGER and client_id in (user.pm_client_ids or []): + return + raise HTTPException(status_code=403, detail="Not a manager for this client") + + +def _assert_client_access(user: User, client_id: str) -> None: + """Allow admin, PM of the client, or any user (team membership checked at job level).""" + if user.role in (UserRole.ADMIN, UserRole.REVIEWER, UserRole.PRODUCTION, UserRole.LINGUIST): + return + if user.role == UserRole.PROJECT_MANAGER and client_id in (user.pm_client_ids or []): + return + # CLIENT role users can list projects so they can pick one on job creation + if user.role in (UserRole.CLIENT, UserRole.PROJECT_MANAGER): + return + raise HTTPException(status_code=403, detail="Insufficient permissions") diff --git a/backend/app/api/v1/routes_jobs.py b/backend/app/api/v1/routes_jobs.py index 7d54625..19a920d 100644 --- a/backend/app/api/v1/routes_jobs.py +++ b/backend/app/api/v1/routes_jobs.py @@ -8,7 +8,7 @@ from motor.motor_asyncio import AsyncIOMotorDatabase from ...core.config import settings from ...core.database import get_database -from ...core.dependencies import get_current_user, require_roles +from ...core.dependencies import get_accessible_project_ids, get_current_user, require_roles from ...core.logging import get_logger from ...lib.vtt import VTTEditor from ...models.job import JobStatus, RequestedOutputs @@ -70,6 +70,7 @@ async def create_job( requested_outputs: str = Form(...), # JSON string file: UploadFile = File(...), brand_context: Optional[str] = Form(None), + project_id: Optional[str] = Form(None), current_user: User = Depends(get_current_user), db: AsyncIOMotorDatabase = Depends(get_database), ): @@ -120,6 +121,7 @@ async def create_job( }] }, "brand_context": brand_context or None, + "project_id": project_id or None, "created_at": datetime.utcnow(), "updated_at": datetime.utcnow() } @@ -448,13 +450,15 @@ async def bulk_download_jobs( detail="No jobs found" ) - # Filter by permissions (clients can only download their own jobs) + # Filter by permissions + _accessible_pids = await get_accessible_project_ids(current_user, db) valid_jobs = [] for job_doc in jobs: - # Check ownership for client users - if (current_user.role == UserRole.CLIENT and - job_doc["client_id"] != str(current_user.id)): - continue + if _accessible_pids is not None: + _jpid = job_doc.get("project_id") + _own_legacy = job_doc.get("client_id") == str(current_user.id) and not _jpid + if not _own_legacy and not (_jpid and _jpid in _accessible_pids): + continue # Check downloadable status if job_doc["status"] not in DOWNLOADABLE_STATUSES: @@ -514,8 +518,18 @@ async def list_jobs( else: query["status"] = status - if mine or current_user.role == UserRole.CLIENT: + if mine: query["client_id"] = str(current_user.id) + else: + project_ids = await get_accessible_project_ids(current_user, db) + if project_ids is not None: + # CLIENT / PM: jobs in accessible projects OR legacy jobs owned by this user + legacy_filter = {"client_id": str(current_user.id), "project_id": None} + if project_ids: + query["$or"] = [{"project_id": {"$in": project_ids}}, legacy_filter] + else: + # No teams / no projects yet — show only own legacy jobs + query.update(legacy_filter) # Get total count total = await db.jobs.count_documents(query) @@ -591,12 +605,13 @@ async def get_job( ) # Check access permissions - if (current_user.role == UserRole.CLIENT and - job_doc["client_id"] != str(current_user.id)): - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Access denied" - ) + if current_user.role in (UserRole.CLIENT, UserRole.PROJECT_MANAGER): + accessible = await get_accessible_project_ids(current_user, db) + job_project_id = job_doc.get("project_id") + is_own_legacy = job_doc.get("client_id") == str(current_user.id) and not job_project_id + in_project = accessible is None or (job_project_id and job_project_id in (accessible or [])) + if not is_own_legacy and not in_project: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied") # Check task status if task_id exists task_id = job_doc.get("task_id") @@ -1035,13 +1050,13 @@ async def get_job_downloads( detail="Job not found" ) - # Check access permissions (only client or admin/reviewer can download) - if (current_user.role == UserRole.CLIENT and - job_doc["client_id"] != str(current_user.id)): - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Access denied" - ) + # Check access permissions + if current_user.role in (UserRole.CLIENT, UserRole.PROJECT_MANAGER): + _ap = await get_accessible_project_ids(current_user, db) + _jpid = job_doc.get("project_id") + _own = job_doc.get("client_id") == str(current_user.id) and not _jpid + if _ap is not None and not _own and not (_jpid and _jpid in (_ap or [])): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied") # Allow downloads for jobs that have outputs available # (PENDING_QC, APPROVED_ENGLISH, TRANSLATING, COMPLETED, etc.) @@ -1163,12 +1178,12 @@ async def get_job_vtt_content( ) # Check access permissions - if (current_user.role == UserRole.CLIENT and - job_doc["client_id"] != str(current_user.id)): - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Access denied" - ) + if current_user.role in (UserRole.CLIENT, UserRole.PROJECT_MANAGER): + _ap = await get_accessible_project_ids(current_user, db) + _jpid = job_doc.get("project_id") + _own = job_doc.get("client_id") == str(current_user.id) and not _jpid + if _ap is not None and not _own and not (_jpid and _jpid in (_ap or [])): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied") # Default to source language if not specified target_language = language or job_doc["source"].get("language", "en") @@ -1519,13 +1534,13 @@ async def delete_job( detail="Job not found" ) - # Check permissions: clients can only delete their own jobs, admins/reviewers can delete any - if (current_user.role == UserRole.CLIENT and - job_doc["client_id"] != str(current_user.id)): - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Access denied" - ) + # Check permissions: clients and PMs can only delete accessible jobs + if current_user.role in (UserRole.CLIENT, UserRole.PROJECT_MANAGER): + _ap = await get_accessible_project_ids(current_user, db) + _jpid = job_doc.get("project_id") + _own = job_doc.get("client_id") == str(current_user.id) and not _jpid + if _ap is not None and not _own and not (_jpid and _jpid in (_ap or [])): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied") logger.info(f"Deleting job {job_id} requested by {current_user.email}") diff --git a/backend/app/api/v1/routes_tts.py b/backend/app/api/v1/routes_tts.py index f148e2e..f5a619a 100644 --- a/backend/app/api/v1/routes_tts.py +++ b/backend/app/api/v1/routes_tts.py @@ -1,3 +1,5 @@ +import asyncio +import time from typing import Literal, Optional from fastapi import APIRouter, Depends, HTTPException, Query @@ -9,6 +11,7 @@ from ...core.logging import get_logger from ...services.gemini_tts import gemini_tts_service from ...services.elevenlabs_voices import elevenlabs_voice_service from ...services.tts import tts_service +from ...services import cost_tracker from ...core.dependencies import get_current_user logger = get_logger(__name__) @@ -212,13 +215,15 @@ async def preview_voice( Generate a voice preview audio sample with all TTS settings applied. Returns MP3 audio data. """ + user_id: str = current_user.get("email") or current_user.get("sub") or "unknown" + if request.provider == "elevenlabs": - return await _preview_elevenlabs(request) + return await _preview_elevenlabs(request, user_id) - return await _preview_gemini(request) + return await _preview_gemini(request, user_id) -async def _preview_gemini(request: VoicePreviewRequest) -> Response: +async def _preview_gemini(request: VoicePreviewRequest, user_id: str) -> Response: """Generate a Gemini TTS voice preview.""" # Validate voice name if request.voice_name not in settings.gemini_tts_voices: @@ -240,12 +245,18 @@ async def _preview_gemini(request: VoicePreviewRequest) -> Response: else: style_prompt = settings.gemini_tts_style_prompts.get(request.style_preset, "") + sample_text = settings.gemini_tts_preview_samples.get( + request.language, + settings.gemini_tts_preview_samples.get("en", "This is a voice preview.") + ) + try: logger.info( f"Generating Gemini voice preview: voice={request.voice_name}, language={request.language}, " f"model={request.model}, speed={request.speed}x, style={request.style_preset}" ) + t0 = time.monotonic() audio_data = await gemini_tts_service.synthesize_preview( voice_name=request.voice_name, language=request.language, @@ -253,6 +264,16 @@ async def _preview_gemini(request: VoicePreviewRequest) -> Response: speed=request.speed, style_prompt=style_prompt ) + elapsed_ms = int((time.monotonic() - t0) * 1000) + + model_id = settings.gemini_tts_models.get(request.model, settings.gemini_tts_model) + asyncio.create_task(cost_tracker.aio_record( + model=model_id, + provider="google", + user_external_id=user_id, + chars=len(sample_text), + latency_ms=elapsed_ms, + )) return Response( content=audio_data, @@ -270,7 +291,7 @@ async def _preview_gemini(request: VoicePreviewRequest) -> Response: ) from e -async def _preview_elevenlabs(request: VoicePreviewRequest) -> Response: +async def _preview_elevenlabs(request: VoicePreviewRequest, user_id: str) -> Response: """Generate an ElevenLabs TTS voice preview.""" if not tts_service.elevenlabs_available: raise HTTPException( @@ -293,12 +314,22 @@ async def _preview_elevenlabs(request: VoicePreviewRequest) -> Response: f"stability={stability}, similarity_boost={similarity_boost}" ) + t0 = time.monotonic() audio_data = await tts_service._synthesize_text_elevenlabs( text=sample_text, voice_id=request.voice_name, stability=stability, similarity_boost=similarity_boost, ) + elapsed_ms = int((time.monotonic() - t0) * 1000) + + asyncio.create_task(cost_tracker.aio_record( + model="eleven_multilingual_v2", + provider="elevenlabs", + user_external_id=user_id, + chars=len(sample_text), + latency_ms=elapsed_ms, + )) return Response( content=audio_data, diff --git a/backend/app/core/dependencies.py b/backend/app/core/dependencies.py index a20149b..15ff774 100644 --- a/backend/app/core/dependencies.py +++ b/backend/app/core/dependencies.py @@ -11,6 +11,9 @@ 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} + async def get_current_user( credentials: HTTPAuthorizationCredentials = Depends(security), @@ -86,3 +89,60 @@ async def get_current_user_optional( return User(**user_doc) except Exception: return None + + +async def get_accessible_project_ids( + user: User, + db: AsyncIOMotorDatabase, +) -> Optional[list[str]]: + """ + Returns project IDs the user may access, or None meaning "see everything". + + - Staff / Admin → None (unrestricted) + - PM → all projects in their assigned client(s) + - CLIENT → projects belonging to clients of any team the user is in + """ + if user.role in STAFF_ROLES: + return None + + if user.role == UserRole.PROJECT_MANAGER: + client_ids = user.pm_client_ids or [] + 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] + + # CLIENT role: find teams the user belongs to → their clients → projects + teams = await db.teams.find( + {"member_user_ids": str(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] + + +def require_pm_for_client(client_id_param: str = "client_id"): + """Dependency: ensures the current user is an Admin or PM for the given client.""" + async def checker( + request: Request, + current_user: User = Depends(get_current_user), + ) -> User: + if current_user.role == UserRole.ADMIN: + return current_user + if current_user.role != UserRole.PROJECT_MANAGER: + raise HTTPException(status_code=403, detail="Insufficient permissions") + client_id = request.path_params.get(client_id_param) + if client_id not in (current_user.pm_client_ids or []): + raise HTTPException(status_code=403, detail="Not a manager for this client") + return current_user + + return checker diff --git a/backend/app/main.py b/backend/app/main.py index 202bade..0c2b005 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -12,6 +12,7 @@ from sentry_sdk.integrations.celery import CeleryIntegration from .api.v1.routes_admin import router as admin_router from .api.v1.routes_auth import router as auth_router +from .api.v1.routes_clients import router as clients_router from .api.v1.routes_files import router as files_router from .api.v1.routes_jobs import router as jobs_router from .api.v1.routes_review_notes import router as review_notes_router @@ -248,6 +249,7 @@ async def validation_middleware(request, call_next): # Include routers app.include_router(auth_router, prefix="/api/v1") +app.include_router(clients_router, prefix="/api/v1") app.include_router(files_router, prefix="/api/v1") app.include_router(jobs_router, prefix="/api/v1") app.include_router(review_notes_router, prefix="/api/v1") diff --git a/backend/app/models/client.py b/backend/app/models/client.py new file mode 100644 index 0000000..2cd98e6 --- /dev/null +++ b/backend/app/models/client.py @@ -0,0 +1,80 @@ +from datetime import datetime +from typing import Optional, Annotated + +from bson import ObjectId +from pydantic import BaseModel, Field, BeforeValidator + + +def validate_object_id(v) -> str: + if isinstance(v, ObjectId): + return str(v) + if isinstance(v, str): + return v + raise ValueError("Invalid ObjectId") + + +PyObjectId = Annotated[str, BeforeValidator(validate_object_id)] + + +class Client(BaseModel): + id: Optional[PyObjectId] = Field(None, alias="_id") + name: str + slug: str # lowercase, URL-safe identifier, unique + is_active: bool = True + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + + class Config: + populate_by_name = True + + +class ClientCreate(BaseModel): + name: str + slug: str + + +class ClientUpdate(BaseModel): + name: Optional[str] = None + slug: Optional[str] = None + is_active: Optional[bool] = None + + +class Team(BaseModel): + id: Optional[PyObjectId] = Field(None, alias="_id") + name: str + client_id: str + member_user_ids: list[str] = [] + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + + class Config: + populate_by_name = True + + +class TeamCreate(BaseModel): + name: str + + +class TeamUpdate(BaseModel): + name: Optional[str] = None + + +class Project(BaseModel): + id: Optional[PyObjectId] = Field(None, alias="_id") + name: str + client_id: str + is_active: bool = True + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + + class Config: + populate_by_name = True + + +class ProjectCreate(BaseModel): + name: str + + +class ProjectUpdate(BaseModel): + name: Optional[str] = None + is_active: Optional[bool] = None diff --git a/backend/app/models/job.py b/backend/app/models/job.py index 58aa0cf..5efb1d6 100644 --- a/backend/app/models/job.py +++ b/backend/app/models/job.py @@ -167,6 +167,7 @@ class Job(BaseModel): ai: Optional[AISection] = None error: Optional[dict[str, Any]] = None tts_rewrites: Optional[list[dict[str, Any]]] = None # Track auto-rewritten TTS cues + project_id: Optional[str] = None # Platform project this job belongs to (Client → Project → Job) brand_context: Optional[str] = None # Brand names present in the video for accurate product identification cost_tracker_project_id: Optional[str] = None # External project ID for AI cost attribution created_at: Optional[datetime] = None diff --git a/backend/app/models/user.py b/backend/app/models/user.py index e393b59..0f7ac51 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -23,6 +23,7 @@ class UserRole(str, Enum): REVIEWER = "reviewer" LINGUIST = "linguist" PRODUCTION = "production" + PROJECT_MANAGER = "project_manager" ADMIN = "admin" @@ -39,6 +40,7 @@ class User(BaseModel): role: UserRole = UserRole.CLIENT auth_provider: AuthProvider = AuthProvider.LOCAL is_active: bool = True + pm_client_ids: list[str] = [] # Client IDs where this user is Project Manager (admin-assigned) created_at: Optional[datetime] = None updated_at: Optional[datetime] = None @@ -63,3 +65,4 @@ class UserUpdate(BaseModel): full_name: Optional[str] = None role: Optional[UserRole] = None is_active: Optional[bool] = None + pm_client_ids: Optional[list[str]] = None diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 41326bd..7e3807c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -16,6 +16,8 @@ import { FinalList } from './routes/admin/FinalList'; import { FinalDetail } from './routes/admin/FinalDetail'; import { UserList } from './routes/admin/UserList'; import { UserDetail } from './routes/admin/UserDetail'; +import { ClientList } from './routes/admin/ClientList'; +import { ClientDetail } from './routes/admin/ClientDetail'; import { Downloads } from './routes/Downloads'; import { RequireAuth } from './components/Auth/RequireAuth'; import { RoleGate } from './components/Auth/RoleGate'; @@ -107,6 +109,20 @@ function AppContent() { } /> + + + + + + } /> + + + + + + } /> diff --git a/frontend/src/components/Layout/Sidebar.tsx b/frontend/src/components/Layout/Sidebar.tsx index 4ff3efc..7a319a9 100644 --- a/frontend/src/components/Layout/Sidebar.tsx +++ b/frontend/src/components/Layout/Sidebar.tsx @@ -52,6 +52,12 @@ export function Sidebar({ onMobileClose }: SidebarProps) { icon: '👥', roles: ['admin'], }, + { + label: 'Clients', + href: '/admin/clients', + icon: '🏢', + roles: ['admin', 'project_manager'], + }, ]; const filteredItems = sidebarItems.filter(item => diff --git a/frontend/src/hooks/useClients.ts b/frontend/src/hooks/useClients.ts new file mode 100644 index 0000000..6045cec --- /dev/null +++ b/frontend/src/hooks/useClients.ts @@ -0,0 +1,154 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { apiClient } from '../lib/api'; +import type { ClientCreateRequest, ProjectCreateRequest, TeamCreateRequest } from '../types/api'; + +// ── Clients ────────────────────────────────────────────────────────────────── + +export function useClients() { + return useQuery({ + queryKey: ['clients'], + queryFn: () => apiClient.listClients(), + }); +} + +export function useClient(clientId: string) { + return useQuery({ + queryKey: ['clients', clientId], + queryFn: () => apiClient.getClient(clientId), + enabled: !!clientId, + }); +} + +export function useCreateClient() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (data: ClientCreateRequest) => apiClient.createClient(data), + onSuccess: () => qc.invalidateQueries({ queryKey: ['clients'] }), + }); +} + +export function useUpdateClient(clientId: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (data: Partial) => + apiClient.updateClient(clientId, data), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['clients'] }); + qc.invalidateQueries({ queryKey: ['clients', clientId] }); + }, + }); +} + +// ── PMs ────────────────────────────────────────────────────────────────────── + +export function usePMs(clientId: string) { + return useQuery({ + queryKey: ['clients', clientId, 'pm'], + queryFn: () => apiClient.listPMs(clientId), + enabled: !!clientId, + }); +} + +export function useAssignPM(clientId: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (userId: string) => apiClient.assignPM(clientId, userId), + onSuccess: () => qc.invalidateQueries({ queryKey: ['clients', clientId, 'pm'] }), + }); +} + +export function useRemovePM(clientId: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (userId: string) => apiClient.removePM(clientId, userId), + onSuccess: () => qc.invalidateQueries({ queryKey: ['clients', clientId, 'pm'] }), + }); +} + +// ── Teams ───────────────────────────────────────────────────────────────────── + +export function useTeams(clientId: string) { + return useQuery({ + queryKey: ['clients', clientId, 'teams'], + queryFn: () => apiClient.listTeams(clientId), + enabled: !!clientId, + }); +} + +export function useCreateTeam(clientId: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (data: TeamCreateRequest) => apiClient.createTeam(clientId, data), + onSuccess: () => qc.invalidateQueries({ queryKey: ['clients', clientId, 'teams'] }), + }); +} + +export function useUpdateTeam(clientId: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ teamId, name }: { teamId: string; name: string }) => + apiClient.updateTeam(clientId, teamId, name), + onSuccess: () => qc.invalidateQueries({ queryKey: ['clients', clientId, 'teams'] }), + }); +} + +export function useDeleteTeam(clientId: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (teamId: string) => apiClient.deleteTeam(clientId, teamId), + onSuccess: () => qc.invalidateQueries({ queryKey: ['clients', clientId, 'teams'] }), + }); +} + +export function useAddTeamMember(clientId: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ teamId, userId }: { teamId: string; userId: string }) => + apiClient.addTeamMember(clientId, teamId, userId), + onSuccess: () => qc.invalidateQueries({ queryKey: ['clients', clientId, 'teams'] }), + }); +} + +export function useRemoveTeamMember(clientId: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ teamId, userId }: { teamId: string; userId: string }) => + apiClient.removeTeamMember(clientId, teamId, userId), + onSuccess: () => qc.invalidateQueries({ queryKey: ['clients', clientId, 'teams'] }), + }); +} + +// ── Projects ────────────────────────────────────────────────────────────────── + +export function useProjects(clientId: string) { + return useQuery({ + queryKey: ['clients', clientId, 'projects'], + queryFn: () => apiClient.listProjects(clientId), + enabled: !!clientId, + }); +} + +export function useCreateProject(clientId: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (data: ProjectCreateRequest) => apiClient.createProject(clientId, data), + onSuccess: () => qc.invalidateQueries({ queryKey: ['clients', clientId, 'projects'] }), + }); +} + +export function useUpdateProject(clientId: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ projectId, data }: { projectId: string; data: Partial<{ name: string; is_active: boolean }> }) => + apiClient.updateProject(clientId, projectId, data), + onSuccess: () => qc.invalidateQueries({ queryKey: ['clients', clientId, 'projects'] }), + }); +} + +export function useArchiveProject(clientId: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (projectId: string) => apiClient.archiveProject(clientId, projectId), + onSuccess: () => qc.invalidateQueries({ queryKey: ['clients', clientId, 'projects'] }), + }); +} diff --git a/frontend/src/hooks/useMultiUpload.ts b/frontend/src/hooks/useMultiUpload.ts index 391bf9b..a0c3b7d 100644 --- a/frontend/src/hooks/useMultiUpload.ts +++ b/frontend/src/hooks/useMultiUpload.ts @@ -13,6 +13,7 @@ export interface FileListItem { export interface SharedJobSettings { requestedOutputs: RequestedOutputs; brandContext?: string; + projectId?: string; } interface UseMultiUploadOptions { @@ -108,6 +109,7 @@ export function useMultiUpload(options: UseMultiUploadOptions = {}): UseMultiUpl title: item.autoTitle, requested_outputs: settings.requestedOutputs, brand_context: settings.brandContext, + project_id: settings.projectId, }, item.file, (progressEvent) => { diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index b8ce0b8..1777f96 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -38,6 +38,13 @@ import type { ReviewNotesListResponse, AccessibleVideoEditState, PausePointData, + Client, + Team, + Project, + PMUser, + ClientCreateRequest, + TeamCreateRequest, + ProjectCreateRequest, } from '../types/api'; const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000'; @@ -162,6 +169,9 @@ class ApiClient { if (data.brand_context) { formData.append('brand_context', data.brand_context); } + if (data.project_id) { + formData.append('project_id', data.project_id); + } formData.append('file', file); const response = await this.client.post('/jobs', formData, { @@ -432,9 +442,14 @@ class ApiClient { } // Accessible Video QC Editing endpoints - async getAccessibleVideoEditState(jobId: string, language: string): Promise { - const response = await this.client.get(`/jobs/${jobId}/accessible-video/${language}/edit-state`); - return response.data; + async getAccessibleVideoEditState(jobId: string, language: string): Promise { + try { + const response = await this.client.get(`/jobs/${jobId}/accessible-video/${language}/edit-state`); + return response.data; + } catch (err: any) { + if (err?.response?.status === 404) return null; + throw err; + } } async updatePausePoint( @@ -484,6 +499,87 @@ class ApiClient { ); return response.data; } + + // ── Client / Team / Project ────────────────────────────────────────────── + + async listClients(): Promise { + const r = await this.client.get('/clients'); + return r.data; + } + + async createClient(data: ClientCreateRequest): Promise { + const r = await this.client.post('/clients', data); + return r.data; + } + + async updateClient(clientId: string, data: Partial): Promise { + const r = await this.client.patch(`/clients/${clientId}`, data); + return r.data; + } + + async getClient(clientId: string): Promise { + const r = await this.client.get(`/clients/${clientId}`); + return r.data; + } + + async listPMs(clientId: string): Promise { + const r = await this.client.get(`/clients/${clientId}/pm`); + return r.data; + } + + async assignPM(clientId: string, userId: string): Promise { + await this.client.post(`/clients/${clientId}/pm`, { user_id: userId }); + } + + async removePM(clientId: string, userId: string): Promise { + await this.client.delete(`/clients/${clientId}/pm/${userId}`); + } + + async listTeams(clientId: string): Promise { + const r = await this.client.get(`/clients/${clientId}/teams`); + return r.data; + } + + async createTeam(clientId: string, data: TeamCreateRequest): Promise { + const r = await this.client.post(`/clients/${clientId}/teams`, data); + return r.data; + } + + async updateTeam(clientId: string, teamId: string, name: string): Promise { + const r = await this.client.patch(`/clients/${clientId}/teams/${teamId}`, { name }); + return r.data; + } + + async deleteTeam(clientId: string, teamId: string): Promise { + await this.client.delete(`/clients/${clientId}/teams/${teamId}`); + } + + async addTeamMember(clientId: string, teamId: string, userId: string): Promise { + await this.client.post(`/clients/${clientId}/teams/${teamId}/members`, { user_id: userId }); + } + + async removeTeamMember(clientId: string, teamId: string, userId: string): Promise { + await this.client.delete(`/clients/${clientId}/teams/${teamId}/members/${userId}`); + } + + async listProjects(clientId: string): Promise { + const r = await this.client.get(`/clients/${clientId}/projects`); + return r.data; + } + + async createProject(clientId: string, data: ProjectCreateRequest): Promise { + const r = await this.client.post(`/clients/${clientId}/projects`, data); + return r.data; + } + + async updateProject(clientId: string, projectId: string, data: Partial<{ name: string; is_active: boolean }>): Promise { + const r = await this.client.patch(`/clients/${clientId}/projects/${projectId}`, data); + return r.data; + } + + async archiveProject(clientId: string, projectId: string): Promise { + await this.client.delete(`/clients/${clientId}/projects/${projectId}`); + } } export const apiClient = new ApiClient(); diff --git a/frontend/src/routes/admin/ClientDetail.tsx b/frontend/src/routes/admin/ClientDetail.tsx new file mode 100644 index 0000000..c4d5ea2 --- /dev/null +++ b/frontend/src/routes/admin/ClientDetail.tsx @@ -0,0 +1,369 @@ +import { useState } from 'react'; +import { useParams } from 'react-router-dom'; +import { + useClient, + useTeams, useCreateTeam, useUpdateTeam, useDeleteTeam, + useAddTeamMember, useRemoveTeamMember, + useProjects, useCreateProject, useUpdateProject, useArchiveProject, + usePMs, useAssignPM, useRemovePM, +} from '../../hooks/useClients'; +import { useUsers } from '../../hooks/useUsers'; +import { useAuthStore } from '../../stores/authStore'; +import { useToastContext } from '../../contexts/ToastContext'; +import type { Team, Project } from '../../types/api'; + +export function ClientDetail() { + const { clientId } = useParams<{ clientId: string }>(); + const { user } = useAuthStore(); + const toast = useToastContext(); + const isAdmin = user?.role === 'admin'; + + const { data: client, isLoading: clientLoading } = useClient(clientId!); + const { data: teams = [] } = useTeams(clientId!); + const { data: projects = [] } = useProjects(clientId!); + const { data: pms = [] } = usePMs(clientId!); + const { data: usersResp } = useUsers({ size: 200, active_only: true }); + const allUsers = usersResp?.users ?? []; + + const createTeam = useCreateTeam(clientId!); + const updateTeam = useUpdateTeam(clientId!); + const deleteTeam = useDeleteTeam(clientId!); + const addMember = useAddTeamMember(clientId!); + const removeMember = useRemoveTeamMember(clientId!); + + const createProject = useCreateProject(clientId!); + const updateProject = useUpdateProject(clientId!); + const archiveProject = useArchiveProject(clientId!); + + const assignPM = useAssignPM(clientId!); + const removePM = useRemovePM(clientId!); + + // UI state + const [newTeamName, setNewTeamName] = useState(''); + const [editingTeam, setEditingTeam] = useState(null); + const [expandedTeam, setExpandedTeam] = useState(null); + const [addMemberTeamId, setAddMemberTeamId] = useState(null); + const [selectedMemberUserId, setSelectedMemberUserId] = useState(''); + + const [newProjectName, setNewProjectName] = useState(''); + const [editingProject, setEditingProject] = useState(null); + + const [pmUserId, setPmUserId] = useState(''); + + if (clientLoading) { + return
; + } + if (!client) { + return
Client not found
; + } + + const pmUserIds = new Set(pms.map(p => p.id)); + + const handleCreateTeam = async (e: React.FormEvent) => { + e.preventDefault(); + if (!newTeamName.trim()) return; + try { + await createTeam.mutateAsync({ name: newTeamName.trim() }); + setNewTeamName(''); + toast.success('Team created'); + } catch { toast.error('Failed to create team'); } + }; + + const handleCreateProject = async (e: React.FormEvent) => { + e.preventDefault(); + if (!newProjectName.trim()) return; + try { + await createProject.mutateAsync({ name: newProjectName.trim() }); + setNewProjectName(''); + toast.success('Project created'); + } catch { toast.error('Failed to create project'); } + }; + + const handleAssignPM = async (e: React.FormEvent) => { + e.preventDefault(); + if (!pmUserId) return; + try { + await assignPM.mutateAsync(pmUserId); + setPmUserId(''); + toast.success('PM assigned'); + } catch { toast.error('Failed to assign PM'); } + }; + + return ( +
+ {/* Header */} +
+

Clients

+

{client.name}

+

{client.slug}

+
+ + {/* Project Managers (admin only) */} + {isAdmin && ( +
+

Project Managers

+
+ {pms.length === 0 &&

No PMs assigned

} + {pms.map(pm => ( +
+
+

{pm.full_name}

+

{pm.email}

+
+ +
+ ))} +
+
+ + +
+
+ )} + + {/* Teams */} +
+

Teams

+
+ {teams.length === 0 &&

No teams yet

} + {teams.map(team => ( +
+
setExpandedTeam(expandedTeam === team.id ? null : team.id)} + > +
+ {team.name} + + {team.member_user_ids.length} members + +
+
e.stopPropagation()}> + + + {expandedTeam === team.id ? '▲' : '▼'} +
+
+ {expandedTeam === team.id && ( +
+
+ {team.member_user_ids.length === 0 && ( +

No members

+ )} + {team.member_user_ids.map(uid => { + const u = allUsers.find(u => u.id === uid); + return ( +
+ {u ? `${u.full_name} (${u.email})` : uid} + +
+ ); + })} +
+ {addMemberTeamId === team.id ? ( +
+ + + +
+ ) : ( + + )} +
+ )} +
+ ))} +
+
+ setNewTeamName(e.target.value)} + placeholder="New team name…" + className="flex-1 border border-gray-300 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" + /> + +
+
+ + {/* Projects */} +
+

Projects

+
+ {projects.length === 0 &&

No projects yet

} + {projects.map(project => ( +
+ {editingProject?.id === project.id ? ( +
{ + e.preventDefault(); + await updateProject.mutateAsync({ projectId: project.id, data: { name: editingProject.name } }); + setEditingProject(null); + toast.success('Project updated'); + }} + > + setEditingProject({ ...editingProject, name: e.target.value })} + className="flex-1 border border-gray-300 rounded px-2 py-1 text-sm" + autoFocus + /> + + +
+ ) : ( + <> +
+ {project.name} + {!project.is_active && ( + Archived + )} +
+
+ + {project.is_active && ( + + )} +
+ + )} +
+ ))} +
+
+ setNewProjectName(e.target.value)} + placeholder="New project name…" + className="flex-1 border border-gray-300 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" + /> + +
+
+ + {/* Rename team modal */} + {editingTeam && ( +
+
{ + e.preventDefault(); + await updateTeam.mutateAsync({ teamId: editingTeam.id, name: editingTeam.name }); + setEditingTeam(null); + toast.success('Team renamed'); + }} + > +

Rename Team

+ setEditingTeam({ ...editingTeam, name: e.target.value })} + className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm mb-4 focus:outline-none focus:ring-2 focus:ring-blue-500" + autoFocus + /> +
+ + +
+
+
+ )} +
+ ); +} diff --git a/frontend/src/routes/admin/ClientList.tsx b/frontend/src/routes/admin/ClientList.tsx new file mode 100644 index 0000000..ad2dace --- /dev/null +++ b/frontend/src/routes/admin/ClientList.tsx @@ -0,0 +1,138 @@ +import { useState } from 'react'; +import { Link } from 'react-router-dom'; +import { useClients, useCreateClient } from '../../hooks/useClients'; +import { useToastContext } from '../../contexts/ToastContext'; + +export function ClientList() { + const { data: clients, isLoading } = useClients(); + const createClient = useCreateClient(); + const toast = useToastContext(); + + const [showCreate, setShowCreate] = useState(false); + const [name, setName] = useState(''); + const [slug, setSlug] = useState(''); + + const handleCreate = async (e: React.FormEvent) => { + e.preventDefault(); + try { + await createClient.mutateAsync({ name, slug }); + toast.success('Client created'); + setShowCreate(false); + setName(''); + setSlug(''); + } catch { + toast.error('Failed to create client'); + } + }; + + const autoSlug = (v: string) => + v.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); + + if (isLoading) { + return ( +
+
+ {[...Array(4)].map((_, i) =>
)} +
+
+ ); + } + + return ( +
+
+
+

Clients

+

Manage client organisations, their teams and projects

+
+ +
+ + {clients && clients.length === 0 && ( +
+

No clients yet. Create your first client to get started.

+
+ )} + +
+ {clients?.map(client => ( + +
+

{client.name}

+

{client.slug}

+
+
+ {!client.is_active && ( + Inactive + )} + +
+ + ))} +
+ + {/* Create modal */} + {showCreate && ( +
+
+

New Client

+
+
+ + { + setName(e.target.value); + setSlug(autoSlug(e.target.value)); + }} + required + className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" + placeholder="e.g. Ford Motor Company" + /> +
+
+ + setSlug(e.target.value)} + required + pattern="[a-z0-9\-]+" + className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500" + placeholder="ford-motor-company" + /> +

Lowercase letters, numbers and hyphens only

+
+
+ + +
+
+
+
+ )} +
+ ); +} diff --git a/frontend/src/routes/jobs/NewJob.tsx b/frontend/src/routes/jobs/NewJob.tsx index 8844f9e..9ce0462 100644 --- a/frontend/src/routes/jobs/NewJob.tsx +++ b/frontend/src/routes/jobs/NewJob.tsx @@ -12,6 +12,7 @@ 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 type { JobCreateRequest, TTSPreferences, AccessibleVideoMethod } from '../../types/api'; const jobSchema = z.object({ @@ -38,6 +39,10 @@ export function NewJob() { // Shared state const [brandContext, setBrandContext] = useState(''); + const [selectedClientId, setSelectedClientId] = useState(''); + const [selectedProjectId, setSelectedProjectId] = useState(''); + const { data: clients = [] } = useClients(); + const { data: projects = [] } = useProjects(selectedClientId); const [showVoiceSettings, setShowVoiceSettings] = useState(false); const [ttsPreferences, setTtsPreferences] = useState({ provider: 'gemini', @@ -136,6 +141,7 @@ export function NewJob() { translation_mode: data.translation_mode, }, brand_context: brandContext.trim() || undefined, + project_id: selectedProjectId || undefined, }; try { @@ -216,6 +222,7 @@ export function NewJob() { translation_mode: data.translation_mode, }, brandContext: brandContext.trim() || undefined, + projectId: selectedProjectId || undefined, }); }; @@ -262,6 +269,7 @@ export function NewJob() { translation_mode: data.translation_mode, }, brandContext: brandContext.trim() || undefined, + projectId: selectedProjectId || undefined, }); }; @@ -690,6 +698,42 @@ export function NewJob() {
)} + {/* Project */} + {clients.length > 0 && ( +
+
+ + +
+ {selectedClientId && ( +
+ + +
+ )} +
+ )} + {/* Brand Context */}