feat(help): in-app role-based help guides + screenshot capture pipeline

- Help.tsx: role tabs, TOC scroll-spy, search, lightbox, react-markdown renderer
- 7 markdown guides (global, client, linguist, reviewer, production, PM, admin)
  with explicit click/drag/keyboard annotations throughout
- Sidebar: Help button added at bottom of nav (all roles)
- App.tsx: /help route, no RoleGate
- frontend/public/help-screenshots/{role}/: directories ready for screenshots
- tools/capture-help-screenshots.ts: Playwright screenshot script
  - Clicks "Local login" toggle before filling credentials
  - Uses test-admin local account (not SSO)
- backend/scripts/seed_test_users.py: idempotent MongoDB seed script
  creates 6 local-auth users (admin + 5 roles) for capture + local dev
- .env.screenshots.example: template with test-admin credentials
- Removes docs/video_accessibility_user_guide_v3.md (superseded by in-app guides)
- Deps: react-markdown, remark-gfm, rehype-raw added to frontend

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-05-01 13:08:13 +01:00
parent d2adfbc3b4
commit 6559ccc1f9
22 changed files with 5852 additions and 1884 deletions

23
.env.screenshots.example Normal file
View file

@ -0,0 +1,23 @@
# Screenshot capture credentials — copy to .env.screenshots and fill in values
# NEVER commit .env.screenshots (it is gitignored)
BASE_URL=https://optical-dev.oliver.solutions/video-accessibility
# Local-password admin seeded by backend/scripts/seed_test_users.py
TEST_ADMIN_EMAIL=test-admin@oliver.agency
TEST_ADMIN_PASSWORD=TestAdmin2026!
TEST_CLIENT_EMAIL=test-client@oliver.agency
TEST_CLIENT_PASSWORD=TestClient2026!
TEST_LINGUIST_EMAIL=test-linguist@oliver.agency
TEST_LINGUIST_PASSWORD=TestLinguist2026!
TEST_REVIEWER_EMAIL=test-reviewer@oliver.agency
TEST_REVIEWER_PASSWORD=TestReviewer2026!
TEST_PRODUCTION_EMAIL=test-production@oliver.agency
TEST_PRODUCTION_PASSWORD=TestProduction2026!
TEST_PM_EMAIL=test-pm@oliver.agency
TEST_PM_PASSWORD=TestPM2026!

1
.gitignore vendored
View file

@ -12,6 +12,7 @@ examples/
.env.local
.env.production
.env.*.local
.env.screenshots
secrets/
*.pem
*.key

View file

@ -0,0 +1,85 @@
"""Seed test users for screenshot capture and local development.
Run from backend/ directory (with MONGODB_URI set in environment or .env):
python scripts/seed_test_users.py
Or inside a running Docker container:
docker compose exec backend python scripts/seed_test_users.py
Idempotent skips users that already exist (case-insensitive email match).
"""
import asyncio
import os
import re
import sys
from datetime import datetime
from bson import ObjectId
from motor.motor_asyncio import AsyncIOMotorClient
from passlib.context import CryptContext
# Load .env if dotenv is available (optional convenience for local runs)
try:
from dotenv import load_dotenv
load_dotenv()
except ImportError:
pass
MONGODB_URI = os.environ.get("MONGODB_URI") or os.environ.get("MONGO_URI")
MONGODB_DB = os.environ.get("MONGODB_DB", "accessible_video")
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
TEST_USERS = [
{"email": "test-admin@oliver.agency", "password": "TestAdmin2026!", "full_name": "Test Admin", "role": "admin"},
{"email": "test-client@oliver.agency", "password": "TestClient2026!", "full_name": "Test Client", "role": "client"},
{"email": "test-linguist@oliver.agency", "password": "TestLinguist2026!", "full_name": "Test Linguist", "role": "linguist"},
{"email": "test-reviewer@oliver.agency", "password": "TestReviewer2026!", "full_name": "Test Reviewer", "role": "reviewer"},
{"email": "test-production@oliver.agency", "password": "TestProduction2026!", "full_name": "Test Production", "role": "production"},
{"email": "test-pm@oliver.agency", "password": "TestPM2026!", "full_name": "Test Project Manager","role": "project_manager"},
]
async def main() -> None:
if not MONGODB_URI:
print("ERROR: MONGODB_URI env var is not set.", file=sys.stderr)
sys.exit(1)
client = AsyncIOMotorClient(MONGODB_URI)
db = client[MONGODB_DB]
created = 0
skipped = 0
for u in TEST_USERS:
pattern = re.compile(f"^{re.escape(u['email'])}$", re.IGNORECASE)
existing = await db.users.find_one({"email": pattern})
if existing:
print(f" skip {u['email']} (already exists)")
skipped += 1
continue
doc = {
"_id": str(ObjectId()),
"email": u["email"],
"hashed_password": pwd_context.hash(u["password"]),
"full_name": u["full_name"],
"role": u["role"],
"auth_provider": "local",
"is_active": True,
"created_at": datetime.utcnow(),
"updated_at": datetime.utcnow(),
}
await db.users.insert_one(doc)
print(f" created {u['email']} ({u['role']})")
created += 1
client.close()
print(f"\nDone — {created} created, {skipped} skipped.")
if __name__ == "__main__":
asyncio.run(main())

View file

@ -1,198 +1,397 @@
# API Specification — Accessible Video Processing Platform
<!-- SCOPE: api-spec | owner: ln-113 | generated: 2026-04-29 -->
<!-- SCOPE: REST API contracts — endpoints, auth scheme, request/response schemas, error codes. No implementation code. -->
<!-- DOC_KIND: reference -->
<!-- DOC_ROLE: canonical -->
<!-- READ_WHEN: Read when implementing a client call, debugging a 4xx/5xx, or adding a new route. -->
<!-- SKIP_WHEN: Skip when you only need database schema or infrastructure details. -->
<!-- PRIMARY_SOURCES: backend/app/api/v1/routes_*.py, backend/app/schemas/ -->
**Base URL (production):** `https://ai-sandbox.oliver.solutions/video-accessibility-back`
**Base URL (local):** `http://localhost:8003`
**OpenAPI docs:** `{base_url}/docs` (Swagger UI)
**Generated:** 2026-05-01
All endpoints require `Authorization: Bearer <access_token>` except `/auth/login`, `/auth/refresh`, `/auth/microsoft/*`, and `/health`.
---
## Quick Navigation
- [Docs Hub](../README.md)
- [Architecture](architecture.md)
- [Database Schema](database_schema.md)
- [Tech Stack](tech_stack.md)
## Agent Entry
| Signal | Value |
|--------|-------|
| Purpose | Authoritative REST API contract for all backend routes |
| Read When | You need endpoint paths, auth requirements, or request/response shapes |
| Skip When | You need DB schema → database_schema.md; infrastructure → infrastructure.md |
| Canonical | Yes |
| Next Docs | [Architecture](architecture.md), [Database Schema](database_schema.md) |
| Primary Sources | `backend/app/api/v1/routes_*.py` |
---
## Base URLs
| Environment | URL |
|-------------|-----|
| Production | `https://ai-sandbox.oliver.solutions/video-accessibility-back` |
| Local (Docker) | `http://localhost:8012` |
| OpenAPI (Swagger) | `{base_url}/docs` |
All routes are prefixed with `/api/v1/`.
---
## Authentication
**Scheme:** Bearer token (access token in memory) + HttpOnly refresh cookie.
| Header / Cookie | Description |
|-----------------|-------------|
| `Authorization: Bearer <access_token>` | Required for all protected endpoints |
| `refresh_token` cookie (HttpOnly) | Used by `/auth/refresh` only |
Roles: `client`, `linguist`, `reviewer`, `production`, `project_manager`, `admin`.
### Auth Endpoints
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| POST | `/api/v1/auth/login` | None | Email/password login; returns access token + sets refresh cookie |
| POST | `/api/v1/auth/refresh` | Cookie | Exchange refresh cookie for new access token |
| POST | `/api/v1/auth/logout` | Bearer | Revoke refresh token, clear cookie |
| POST | `/api/v1/auth/microsoft/callback` | None | Microsoft SSO callback; validates OIDC token |
| GET | `/api/v1/auth/microsoft/login` | None | Redirect to Microsoft login |
| POST | `/api/v1/auth/change-password` | Bearer | Change own password |
| POST | `/auth/login` | None | Email/password login; returns access token + sets refresh cookie |
| POST | `/auth/microsoft` | None | Microsoft OIDC token validation; returns access + refresh |
| POST | `/auth/refresh` | Cookie | Exchange refresh cookie for new access token |
| POST | `/auth/logout` | Bearer | Revoke refresh token, clear cookie |
**Login response fields:**
**Login request:**
```json
{ "email": "user@example.com", "password": "secret" }
```
| Field | Type | Notes |
|-------|------|-------|
| access_token | string | JWT, 15-minute TTL |
| token_type | string | Always "bearer" |
| user | object | User profile (id, email, role, org_id) |
**Login response:**
```json
{ "access_token": "eyJ...", "token_type": "bearer", "user": { "id": "...", "email": "...", "role": "admin" } }
```
---
## Jobs
| Method | Path | Roles | Description |
|--------|------|-------|-------------|
| GET | `/api/v1/jobs` | ALL | List jobs (role-filtered: client sees own, reviewer/admin see all) |
| POST | `/api/v1/jobs` | CLIENT, ADMIN | Create job with MP4 upload |
| GET | `/api/v1/jobs/{id}` | ALL | Job detail with current status + outputs |
| DELETE | `/api/v1/jobs/{id}` | ADMIN | Delete job and GCS files |
| GET | `/api/v1/jobs/{id}/downloads` | ALL | Signed download URLs for deliverables (24h expiry) |
| POST | `/api/v1/jobs/{id}/actions/approve` | REVIEWER, ADMIN | Approve job at current QC stage |
| POST | `/api/v1/jobs/{id}/actions/reject` | REVIEWER, ADMIN | Reject job with reason |
| POST | `/api/v1/jobs/{id}/actions/feedback` | REVIEWER, ADMIN | Send QC feedback without rejection |
| POST | `/api/v1/jobs/{id}/actions/retry` | ADMIN | Retry failed task (TTS_FAILED, RENDER_FAILED) |
### Upload (resumable)
**Job object key fields:**
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| POST | `/jobs/upload/init` | Bearer | Initialise resumable GCS upload; returns signed upload URL |
| POST | `/jobs/upload/complete` | Bearer | Finalise upload; creates Job record and enqueues ingestion |
| Field | Type | Notes |
|-------|------|-------|
| _id | string | MongoDB ObjectId |
| status | string | JobStatus enum — see architecture.md |
| org_id | string | Organisation that owns the job |
| source_language | string | BCP-47 language code |
| requested_outputs | array | Output language codes requested |
| outputs | object | Per-language GCS paths |
| language_qc | object | Per-language QC state |
| created_at | datetime | ISO 8601 |
| updated_at | datetime | ISO 8601 |
| error | string | Last error message if failed |
**`/jobs/upload/init` request:**
```json
{ "filename": "video.mp4", "content_type": "video/mp4", "size_bytes": 104857600 }
```
---
**`/jobs/upload/complete` request:**
```json
{
"gcs_uri": "gs://accessible-video/jobs/{job_id}/source.mp4",
"filename": "video.mp4",
"source_language": "en",
"requested_outputs": {
"captions_vtt": true,
"audio_description_vtt": true,
"audio_description_mp3": true,
"accessible_video_mp4": false,
"languages": ["fr", "de"],
"tts_preferences": { "provider": "gemini", "model": "flash", "default_voice": "Kore" }
}
}
```
## VTT Management
### Job CRUD & Listing
| Method | Path | Roles | Description |
|--------|------|-------|-------------|
| GET | `/api/v1/jobs/{id}/vtt/{lang}` | REVIEWER, ADMIN | Get VTT content for language |
| PATCH | `/api/v1/jobs/{id}/vtt/{lang}` | REVIEWER, LINGUIST, ADMIN | Update VTT content (auto-snapshots before save) |
| POST | `/api/v1/vtt/adjust-timing` | REVIEWER, ADMIN | Bulk shift all cue timings |
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| POST | `/jobs` | Bearer | Create job directly (small upload, no resumable) |
| GET | `/jobs` | Bearer | List jobs (paginated, filterable by status/org/client/project) |
| GET | `/jobs/{job_id}` | Bearer | Get single job with full language output map |
| PATCH | `/jobs/{job_id}` | Bearer | Update job metadata (title, notes, requested outputs) |
| DELETE | `/jobs/{job_id}` | Bearer | Soft-delete job |
| POST | `/jobs/{job_id}/clone` | Bearer | Clone a job (new upload, same config) |
| POST | `/jobs/{job_id}/cancel` | Bearer | Cancel a running job |
| POST | `/jobs/{job_id}/retry` | Bearer | Retry failed job from the failed step |
| GET | `/jobs/{job_id}/validate` | Bearer | Validate all GCS assets exist for a completed job |
---
**Job list query params:** `status`, `org_id`, `client_id`, `project_id`, `page`, `size`, `sort`.
## VTT Version Control
**Job list response:**
```json
{ "items": [ { "id": "...", "status": "pending_qc", "title": "...", "created_at": "..." } ], "total": 42, "page": 1, "size": 20 }
```
| Method | Path | Roles | Description |
|--------|------|-------|-------------|
| GET | `/api/v1/jobs/{id}/vtt-versions/{lang}` | REVIEWER, ADMIN | List version history |
| GET | `/api/v1/jobs/{id}/vtt-versions/{lang}/{version_id}` | REVIEWER, ADMIN | Get specific version content |
| POST | `/api/v1/jobs/{id}/vtt-versions/{lang}/{version_id}/restore` | REVIEWER, ADMIN | Restore a previous version (creates new snapshot) |
| GET | `/api/v1/jobs/{id}/vtt-versions/{lang}/diff` | REVIEWER, ADMIN | Diff two versions (`?from=v1_id&to=v2_id`) |
### Job State Transitions (Actions)
| Method | Path | Auth | Roles | Description |
|--------|------|------|-------|-------------|
| POST | `/jobs/{job_id}/actions/approve_source` | Bearer | reviewer, production, admin | Approve source language VTT |
| POST | `/jobs/{job_id}/actions/approve_english` | Bearer | reviewer, production, admin | Approve English source specifically |
| POST | `/jobs/{job_id}/actions/reject` | Bearer | reviewer, production, admin | Reject job, return to creator |
| POST | `/jobs/{job_id}/actions/complete` | Bearer | project_manager, admin | Mark job fully complete |
| POST | `/jobs/{job_id}/actions/reject_final` | Bearer | project_manager, admin | Reject at final review stage |
| POST | `/jobs/{job_id}/actions/return_to_qc` | Bearer | project_manager, admin | Return from final review to QC |
| POST | `/jobs/{job_id}/actions/blocked_on_source` | Bearer | production, admin | Flag source video issue |
| POST | `/jobs/{job_id}/actions/promote_to_qc` | Bearer | production, admin | Manually advance to pending_qc |
| POST | `/jobs/{job_id}/actions/retry_tts` | Bearer | production, admin | Retry TTS generation for a language |
### Bulk Job Actions
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| DELETE | `/jobs/bulk` | Bearer | Bulk delete jobs by IDs |
| POST | `/jobs/bulk/approve` | Bearer | Bulk approve source for multiple jobs |
| POST | `/jobs/bulk/return-to-qc` | Bearer | Bulk return jobs to QC |
| POST | `/jobs/bulk/download` | Bearer | Generate signed download ZIP for multiple jobs |
### VTT Editing
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/jobs/{job_id}/vtt` | Bearer | Get VTT content for a language/kind (`?lang=en&kind=captions`) |
| PATCH | `/jobs/{job_id}/vtt` | Bearer | Save edited VTT content (creates new version) |
| POST | `/jobs/{job_id}/vtt/adjust-timing` | Bearer | Bulk shift cue timestamps |
### VTT Versions
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/jobs/{job_id}/vtt/versions` | Bearer | List VTT version history |
| GET | `/jobs/{job_id}/vtt/versions/{version}` | Bearer | Get specific version content |
| GET | `/jobs/{job_id}/vtt/versions/diff` | Bearer | Diff two versions |
| POST | `/jobs/{job_id}/vtt/versions/restore` | Bearer | Restore an earlier version |
### Downloads & Accessible Video
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/jobs/{job_id}/downloads` | Bearer | Get signed GCS download URLs for all assets |
| GET | `/jobs/{job_id}/accessible-video/{language}/edit-state` | Bearer | Get pause-point edit state |
| PATCH | `/jobs/{job_id}/accessible-video/{language}/pause-points/{cue_index}` | Bearer | Adjust a pause point timestamp |
---
## Language QC
| Method | Path | Roles | Description |
|--------|------|-------|-------------|
| GET | `/api/v1/jobs/{id}/language-qc` | REVIEWER, PM, ADMIN | Get per-language QC status for all languages |
| POST | `/api/v1/jobs/{id}/language-qc/{lang}/assign` | PM, ADMIN | Assign linguist to language |
| POST | `/api/v1/jobs/{id}/language-qc/{lang}/approve` | LINGUIST (assigned), PM, ADMIN | Approve language |
| POST | `/api/v1/jobs/{id}/language-qc/{lang}/reject` | LINGUIST (assigned), PM, ADMIN | Reject language with reason |
| POST | `/api/v1/jobs/{id}/language-qc/{lang}/feedback` | LINGUIST (assigned), PM, ADMIN | Send feedback without rejection |
---
## Glossaries
| Method | Path | Roles | Description |
|--------|------|-------|-------------|
| GET | `/api/v1/glossaries` | ALL | List glossaries for current org |
| POST | `/api/v1/glossaries` | ADMIN | Create glossary |
| GET | `/api/v1/glossaries/{id}` | ALL | Get glossary with terms |
| PUT | `/api/v1/glossaries/{id}` | ADMIN | Update glossary metadata |
| DELETE | `/api/v1/glossaries/{id}` | ADMIN | Delete glossary |
| POST | `/api/v1/glossaries/{id}/terms` | ADMIN | Add term |
| DELETE | `/api/v1/glossaries/{id}/terms/{term_id}` | ADMIN | Delete term |
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/language-qc/jobs/{job_id}/language-qc` | Bearer | Get QC state map for all languages |
| POST | `/language-qc/jobs/{job_id}/languages/{lang}/assign` | Bearer | Assign linguist to language |
| POST | `/language-qc/jobs/{job_id}/languages/{lang}/reassign` | Bearer | Reassign linguist |
| POST | `/language-qc/jobs/{job_id}/languages/{lang}/assign-reviewer` | Bearer | Assign reviewer |
| POST | `/language-qc/jobs/{job_id}/languages/{lang}/reassign-reviewer` | Bearer | Reassign reviewer |
| POST | `/language-qc/jobs/{job_id}/languages/bulk-assign` | Bearer | Bulk assign linguist/reviewer for multiple languages |
| POST | `/language-qc/jobs/{job_id}/languages/{lang}/start-work` | Bearer | Linguist starts working |
| POST | `/language-qc/jobs/{job_id}/languages/{lang}/submit` | Bearer | Linguist submits for review |
| POST | `/language-qc/jobs/{job_id}/languages/{lang}/open-review` | Bearer | Reviewer opens language for review |
| POST | `/language-qc/jobs/{job_id}/languages/{lang}/approve` | Bearer | Reviewer approves language |
| POST | `/language-qc/jobs/{job_id}/languages/{lang}/reject` | Bearer | Reviewer rejects language |
| POST | `/language-qc/jobs/{job_id}/languages/{lang}/reopen` | Bearer | Reopen after rejection |
| POST | `/language-qc/jobs/{job_id}/languages/{lang}/mark-cue-reviewed` | Bearer | Mark individual VTT cue reviewed |
| GET | `/language-qc/jobs/{job_id}/languages/{lang}/comments` | Bearer | List reviewer comments |
| POST | `/language-qc/jobs/{job_id}/languages/{lang}/comments` | Bearer | Add reviewer comment |
| GET | `/language-qc/me/language-qc-queue` | Bearer | Get current user's QC queue |
---
## Files
| Method | Path | Roles | Description |
|--------|------|-------|-------------|
| POST | `/api/v1/files/upload-url` | CLIENT, ADMIN | Get signed GCS upload URL |
| GET | `/api/v1/files/{job_id}/{path}` | ALL | Get signed download URL |
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| POST | `/files/signed-upload` | Bearer | Get a signed GCS upload URL for arbitrary file upload |
---
## Users and Organisations
## TTS
| Method | Path | Roles | Description |
|--------|------|-------|-------------|
| GET | `/api/v1/users/me` | ALL | Current user profile |
| GET | `/api/v1/organizations` | ADMIN | List organisations |
| POST | `/api/v1/organizations` | ADMIN | Create organisation |
| GET | `/api/v1/organizations/{id}/members` | PM, ADMIN | List org members |
| POST | `/api/v1/organizations/{id}/invite` | PM, ADMIN | Invite member |
| DELETE | `/api/v1/organizations/{id}/members/{user_id}` | PM, ADMIN | Remove member |
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/tts/voices` | Bearer | List available voices per provider |
| GET | `/tts/languages` | Bearer | List supported language codes |
| GET | `/tts/options` | Bearer | Get provider/model options |
| POST | `/tts/preview` | Bearer | Generate a short TTS audio preview for a voice |
---
## Briefs
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/briefs` | Bearer | List briefs (filtered by org/status) |
| POST | `/briefs` | Bearer | Create a brief (draft) |
| GET | `/briefs/{brief_id}` | Bearer | Get brief detail |
| PATCH | `/briefs/{brief_id}` | Bearer | Update brief (draft only) |
| POST | `/briefs/{brief_id}/submit` | Bearer | Submit brief for PM approval |
| POST | `/briefs/{brief_id}/approve` | Bearer | PM approves brief |
---
## Glossaries
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/glossaries` | Bearer | List glossaries for current client |
| POST | `/glossaries` | Bearer | Create glossary (XLSX upload) |
| GET | `/glossaries/{glossary_id}` | Bearer | Get glossary detail |
| GET | `/glossaries/{glossary_id}/terms` | Bearer | List terms (paginated) |
| POST | `/glossaries/{glossary_id}/versions` | Bearer | Upload new version (XLSX) |
| POST | `/glossaries/{glossary_id}/activate` | Bearer | Activate a glossary version |
| POST | `/glossaries/{glossary_id}/versions/{version_id}/reembed` | Bearer | Re-trigger embedding for a version |
| DELETE | `/glossaries/{glossary_id}` | Bearer | Archive/delete glossary |
---
## Organizations
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/organizations` | Bearer | List organisations |
| POST | `/organizations` | Bearer (admin) | Create organisation |
| GET | `/organizations/{org_id}` | Bearer | Get organisation |
| PATCH | `/organizations/{org_id}` | Bearer (admin) | Update organisation |
| GET | `/organizations/{org_id}/members` | Bearer | List members |
| POST | `/organizations/{org_id}/members` | Bearer (admin) | Add member |
| PATCH | `/organizations/{org_id}/members/{user_id}` | Bearer (admin) | Update member role |
| DELETE | `/organizations/{org_id}/members/{user_id}` | Bearer (admin) | Remove member |
| GET | `/organizations/me/memberships` | Bearer | Get current user's org memberships |
---
## Clients, Teams & Projects
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/clients` | Bearer | List clients |
| POST | `/clients` | Bearer (admin) | Create client |
| GET | `/clients/{client_id}` | Bearer | Get client |
| PATCH | `/clients/{client_id}` | Bearer (admin) | Update client |
| DELETE | `/clients/{client_id}` | Bearer (admin) | Delete client |
| POST | `/clients/{client_id}/pm` | Bearer (admin) | Assign PM to client |
| DELETE | `/clients/{client_id}/pm/{user_id}` | Bearer (admin) | Remove PM from client |
| GET | `/clients/{client_id}/pm` | Bearer | List PMs for client |
| GET | `/clients/{client_id}/teams` | Bearer | List teams |
| POST | `/clients/{client_id}/teams` | Bearer (admin) | Create team |
| PATCH | `/clients/{client_id}/teams/{team_id}` | Bearer (admin) | Update team |
| DELETE | `/clients/{client_id}/teams/{team_id}` | Bearer (admin) | Delete team |
| POST | `/clients/{client_id}/teams/{team_id}/members` | Bearer | Add team member |
| DELETE | `/clients/{client_id}/teams/{team_id}/members/{user_id}` | Bearer | Remove team member |
| GET | `/clients/all-projects` | Bearer | List all projects across clients |
| GET | `/clients/{client_id}/projects` | Bearer | List client projects |
| POST | `/clients/{client_id}/projects` | Bearer (admin) | Create project |
| PATCH | `/clients/{client_id}/projects/{project_id}` | Bearer (admin) | Update project |
| DELETE | `/clients/{client_id}/projects/{project_id}` | Bearer (admin) | Delete project |
---
## Share Tokens
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| POST | `/share/jobs/{job_id}/share` | Bearer | Create share token for a job |
| GET | `/share/jobs/{job_id}/share` | Bearer | List active share tokens |
| DELETE | `/share/jobs/{job_id}/share/{token_id}` | Bearer | Revoke share token |
| GET | `/share/public/share/{token}` | None | Public job preview (client portal) |
---
## Invitations
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| POST | `/invitations/preview` | None | Preview invitation details from token |
| POST | `/invitations/accept` | None | Accept invitation and create/link account |
---
## Review Notes
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/jobs/{job_id}/review-notes` | Bearer | List timestamped notes for a job asset |
| POST | `/jobs/{job_id}/review-notes` | Bearer | Create note at a video timestamp |
| GET | `/jobs/{job_id}/review-notes/{note_id}` | Bearer | Get single note |
| PATCH | `/jobs/{job_id}/review-notes/{note_id}` | Bearer | Update note |
| DELETE | `/jobs/{job_id}/review-notes/{note_id}` | Bearer | Delete note |
---
## Admin
| Method | Path | Roles | Description |
|--------|------|-------|-------------|
| GET | `/api/v1/admin/users` | ADMIN | List all users |
| PATCH | `/api/v1/admin/users/{id}` | ADMIN | Update user role or status |
| GET | `/api/v1/admin/audit-log` | ADMIN, PM | Query audit log |
| Method | Path | Auth | Roles | Description |
|--------|------|------|-------|-------------|
| GET | `/admin/users` | Bearer | admin | List all users |
| POST | `/admin/users` | Bearer | admin | Create user |
| GET | `/admin/users/{user_id}` | Bearer | admin | Get user |
| PATCH | `/admin/users/{user_id}` | Bearer | admin | Update user |
| DELETE | `/admin/users/{user_id}` | Bearer | admin | Delete user |
| POST | `/admin/users/{user_id}/reset-password` | Bearer | admin | Trigger password reset email |
| POST | `/admin/users/{user_id}/password/reset` | Bearer | admin | Set new password directly |
| GET | `/admin/stats` | Bearer | admin | Platform-wide stats |
| GET | `/admin/jobs/stats` | Bearer | admin | Job pipeline stats |
| GET | `/admin/health/detailed` | Bearer | admin | Detailed health check (DB, Redis, queues) |
| POST | `/admin/maintenance/reprocess-job/{job_id}` | Bearer | admin | Force reprocess a stuck job |
| GET | `/admin/audit-logs` | Bearer | admin | List audit log entries |
| GET | `/admin/audit-logs/user/{user_id}` | Bearer | admin | Audit log for a specific user |
| GET | `/admin/audit-logs/security` | Bearer | admin | Security-related audit events |
| DELETE | `/admin/audit-logs/cleanup` | Bearer | admin | Purge old audit logs |
---
## Production / Admin Production
| Method | Path | Auth | Roles | Description |
|--------|------|------|-------|-------------|
| GET | `/production/failures` | Bearer | production, admin | List failed jobs |
| POST | `/production/bulk-retry` | Bearer | production, admin | Bulk retry failed jobs |
| GET | `/production/queue-stats` | Bearer | production, admin | Celery queue depths and worker counts |
| POST | `/production/jobs/{job_id}/upload-final-vtt` | Bearer | production, admin | Manually upload a corrected VTT file |
---
## WebSocket
| Path | Auth | Description |
|------|------|-------------|
| `WS /api/v1/ws/jobs/{id}` | Query param `token=<access_token>` | Real-time job status updates |
| `WS /api/v1/ws/org/{org_id}` | Query param `token=<access_token>` | Org-scoped event stream |
**Message format:**
| Field | Type | Notes |
|-------|------|-------|
| type | string | `job_status_update`, `notification`, `ping` |
| job_id | string | Job ObjectId |
| status | string | New JobStatus value |
| updated_at | datetime | ISO 8601 |
---
## Health
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/health` | None | Returns `{"status":"healthy","version":"1.0.0"}` |
| GET | `/metrics` | None (internal) | Prometheus metrics |
| GET | `/ws/status` | Bearer | WebSocket endpoint for real-time job status updates |
---
## Error Response Format
## Standard Error Responses
All errors return:
| Field | Type | Notes |
|-------|------|-------|
| detail | string | Human-readable error message (never internal exception text) |
Common status codes:
| Code | Meaning |
|------|---------|
| 400 | Bad request / validation error |
| 401 | Unauthenticated or invalid token |
| 403 | Forbidden — insufficient role |
| Status | Meaning |
|--------|---------|
| 400 | Validation error — see `detail` field |
| 401 | Missing or expired access token |
| 403 | Insufficient role permissions |
| 404 | Resource not found |
| 422 | Pydantic validation error |
| 429 | Rate limit exceeded |
| 500 | Internal server error (details logged, not returned) |
| 409 | Conflict (e.g. duplicate email) |
| 422 | Pydantic schema validation failure |
| 500 | Internal server error |
**Error body:**
```json
{ "detail": "Job not found" }
```
---
## Maintenance
**Update triggers:** New endpoint added, request/response schema changed, auth flow change.
**Verification:** All endpoints listed here exist in `backend/app/api/v1/routes_*.py`. OpenAPI schema at `/docs` matches this table.
**Last Updated:** 2026-05-01
<!-- END SCOPE: api-spec -->
**Update Triggers:**
- New route file added (`routes_*.py`)
- Existing endpoint path or method changes
- New request/response fields in schemas
- Auth requirements change for an endpoint
**Verification:**
- [ ] All `@router.*` decorators in `backend/app/api/v1/routes_*.py` reflected here
- [ ] Auth column accurate for each endpoint
- [ ] Base URL correct for production deployment

View file

@ -1,219 +1,602 @@
# Database Schema — Accessible Video Processing Platform
<!-- SCOPE: database-schema | owner: ln-113 | generated: 2026-04-29 -->
<!-- SCOPE: MongoDB collections, field definitions, indexes, and ER diagram. No implementation code. -->
<!-- DOC_KIND: reference -->
<!-- DOC_ROLE: canonical -->
<!-- READ_WHEN: Read when writing a query, adding a migration, or understanding data relationships. -->
<!-- SKIP_WHEN: Skip when you only need API contracts or infrastructure details. -->
<!-- PRIMARY_SOURCES: backend/app/models/*.py, backend/app/migrations/scripts/ -->
**Database:** MongoDB Atlas
**Database name:** configured via `MONGODB_DB` env var (default: `accessible_video`)
**Generated:** 2026-05-01
---
## Quick Navigation
- [Docs Hub](../README.md)
- [Architecture](architecture.md)
- [API Spec](api_spec.md)
## Agent Entry
| Signal | Value |
|--------|-------|
| Purpose | Authoritative reference for all MongoDB collections, fields, and indexes |
| Read When | You need collection structure, field types, or index strategy |
| Skip When | You need API endpoints → api_spec.md; infrastructure → infrastructure.md |
| Canonical | Yes |
| Next Docs | [Architecture](architecture.md), [API Spec](api_spec.md) |
| Primary Sources | `backend/app/models/*.py`, `backend/app/migrations/scripts/` |
---
## Overview
| Detail | Value |
|--------|-------|
| Database | MongoDB Atlas |
| DB name | `accessible_video` (env: `MONGODB_DB`) |
| Driver | Motor (async) via PyMongo |
| Schema style | Schema-on-read; Pydantic models enforce shape in application layer |
---
## ER Diagram
```mermaid
erDiagram
users {
ObjectId _id
string email
string hashed_password
string full_name
string role
string auth_provider
bool is_active
array pm_client_ids
array languages
datetime created_at
datetime updated_at
}
jobs {
ObjectId _id
string org_id
string client_id
string project_id
string created_by_user_id
string status
object source
object requested_outputs
object tts_preferences
map language_outputs
object review
map language_qc
object failure
datetime created_at
datetime updated_at
}
organizations {
ObjectId _id
string name
string slug
bool is_active
string plan
datetime created_at
datetime updated_at
}
memberships {
ObjectId _id
string user_id
string organization_id
string role_in_org
array team_ids
datetime created_at
string created_by
}
clients {
ObjectId _id
string name
string slug
bool is_active
datetime created_at
datetime updated_at
}
teams {
ObjectId _id
string name
string client_id
array member_user_ids
datetime created_at
}
projects {
ObjectId _id
string name
string client_id
bool is_active
array default_languages
string default_linguist_id
string default_reviewer_id
datetime created_at
}
glossaries {
ObjectId _id
string client_id
string name
string source_locale
string status
string current_version_id
datetime created_at
string created_by
}
glossary_versions {
ObjectId _id
string glossary_id
int version_number
int term_count
string embedding_status
datetime created_at
}
glossary_terms {
ObjectId _id
string glossary_id
string version_id
string source_term
map translations
}
job_briefs {
ObjectId _id
string organization_id
string project_id
string title
object requested_outputs
string status
string created_by
string job_id
datetime created_at
datetime updated_at
}
vtt_versions {
ObjectId _id
string job_id
string lang
string kind
int version
string content
string gcs_uri
datetime created_at
object created_by
}
share_tokens {
string _id
string job_id
string organization_id
string created_by_user_id
datetime expires_at
bool is_active
}
invitations {
ObjectId _id
string email
string organization_id
string role_in_org
string token_hash
datetime expires_at
datetime accepted_at
}
review_notes {
ObjectId _id
string job_id
string asset_key
float timestamp_seconds
string content
string user_id
datetime created_at
}
audit_logs {
ObjectId _id
string action
string user_id
string target_type
string target_id
object details
datetime timestamp
}
users ||--o{ memberships : "belongs to org via"
organizations ||--o{ memberships : "has"
users ||--o{ jobs : "creates"
clients ||--o{ jobs : "owns"
projects ||--o{ jobs : "groups"
clients ||--o{ teams : "has"
clients ||--o{ projects : "has"
clients ||--o{ glossaries : "owns"
glossaries ||--o{ glossary_versions : "has"
glossary_versions ||--o{ glossary_terms : "contains"
jobs ||--o{ vtt_versions : "has"
jobs ||--o{ review_notes : "has"
jobs ||--o{ share_tokens : "has"
organizations ||--o{ job_briefs : "submits"
```
---
## Collections
### `jobs`
### `users`
Central document for each video accessibility job.
| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `_id` | ObjectId | Yes | Primary key |
| `email` | string | Yes | Unique; login identity |
| `hashed_password` | string | No | Null for Microsoft SSO users |
| `full_name` | string | Yes | Display name |
| `role` | string | Yes | Enum: `client`, `linguist`, `reviewer`, `production`, `project_manager`, `admin` |
| `auth_provider` | string | Yes | Enum: `local`, `microsoft` |
| `is_active` | bool | Yes | Soft-disable without deleting |
| `pm_client_ids` | string[] | No | Client IDs where user is Project Manager |
| `languages` | string[] | No | BCP-47 codes (linguist/reviewer competency) |
| `created_at` | datetime | Yes | |
| `updated_at` | datetime | No | |
| Field | Type | Description |
|-------|------|-------------|
| _id | ObjectId | Primary key |
| org_id | ObjectId | Owning organisation |
| client_user_id | ObjectId | User who uploaded the video |
| status | string | JobStatus enum (16 values — see architecture.md) |
| source_language | string | BCP-47 code (e.g., `en-US`) |
| requested_outputs | array[string] | Output language codes |
| source | object | `{ gcs_path, filename, duration_seconds }` |
| outputs | object | Per-language `{ captions_vtt, ad_vtt, ad_mp3, accessible_mp4 }` GCS paths |
| review | object | QC state `{ reviewer_id, approved_at, rejected_at, reason }` |
| language_qc | object | Per-language QC state (see LanguageQCState below) |
| vtt_versions | array | Version snapshot references (see `vtt_versions` collection) |
| glossary_id | ObjectId | Client glossary to use for translation |
| retry_count | int | Number of task retries |
| error | string | Last error message |
| created_at | datetime | ISO 8601 |
| updated_at | datetime | ISO 8601 |
| completed_at | datetime | ISO 8601 |
**LanguageQCState (per-language, nested in `language_qc`):**
| Field | Type | Description |
|-------|------|-------------|
| status | string | `pending`, `assigned`, `approved`, `rejected`, `feedback_requested` |
| linguist_id | ObjectId | Assigned linguist (nullable) |
| assigned_at | datetime | When linguist was assigned |
| reviewed_at | datetime | When approved/rejected |
| reason | string | Rejection or feedback reason |
**Indexes:**
| Index | Fields | Purpose |
|-------|--------|---------|
| Primary | `_id` | Document lookup |
| org_status | `org_id` + `status` | List jobs by org and status |
| client | `client_user_id` | Client's own jobs |
| created | `created_at` (desc) | Time-sorted listing |
| status | `status` | Status-filtered queries |
**Indexes:** `email` (unique), `role`, `is_active`, `created_at` (desc), `auth_provider`
---
### `users`
### `jobs`
| Field | Type | Description |
|-------|------|-------------|
| _id | ObjectId | Primary key |
| email | string | Unique, lowercase |
| hashed_password | string | bcrypt hash (null for SSO-only users) |
| role | string | `client`, `reviewer`, `linguist`, `pm`, `admin` |
| org_id | ObjectId | Primary organisation |
| is_active | boolean | Account enabled flag |
| microsoft_id | string | Entra ID subject claim (nullable) |
| created_at | datetime | |
| updated_at | datetime | |
| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `_id` | ObjectId | Yes | Primary key |
| `org_id` | string | No | Owning organisation |
| `client_id` | string | No | Owning client |
| `project_id` | string | No | Owning project |
| `created_by_user_id` | string | Yes | Uploader |
| `status` | string | Yes | See Job Status Enum below |
| `source` | object | Yes | `{filename, gcs_uri, duration_s, language, detected_language}` |
| `requested_outputs` | object | Yes | `{captions_vtt, audio_description_vtt, audio_description_mp3, accessible_video_mp4, languages[], tts_preferences}` |
| `tts_preferences` | object | No | `{provider, default_voice, voices_per_language, model, speed, style_preset}` |
| `language_outputs` | map | No | Key: BCP-47 lang code → `LangOutput` object |
| `review` | object | No | `{notes, reviewer_id, history[]}` |
| `language_qc` | map | No | Key: BCP-47 lang code → `LanguageQCState` |
| `failure` | object | No | `{step, type, message, retriable, occurred_at, retry_count}` |
| `created_at` | datetime | Yes | |
| `updated_at` | datetime | No | |
**Indexes:**
**`LangOutput` sub-document:**
| Index | Fields | Purpose |
|-------|--------|---------|
| email_unique | `email` (unique) | Login lookup |
| org | `org_id` | Members-of-org query |
| microsoft | `microsoft_id` (sparse) | SSO user lookup |
| Field | Type | Notes |
|-------|------|-------|
| `captions_vtt_gcs` | string | GCS URI |
| `sdh_captions_vtt_gcs` | string | SDH captions (speaker labels, sound effects, music) |
| `ad_vtt_gcs` | string | Audio description VTT |
| `ad_mp3_gcs` | string | Audio description MP3 |
| `accessible_video_gcs` | string | Rendered accessible MP4 |
| `accessible_video_method` | string | `overlay` or `pause_insert` |
| `retimed_captions_vtt_gcs` | string | Re-timed captions for pause-insert |
| `ad_cue_manifest` | array | Per-cue: `{cue_index, gcs_uri, text, duration_s}` |
| `accessible_video_edit_state` | object | Pause points, video segments, TTS regen queue |
| `descriptive_transcript_gcs` | string | WCAG combined transcript |
| `origin` | string | `translate`, `transcreate`, `gemini_translate`, `video_native` |
| `qa_notes` | string | QA reviewer notes |
**`LanguageQCState` sub-document:**
| Field | Type | Notes |
|-------|------|-------|
| `status` | string | `pending`, `in_progress`, `pending_review`, `in_review`, `approved`, `rejected` |
| `assigned_linguist_id` | string | |
| `assigned_linguist_email` | string | |
| `assigned_reviewer_id` | string | |
| `assigned_reviewer_email` | string | |
| `linguist_deadline` | datetime | |
| `reviewer_deadline` | datetime | |
| `total_cues` | int | Set when reviewer opens |
| `reviewed_cues` | int | Incremented as cues are reviewed |
| `reject_category` | string | `timing`, `mistranslation`, `terminology`, `profanity`, `length` |
| `history` | array | `LanguageQCEvent[]` |
| `comments` | array | `LanguageQCComment[]` |
**Job Status Enum:**
| Status | Meaning |
|--------|---------|
| `created` | Job record created, upload pending |
| `ingesting` | Celery worker downloading/preparing video |
| `ai_processing` | Gemini 2.5 Pro generating VTT |
| `pending_qc` | Ready for QC review |
| `approved_english` | English source VTT approved |
| `approved_source` | Non-English source VTT approved |
| `rejected` | Rejected at QC |
| `qc_feedback` | Returned with QC notes |
| `translating` | Google Translate running |
| `tts_generating` | TTS synthesis in progress |
| `tts_failed` | Legacy; use `processing_failed` + `failure.step="tts"` for new failures |
| `rendering_video` | Accessible video render in progress |
| `render_failed` | Legacy failure status |
| `processing_failed` | Unified failure — see `failure.step` |
| `rendering_qc` | Re-rendering during QC |
| `pending_final_review` | PM final sign-off required |
| `completed` | Fully delivered |
| `cancelled` | Manually cancelled |
**Indexes:** `(status, created_at desc)`, `org_id`, `client_id`, `project_id`, `created_by_user_id`
---
### `organizations`
| Field | Type | Description |
|-------|------|-------------|
| _id | ObjectId | Primary key |
| name | string | Organisation display name |
| slug | string | URL-safe identifier |
| member_ids | array[ObjectId] | User IDs in this org |
| created_at | datetime | |
| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `_id` | ObjectId | Yes | |
| `name` | string | Yes | |
| `slug` | string | Yes | URL-safe identifier |
| `is_active` | bool | Yes | |
| `plan` | string | Yes | Default: `standard` |
| `created_at` | datetime | Yes | |
| `updated_at` | datetime | No | |
**Indexes:**
---
| Index | Fields | Purpose |
|-------|--------|---------|
| slug_unique | `slug` (unique) | Org lookup by slug |
### `memberships`
| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `_id` | ObjectId | Yes | |
| `user_id` | string | Yes | |
| `organization_id` | string | Yes | |
| `role_in_org` | string | Yes | `owner`, `admin`, `manager`, `member`, `viewer` |
| `team_ids` | string[] | No | Teams within the org |
| `created_at` | datetime | No | |
| `created_by` | string | No | |
**Indexes:** `(user_id, organization_id)` unique, `organization_id`, `user_id`
---
### `clients`
| Field | Type | Notes |
|-------|------|-------|
| `_id` | ObjectId | |
| `name` | string | |
| `slug` | string | |
| `is_active` | bool | |
| `created_at` | datetime | |
| `updated_at` | datetime | |
---
### `teams`
| Field | Type | Notes |
|-------|------|-------|
| `_id` | ObjectId | |
| `name` | string | |
| `client_id` | string | FK → clients |
| `member_user_ids` | string[] | User IDs |
| `created_at` | datetime | |
---
### `projects`
| Field | Type | Notes |
|-------|------|-------|
| `_id` | ObjectId | |
| `name` | string | |
| `client_id` | string | FK → clients |
| `is_active` | bool | |
| `default_languages` | string[] | BCP-47 language codes |
| `default_linguist_id` | string | |
| `default_reviewer_id` | string | |
| `created_at` | datetime | |
| `updated_at` | datetime | |
---
### `glossaries`
| Field | Type | Description |
|-------|------|-------------|
| _id | ObjectId | Primary key |
| org_id | ObjectId | Owning organisation |
| name | string | Glossary display name |
| terms | array | Array of GlossaryTerm documents |
| created_at | datetime | |
| updated_at | datetime | |
| Field | Type | Notes |
|-------|------|-------|
| `_id` | ObjectId | |
| `client_id` | string | FK → clients |
| `name` | string | |
| `description` | string | Optional |
| `source_locale` | string | BCP-47, e.g. `en-GB` |
| `source` | string | `xlsx_upload` or `fraze_api` |
| `status` | string | `active`, `archived` |
| `current_version_id` | string | FK → glossary_versions |
| `created_at` | datetime | |
| `created_by` | string | user_id |
**GlossaryTerm (embedded in `terms`):**
---
| Field | Type | Description |
|-------|------|-------------|
| _id | ObjectId | Term ID |
| source_term | string | Term in source language |
| target_language | string | BCP-47 code |
| preferred_translation | string | Required translation |
| context | string | Usage notes (optional) |
| embedding | array[float] | Vector embedding for similarity search |
### `glossary_versions`
**Indexes:**
| Field | Type | Notes |
|-------|------|-------|
| `_id` | ObjectId | |
| `glossary_id` | string | FK → glossaries |
| `version_number` | int | Monotonically increasing |
| `source_xlsx_gcs_path` | string | Original XLSX on GCS |
| `term_count` | int | |
| `embedded_count` | int | Terms with embeddings |
| `embedding_status` | string | `pending`, `in_progress`, `done`, `failed` |
| `created_at` | datetime | |
| `created_by` | string | user_id |
| `change_note` | string | Optional version note |
| Index | Fields | Purpose |
|-------|--------|---------|
| org | `org_id` | List org glossaries |
| vector | `terms.embedding` (Atlas Vector Search) | Similarity retrieval |
---
**Atlas Vector Search index name:** `glossary_embedding_index`
### `glossary_terms`
| Field | Type | Notes |
|-------|------|-------|
| `_id` | ObjectId | |
| `glossary_id` | string | FK → glossaries |
| `version_id` | string | FK → glossary_versions |
| source term | string | Source language term |
| translations | map | locale → translated string |
---
### `job_briefs`
| Field | Type | Notes |
|-------|------|-------|
| `_id` | ObjectId | |
| `organization_id` | string | FK → organizations |
| `project_id` | string | Optional FK → projects |
| `title` | string | |
| `description` | string | Optional |
| `requested_outputs` | object | Same shape as `jobs.requested_outputs` |
| `languages` | string[] | Requested target languages |
| `deadline` | datetime | Optional |
| `status` | string | `draft`, `submitted`, `approved`, `rejected`, `fulfilled` |
| `created_by` | string | user_id |
| `job_id` | string | FK → jobs (set when brief is fulfilled) |
| `created_at` | datetime | |
| `updated_at` | datetime | |
| `submitted_at` | datetime | |
| `approved_by` | string | PM user_id |
| `reject_reason` | string | |
---
### `vtt_versions`
Immutable version snapshots created before each VTT save.
| Field | Type | Description |
|-------|------|-------------|
| _id | ObjectId | Primary key |
| job_id | ObjectId | Parent job |
| language | string | Language code |
| version_number | int | Sequential version number |
| content | string | Full VTT file content at time of snapshot |
| author_id | ObjectId | User who made the change |
| created_at | datetime | Snapshot timestamp |
| diff_from_prev | string | Diff against previous version (optional) |
**Indexes:**
| Index | Fields | Purpose |
|-------|--------|---------|
| job_lang | `job_id` + `language` + `version_number` | Version history listing |
| job_lang_created | `job_id` + `language` + `created_at` (desc) | Time-sorted history |
| Field | Type | Notes |
|-------|------|-------|
| `_id` | ObjectId | |
| `job_id` | string | FK → jobs |
| `lang` | string | BCP-47 language code |
| `kind` | string | `captions` or `ad` |
| `version` | int | Monotonically increasing per (job_id, lang, kind) |
| `content` | string | Full VTT file content |
| `gcs_uri` | string | Backup copy on GCS |
| `created_at` | datetime | |
| `created_by` | object | `{user_id, user_email}` |
| `note` | string | Editor note |
| `parent_version` | int | Version this was derived from |
| `cue_count` | int | |
| `byte_size` | int | |
---
### `audit_logs`
### `share_tokens`
Immutable audit trail for all reviewer, linguist, and PM actions.
| Field | Type | Description |
|-------|------|-------------|
| _id | ObjectId | Primary key |
| actor_id | ObjectId | User performing the action |
| actor_email | string | Denormalised for readability |
| action | string | Action type enum (see below) |
| job_id | ObjectId | Affected job (nullable) |
| org_id | ObjectId | Organisation context |
| before_state | string | Job status before action |
| after_state | string | Job status after action |
| metadata | object | Action-specific context (reason, language, etc.) |
| created_at | datetime | Event timestamp |
**Action types:**
| Action | Trigger |
|--------|---------|
| `job_approved` | QC approve |
| `job_rejected` | QC reject |
| `qc_feedback_sent` | QC feedback |
| `language_approved` | Language-level QC approve |
| `language_rejected` | Language-level QC reject |
| `linguist_assigned` | PM assigns linguist |
| `vtt_edited` | VTT content saved |
| `vtt_restored` | Version restore |
| `job_retry` | Admin manual retry |
| `user_invited` | PM/Admin invites member |
**Indexes:**
| Index | Fields | Purpose |
|-------|--------|---------|
| job | `job_id` + `created_at` | Per-job audit trail |
| org_created | `org_id` + `created_at` (desc) | Org-level audit log |
| actor | `actor_id` + `created_at` | Per-user action history |
| Field | Type | Notes |
|-------|------|-------|
| `_id` | string | Token itself (32 hex chars) — used as PK |
| `job_id` | string | FK → jobs |
| `organization_id` | string | FK → organizations |
| `created_by_user_id` | string | |
| `created_by_email` | string | Denormalised |
| `created_at` | datetime | |
| `expires_at` | datetime | Null = never expires |
| `is_active` | bool | |
| `label` | string | Human note |
---
### `invitations`
| Field | Type | Description |
|-------|------|-------------|
| _id | ObjectId | Primary key |
| email | string | Invitee email |
| org_id | ObjectId | Org being joined |
| role | string | Role to assign on accept |
| token | string | Unique invite token (hashed) |
| expires_at | datetime | 7-day expiry |
| accepted_at | datetime | Nullable — set on accept |
| created_by | ObjectId | User who sent invite |
| Field | Type | Notes |
|-------|------|-------|
| `_id` | ObjectId | |
| `email` | string | Invitee email |
| `organization_id` | string | FK → organizations |
| `role_in_org` | string | OrgRole enum |
| `target_team_ids` | string[] | Teams to join on accept |
| `token_hash` | string | SHA-256 hash of invitation token |
| `invited_by_user_id` | string | |
| `expires_at` | datetime | |
| `accepted_at` | datetime | Null if pending |
| `revoked_at` | datetime | Null if active |
| `created_at` | datetime | |
**Indexes:** `token_hash`, `organization_id`, `email`, TTL on `expires_at`
---
### `review_notes`
| Field | Type | Notes |
|-------|------|-------|
| `_id` | ObjectId | |
| `job_id` | string | FK → jobs |
| `asset_key` | string | e.g. `en`, `es`, `en_accessible` |
| `timestamp_seconds` | float | Video timestamp when note was placed |
| `content` | string | Note text |
| `user_id` | string | Author |
| `user_name` | string | Denormalised display name |
| `created_at` | datetime | |
| `updated_at` | datetime | |
---
### `audit_logs`
| Field | Type | Notes |
|-------|------|-------|
| `_id` | ObjectId | |
| `action` | string | Dotted enum, e.g. `auth.login.success`, `job.approve` |
| `user_id` | string | Actor |
| `target_type` | string | e.g. `job`, `user` |
| `target_id` | string | Target resource ID |
| `details` | object | Freeform context |
| `timestamp` | datetime | |
**Indexes:** `timestamp` (desc), `user_id`, `action`, `(target_type, target_id)`
---
### `migrations`
Internal collection managed by `backend/app/migrations/migrator.py`.
| Field | Type | Notes |
|-------|------|-------|
| `_id` | ObjectId | |
| `version` | string | Migration script name/timestamp |
| `applied_at` | datetime | |
**Indexes:** `version` (unique), `applied_at` (desc)
---
## GCS File Layout
All binary assets are stored in GCS; MongoDB stores only GCS URIs.
```text
gs://{GCS_BUCKET}/jobs/{job_id}/
source.mp4
{lang}/
captions.vtt
ad.vtt
ad.mp3
accessible.mp4
sdh_captions.vtt
descriptive_transcript.txt
cues/
cue_{n}.mp3
```
---
## Maintenance
**Update triggers:** New collection added, index added or removed, field added to model.
**Verification:** All collections listed here exist in production Atlas. Index names match `backend/app/core/database.py` `create_indexes()` function (currently commented out — indexes were created manually).
**Last Updated:** 2026-05-01
<!-- END SCOPE: database-schema -->
**Update Triggers:**
- New model file added in `backend/app/models/`
- New migration script that adds fields or collections
- Field added/removed from existing Pydantic model
- New index created in a migration
**Verification:**
- [ ] All files in `backend/app/models/` represented
- [ ] All indexes from `backend/app/migrations/scripts/` reflected
- [ ] Job status enum matches `JobStatus` in `models/job.py`
- [ ] ER diagram relationships accurate

View file

@ -0,0 +1,332 @@
# Design Guidelines — Accessible Video Processing Platform
<!-- SCOPE: UI/UX design system (typography, colors, spacing, grid), component library (navigation, buttons, badges, forms, layout), accessibility guidelines (WCAG 2.1 AA, keyboard, ARIA), responsive behavior (breakpoints), role-based UI. ONLY. -->
<!-- DOC_KIND: explanation -->
<!-- DOC_ROLE: canonical -->
<!-- READ_WHEN: Read when you need visual standards, accessibility rules, or component-level design constraints. -->
<!-- SKIP_WHEN: Skip when you only need implementation details or backend contracts. -->
<!-- PRIMARY_SOURCES: frontend/src/components/, frontend/src/styles/index.css, frontend/tailwind.config.js -->
**Document Version:** 1.0
**Date:** 2026-05-01
**Status:** Active
---
## Quick Navigation
- [Docs Hub](../README.md)
- [Architecture](architecture.md)
- [Tech Stack](tech_stack.md)
- [API Spec](api_spec.md)
## Agent Entry
| Signal | Value |
|--------|-------|
| Purpose | Visual system, accessibility rules, and UI consistency expectations for the React 19 frontend |
| Read When | You need design constraints, token definitions, component patterns, or accessibility guidance |
| Skip When | You only need implementation code or backend/system contracts |
| Canonical | Yes |
| Next Docs | [Architecture](architecture.md), [Tech Stack](tech_stack.md) |
| Primary Sources | `frontend/src/components/`, `frontend/src/styles/index.css`, `frontend/tailwind.config.js` |
---
## 1. Design Approach
### 1.1 Design Philosophy
Clean, professional accessibility-first SaaS platform. The interface prioritises clarity and information hierarchy to support complex multi-language workflows for production teams, QC reviewers, and clients. Dark sidebar + light main content area is the primary shell pattern.
### 1.2 Stack
| Layer | Choice |
|-------|--------|
| CSS framework | Tailwind CSS v4 (utility-first, no component library) |
| Icon system | Inline SVG (24×24 standard, 16×16 compact) |
| Component pattern | Custom React 19 functional components with TypeScript |
| State (server) | TanStack Query v5 |
| State (client) | Zustand v5 |
---
## 2. Core Design Elements
### 2.1 Typography
**Font Families:**
| Role | Font | Weights | Usage |
|------|------|---------|-------|
| Primary / Body | Inter | 400, 500, 600 | All UI text |
| Fallback | system-ui, Avenir, Helvetica, Arial, sans-serif | — | When Inter unavailable |
**Type Scale (Tailwind):**
| Element | Class | Approx Size | Usage |
|---------|-------|-------------|-------|
| Page title | `text-xl font-semibold` | 20px/700 | Navbar page heading |
| Section header | `text-lg font-semibold` | 18px/600 | Card / panel titles |
| Body | `text-sm` / `text-base` | 1416px | Table cells, form labels |
| Caption / badge | `text-xs font-medium` | 12px/500 | Status badges, metadata |
**Line Height:** `leading-normal` (1.5) for body; `leading-tight` (1.25) for headings and compact UI.
---
### 2.2 Color System
**Brand & Interactive:**
| Purpose | Tailwind | Usage |
|---------|----------|-------|
| Primary action | `bg-gradient-to-r from-blue-500 to-blue-600` | Primary CTAs (New Upload, submit) |
| Primary hover | `hover:from-blue-600 hover:to-blue-700` | Primary CTA hover state |
| Link / accent | `#646cff` | Hyperlinks (global CSS) |
**Semantic UI Colors:**
| Purpose | Tailwind classes | Hex approx | Usage |
|---------|-----------------|------------|-------|
| Surface / background | `bg-white` | #ffffff | Cards, main content |
| Sidebar / shell | `bg-gray-900` or dark bg | — | App shell sidebar |
| Navbar | `bg-white shadow-sm border-b border-gray-200` | #ffffff | Top navigation bar |
| Primary text | `text-gray-900` | #111827 | Headings, body |
| Secondary text | `text-gray-600` | #4B5563 | Labels, descriptions |
| Border | `border-gray-200` | #E5E7EB | Card borders, dividers |
| Hover bg | `hover:bg-gray-100` | #F3F4F6 | Button/row hover |
**Status Badge Colors (semantic):**
| State | Tailwind classes |
|-------|-----------------|
| created | `bg-gray-100 text-gray-800` |
| ingesting / translating | `bg-blue-100 text-blue-800` |
| ai_processing | `bg-purple-100 text-purple-800` |
| pending_qc | `bg-yellow-100 text-yellow-800` |
| completed | `bg-green-100 text-green-800` |
| processing_failed / tts_failed | `bg-red-100 text-red-800` |
| qc_feedback | `bg-orange-100 text-orange-800` |
| pending_final_review | `bg-orange-100 text-orange-800` |
| tts_generating | `bg-indigo-100 text-indigo-800` |
| rendering_video / rendering_qc | `bg-violet-100 text-violet-800` |
Source: `frontend/src/utils/jobStatusMessages.ts``getJobStatusColor()`.
**Color Accessibility:** All status badge combinations (light bg / dark text) target WCAG 2.1 AA contrast (4.5:1 for text). Primary blue gradient on white meets 4.5:1 ratio.
---
### 2.3 Layout System
**Spacing Primitives (Tailwind):**
| Token | Value | Usage |
|-------|-------|-------|
| `p-2` | 8px | Icon button padding |
| `px-4 py-2` | 16px / 8px | Compact button |
| `px-6 py-4` | 24px / 16px | Navbar padding |
| `space-x-4` | 16px | Horizontal item gap |
| `gap-6` | 24px | Grid gap |
**Container Strategy:**
| Container | Max Width | Context |
|-----------|-----------|---------|
| Page shell | Full viewport | App layout |
| Main content | `max-w-7xl` (1280px) | Dashboard, job lists |
| Narrow form | `max-w-2xl` (672px) | Upload form, settings |
**App Shell Pattern:**
```text
┌─────────────────────────────────────┐
│ Navbar (h-16, bg-white, shadow-sm) │
├────────────┬────────────────────────┤
│ Sidebar │ Main Content Area │
│ (w-64) │ (flex-1, scrollable) │
│ hidden │ │
│ on mobile │ │
└────────────┴────────────────────────┘
```
**Grid System:** 12-column Tailwind grid, responsive with `grid-cols-1 md:grid-cols-2 lg:grid-cols-3`.
---
### 2.4 Component Patterns
#### Navigation
| Component | Classes | Notes |
|-----------|---------|-------|
| Navbar | `bg-white shadow-sm border-b border-gray-200 px-6 py-4` | Fixed top, h-16 |
| Sidebar | `w-64`, hidden on `< lg` | Role-filtered nav items with badge counts |
| Mobile toggle | `p-2 text-gray-600 hover:bg-gray-100 rounded-lg` | Hamburger, `lg:hidden` |
| Active nav link | `border-l-4 border-blue-500 bg-blue-50 text-blue-700` | Left border indicator |
| Badge counter | `bg-red-500 text-white text-xs rounded-full` | Pending QC / review counts |
#### Buttons
| Variant | Classes | Usage |
|---------|---------|-------|
| Primary | `bg-gradient-to-r from-blue-500 to-blue-600 text-white px-4 py-2 rounded-lg hover:from-blue-600 hover:to-blue-700 shadow-sm hover:shadow-md transition-all duration-200` | Primary CTA, submit |
| Secondary | `text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-lg p-2 transition-colors` | Icon buttons, secondary actions |
| Destructive | `bg-red-500 text-white hover:bg-red-600` | Delete, reject actions |
Icon size in buttons: `w-4 h-4 mr-2` (leading icon), `w-6 h-6` (standalone icon button).
#### Status Badges
```text
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium {status-color-classes}">
```
See Status Badge Colors table above for the full mapping. Source: `src/components/StatusBadge.tsx`.
#### Forms
| Element | Classes |
|---------|---------|
| Input | `border border-gray-300 rounded-lg px-4 py-3 focus:ring-2 focus:ring-blue-500 focus:border-blue-500` |
| Label | `text-sm font-medium text-gray-700 mb-2` |
| Error | `text-red-500 text-sm mt-1` |
| Help text | `text-gray-500 text-sm mt-1` |
Forms use `react-hook-form` v7 for validation. Min touch target: 44×44px.
#### Cards / Panels
| Element | Classes |
|---------|---------|
| Card | `bg-white shadow-md rounded-lg border border-gray-200 p-6` |
| Hover | `hover:shadow-lg transition-shadow duration-200` |
| Interactive | `cursor-pointer hover:border-blue-300` |
#### Dropzone (Upload)
See `src/components/UploadDropzone/`. Uses `react-dropzone` v14. Pattern: dashed border + centered icon + instructional text + drag-active state change.
---
### 2.5 Responsive Behavior
**Breakpoints (Tailwind defaults):**
| Breakpoint | Min Width | Layout adaptation |
|------------|-----------|-------------------|
| default | 0px | Single column, sidebar hidden |
| `md` | 768px | 2-column grids |
| `lg` | 1024px | Sidebar visible, 3-column grids |
| `xl` | 1280px | Full desktop layout |
**Mobile adaptations:**
- Sidebar: hidden, toggled by hamburger button (`lg:hidden` control)
- Tables: horizontal scroll
- Navigation: mobile overlay panel
- Touch targets: min 44×44px on all interactive elements
---
## 3. Accessibility Guidelines
### 3.1 WCAG Compliance
**Target: WCAG 2.1 Level AA** — mandatory, as this platform produces accessibility assets for third parties.
| Criterion | Requirement |
|-----------|-------------|
| Contrast ratio (text) | ≥ 4.5:1 |
| Contrast ratio (UI components) | ≥ 3:1 |
| Focus visible | Visible focus ring on all interactive elements |
| Alt text | All informational images |
| Form labels | All inputs have associated labels |
### 3.2 Keyboard Navigation
- All interactive elements focusable via `Tab`
- Visible focus ring: `focus:ring-2 focus:ring-blue-500 focus:ring-offset-2`
- Logical tab order (top-to-bottom, left-to-right)
- Modal focus trap (Tab cycles within modal when open)
- Skip-to-main link for keyboard users
### 3.3 Screen Reader Support
- ARIA labels on icon-only buttons: `aria-label="Close dialog"`
- Semantic HTML: `<nav>`, `<main>`, `<article>`, `<aside>`
- Form `<label>` elements associated via `for`/`id`
- `aria-live="polite"` for toast/notification messages
- Status updates announced to screen readers
### 3.4 Focus Management
- Focus returns to trigger element after dialog/modal close
- Error messages announced via `aria-live`
- Notification menu uses ARIA landmark roles
---
## 4. Role-Based UI Patterns
The UI adapts based on the authenticated user's role. Role-gated elements are controlled at component level via `useAuthStore()`.
| Role | Visible UI |
|------|-----------|
| `client` | Jobs list (own jobs), New Upload, Downloads |
| `linguist` / `reviewer` | QC queue, VTT editor |
| `production` | All jobs, processing controls |
| `project_manager` | Briefs, final review queue, notifications |
| `admin` | All of the above + Admin panel, Org management |
Sidebar badges reflect pending work counts per role:
- `pending_qc` count → linguist/reviewer/production/admin
- `pending_final_review` count → project_manager/admin
- Failed jobs count → production/admin
- Submitted briefs count → all roles
---
## 5. Page Layout Patterns
### 5.1 Dashboard
Fixed header + sidebar shell. Main area: KPI summary row → filterable jobs table with status badges → pagination. Layout: full-width table inside `max-w-7xl` container.
### 5.2 Job Detail / QC Review
Split panel: video player (left, 60%) + VTT editor (right, 40%). Approval/rejection actions pinned to bottom. Responsive: stacked on `< lg`.
### 5.3 Upload Form
Centred narrow container (`max-w-2xl`). Steps: dropzone → metadata form → language selection → submit. Uses `react-dropzone` + `react-hook-form`.
### 5.4 Admin / Settings
Full-width table layout. Tabs for sub-sections (Users, Organisations, TTS Settings). Tables use standard card wrapper.
---
## 6. Internationalisation
The platform supports 50+ target languages for the content it processes. The UI shell itself is English-only at present. No i18n library is currently used for the UI layer.
---
## Maintenance
**Last Updated:** 2026-05-01
**Update Triggers:**
- New component added or removed from `src/components/`
- Tailwind config changes (breakpoints, theme extensions)
- Color system changes
- Accessibility audit findings
- Role changes affecting navigation visibility
**Verification:**
- [ ] Status badge color table matches `getJobStatusColor()` in `src/utils/jobStatusMessages.ts`
- [ ] Role-based UI section reflects current roles in `useAuthStore()`
- [ ] Color contrast ratios remain WCAG AA compliant
- [ ] Responsive breakpoints match Tailwind config
- [ ] Component patterns verified against actual component files in `src/components/`

View file

@ -1,146 +1,172 @@
# Infrastructure — Accessible Video Processing Platform
<!-- SCOPE: infrastructure | owner: ln-115 | generated: 2026-04-29 -->
<!-- SCOPE: Declarative inventory of what is deployed and where — servers, services, ports, external dependencies. No procedures (see runbook.md). -->
<!-- DOC_KIND: explanation -->
<!-- DOC_ROLE: canonical -->
<!-- READ_WHEN: Read when you need to know what runs where, which ports are exposed, or which external services are used. -->
<!-- SKIP_WHEN: Skip when you need deployment steps → runbook.md; API contracts → api_spec.md. -->
<!-- PRIMARY_SOURCES: docker-compose.yml, backend/app/core/config.py -->
**Generated:** 2026-05-01
---
## Quick Navigation
- [Docs Hub](../README.md)
- [Runbook](runbook.md)
- [Architecture](architecture.md)
- [Tech Stack](tech_stack.md)
## Agent Entry
| Signal | Value |
|--------|-------|
| Purpose | Inventory of servers, Docker services, ports, and external dependencies |
| Read When | You need to know what runs where, port mappings, or external service dependencies |
| Skip When | You need how-to deploy → runbook.md; API contracts → api_spec.md |
| Canonical | Yes |
| Next Docs | [Runbook](runbook.md), [Architecture](architecture.md) |
| Primary Sources | `docker-compose.yml`, `.env.example` |
---
## Server Inventory
| Server | Role | Resources | Location |
|--------|------|-----------|---------|
| optical-web-1 | Production host | 32GB RAM, 8 CPU | GCP VM |
| Server | Role | Environment |
|--------|------|-------------|
| `optical-web-1` | Production host — runs all Docker services | Production |
| Local machine | Developer workstation — Docker Compose local stack | Development |
**Domain:** ai-sandbox.oliver.solutions
**SSL:** Wildcard certificate covering *.ai-sandbox.oliver.solutions
**Production URL:** `https://ai-sandbox.oliver.solutions/video-accessibility`
**Production API URL:** `https://ai-sandbox.oliver.solutions/video-accessibility-back`
---
## URL Map
## Docker Services
| Endpoint | URL | Served by |
|----------|-----|---------|
| Frontend SPA | `https://ai-sandbox.oliver.solutions/video-accessibility/` | Apache → /var/www/html/video-accessibility |
| Backend API | `https://ai-sandbox.oliver.solutions/video-accessibility-back/` | Apache → localhost:8000 |
| Backend health | `https://ai-sandbox.oliver.solutions/video-accessibility-back/health` | FastAPI |
| Backend docs | `https://ai-sandbox.oliver.solutions/video-accessibility-back/docs` | FastAPI (Swagger) |
| Prometheus metrics | localhost:8001 | Prometheus client (internal only) |
| WebSocket | `wss://ai-sandbox.oliver.solutions/video-accessibility-back/api/v1/ws/` | Apache mod_proxy_wstunnel |
All services are defined in `docker-compose.yml` and share the `accessible-video-network` bridge network.
| Service | Image / Build | Container Name | Purpose |
|---------|--------------|----------------|---------|
| `mongodb` | `mongo:7.0` | `accessible-video-mongodb` | Primary database |
| `redis` | `redis:7-alpine` | `accessible-video-redis` | Celery broker + result backend |
| `api` | `./backend` (target: `api`) | `accessible-video-api` | FastAPI REST + WebSocket |
| `worker` | `./backend` (target: `worker`) | `accessible-video-worker` | Celery: default, ingest, notify, render queues |
| `tts-worker` | `./backend` (target: `worker`) | `accessible-video-tts-worker` | Celery: tts queue |
| `ffmpeg-worker` | `./backend` (target: `worker`) | `accessible-video-ffmpeg-worker` | Celery: ffmpeg queue |
| `whisper-worker` | `./backend` (target: `whisper-worker`) | `accessible-video-whisper-worker` | Celery: whisper queue |
---
## Docker Compose Services
## Port Allocation
| Service | Image | Port (internal) | Port (host) | Depends on |
|---------|-------|----------------|------------|-----------|
| api | backend/Dockerfile | 8000 | 8000 | mongodb, redis |
| worker | backend/Dockerfile (celery cmd) | — | — | mongodb, redis |
| mongodb | mongo:7.0 | 27017 | 27017 | — |
| redis | redis:7.2 | 6379 | 6379 | — |
| Service | Internal Port | External Port | Notes |
|---------|--------------|---------------|-------|
| `api` | 8000 | **8012** | Exposed to host |
| `mongodb` | 27017 | — | Internal only |
| `redis` | 6379 | — | Internal only |
| Workers | — | — | No HTTP port |
**Deploy path:** `/opt/video-accessibility/`
Production: nginx reverse-proxies `optical-web-1:8012``https://ai-sandbox.oliver.solutions/video-accessibility-back`.
---
## Apache Configuration Requirements
## Worker Configuration
| Module | Required for |
|--------|-------------|
| mod_rewrite | SPA routing (all paths → index.html) |
| mod_proxy | API reverse proxy |
| mod_proxy_http | HTTP proxying |
| mod_proxy_wstunnel | WebSocket proxying |
| mod_headers | CORS + security headers |
| Worker | Celery Queues | Concurrency | Memory Limit | Notes |
|--------|--------------|-------------|--------------|-------|
| `worker` | `default, ingest, notify, render` | `${WORKER_CONCURRENCY:-8}` | — | General pipeline |
| `tts-worker` | `tts` | `${TTS_WORKER_CONCURRENCY:-2}` | — | Configurable via env |
| `ffmpeg-worker` | `ffmpeg` | `${FFMPEG_WORKER_CONCURRENCY:-1}` | — | CPU-bound; 1 local, higher in Cloud Run mode |
| `whisper-worker` | `whisper` | `${WHISPER_WORKER_CONCURRENCY:-1}` | **8 GB** (4 GB reserved) | RAM-bound; Whisper large-v3 needs ~46 GB |
Config snippet location: `APACHE_DEPLOYMENT.md` (archived) and `/etc/apache2/sites-available/ai-sandbox.oliver.solutions-ssl.conf` on server.
Cloud Run offload: when `FFMPEG_SERVICE_URL` or `WHISPER_SERVICE_URL` are set, the respective workers delegate to Cloud Run HTTP endpoints instead of running locally.
---
## GCS Layout
## Volumes
**Bucket:** `accessible-video` (GCP project: `optical-414516`)
| Path pattern | Contents |
|-------------|---------|
| `{jobId}/source.mp4` | Original uploaded video |
| `{jobId}/en/captions.vtt` | English closed captions |
| `{jobId}/en/ad.vtt` | English audio description VTT |
| `{jobId}/en/ad.mp3` | English audio description audio |
| `{jobId}/{lang}/captions.vtt` | Translated captions (e.g., `fr/`, `de/`) |
| `{jobId}/{lang}/ad.vtt` | Translated audio description VTT |
| `{jobId}/{lang}/ad.mp3` | Translated audio description audio |
| `{jobId}/accessible.mp4` | Final accessible video (burned-in captions + AD audio) |
**Signed URL expiry:** 24h (V4 signing). URLs must not be cached or stored in the database.
| Volume Name | Mounted In | Purpose |
|-------------|-----------|---------|
| `accessible-video-mongodb-data` | mongodb `/data/db` | MongoDB data |
| `accessible-video-mongodb-config` | mongodb `/data/configdb` | MongoDB config |
| `accessible-video-redis-data` | redis `/data` | Redis AOF persistence |
| `accessible-video-api-logs` | api `/app/logs` | API log files |
| `accessible-video-worker-logs` | worker `/app/logs` | Worker log files |
| `accessible-video-tts-worker-logs` | tts-worker `/app/logs` | TTS worker logs |
| `accessible-video-ffmpeg-worker-logs` | ffmpeg-worker `/app/logs` | FFmpeg worker logs |
| `accessible-video-whisper-worker-logs` | whisper-worker `/app/logs` | Whisper worker logs |
| `accessible-video-shared-tmp` | worker, tts/ffmpeg/whisper workers `/shared-tmp` | Shared temp dir for FFmpeg operations |
| `./secrets` (bind mount) | all services `/secrets:ro` | GCP credentials JSON |
---
## External Service Dependencies
## External Dependencies
| Service | Region / Endpoint | Rate limits / Quotas |
|---------|-----------------|-------------------|
| MongoDB Atlas | Cloud (Atlas cluster) | M10+ tier recommended |
| GCS | us-central1 | Standard storage class |
| Gemini 2.5 Pro | `generativelanguage.googleapis.com` | Per project quota |
| Google Cloud TTS | `texttospeech.googleapis.com` | 1M chars/month free tier |
| Google Cloud Translate | `translate.googleapis.com` | 500k chars/month free tier |
| ElevenLabs | `api.elevenlabs.io` | Subscription-dependent |
| SendGrid | `api.sendgrid.com` | 100 emails/day free tier |
| Microsoft Entra ID | `login.microsoftonline.com` | Tenant-configured |
| GCP Secret Manager | `secretmanager.googleapis.com` | 10k ops/month free |
| Sentry | `sentry.io` | Project DSN |
| Service | Purpose | Config Key |
|---------|---------|------------|
| Google Cloud Storage | Video files, VTT, MP3, rendered video | `GCS_BUCKET`, `GOOGLE_APPLICATION_CREDENTIALS` |
| MongoDB Atlas | Production database (can also run local container) | `MONGODB_URI`, `MONGODB_DB` |
| Gemini 2.5 Pro | VTT generation, translation | `GEMINI_API_KEY` |
| Google Cloud Translate | Language translation | `TRANSLATE_API_KEY` |
| Google Cloud TTS | Audio description synthesis | `GOOGLE_TTS_CREDENTIALS` |
| ElevenLabs | Premium TTS synthesis | `ELEVENLABS_API_KEY` |
| SendGrid | Transactional email (notifications, delivery) | `SENDGRID_API_KEY` |
| Azure AD / Microsoft MSAL | SSO authentication | `AZURE_CLIENT_ID`, `AZURE_AUTHORITY`, `AZURE_REDIRECT_URI` |
| Sentry | Error tracking and alerting | `SENTRY_DSN` |
| OpenTelemetry / OTLP | Distributed tracing | `OTEL_EXPORTER_OTLP_ENDPOINT` |
| AI Cost Tracker | Cross-project AI cost tracking (optical-dev) | `COST_TRACKER_BASE_URL`, `COST_TRACKER_API_KEY` |
| Google Cloud Run (optional) | Offload FFmpeg and Whisper workloads | `FFMPEG_SERVICE_URL`, `WHISPER_SERVICE_URL` |
---
## Network Ports
## Health Checks
| Port | Service | Exposed to |
|------|---------|-----------|
| 443 | Apache HTTPS | Public |
| 80 | Apache HTTP (→ 443 redirect) | Public |
| 8000 | FastAPI | localhost only |
| 8001 | Prometheus metrics | localhost only |
| 27017 | MongoDB | Docker network only |
| 6379 | Redis | Docker network only |
| Service | Check | Interval |
|---------|-------|---------|
| `mongodb` | TCP port 27017 check | 60s (10s timeout, 3 retries) |
| `redis` | `redis-cli ping` | 30s (10s timeout, 3 retries) |
| `api` | Depends on mongodb + redis healthy | — |
| Workers | Depend on mongodb + redis healthy | — |
Admin health endpoint: `GET /api/v1/admin/health/detailed` (requires admin Bearer token).
---
## Secret Management
## Redis Configuration
**Production:** GCP Secret Manager. Secrets fetched at startup via `core/secrets_config.py`.
**Local:** `.env.local` (gitignored).
**Template:** `.env.prod.example` (checked in, no real values).
| Secret | Where used |
|--------|-----------|
| `JWT_SECRET_KEY` | Access token signing |
| `JWT_REFRESH_SECRET_KEY` | Refresh token signing |
| `GEMINI_API_KEY` | Gemini API |
| `ELEVENLABS_API_KEY` | ElevenLabs TTS |
| `SENDGRID_API_KEY` | Email delivery |
| `GCS_BUCKET_NAME` | File storage |
| `GOOGLE_CLOUD_PROJECT` | GCP project ID |
| `MONGODB_URI` | Atlas connection string |
| `REDIS_URL` | Redis connection |
| `SENTRY_DSN` | Error tracking |
| `DEFAULT_ADMIN_PASSWORD` | Seed script (must not have fallback value) |
```text
maxmemory 2gb
maxmemory-policy allkeys-lru
appendonly yes
```
---
## GCP Service Account IAM Roles
## Log Rotation
| Role | Purpose |
|------|---------|
| Storage Admin | GCS read/write + signed URL generation |
| AI Platform User | Gemini API access |
| Cloud Translation User | Translate API access |
| Cloud Text-to-Speech User | TTS API access |
| Secret Manager Secret Accessor | Read secrets at runtime |
All services use Docker `json-file` logging:
**Credentials file:** `./secrets/gcp-credentials.json` (mounted into Docker containers, permissions 600).
```text
max-size: 10m
max-file: 3
```
---
## Maintenance
**Update triggers:** Server migration, new external service, GCS bucket rename, secret rotation.
**Verification:** All URLs in URL Map resolve. Docker service ports match `docker-compose.prod.yml`. GCS bucket name matches `GCS_BUCKET_NAME` env var.
**Last Updated:** 2026-05-01
<!-- END SCOPE: infrastructure -->
**Update Triggers:**
- New service added to `docker-compose.yml`
- Port mapping changes
- New external dependency added
- Worker queue or concurrency configuration changes
**Verification:**
- [ ] All services in `docker-compose.yml` listed
- [ ] Port table matches `ports:` sections in compose file
- [ ] External dependencies match env vars in `.env.example`
- [ ] Volume names match `volumes:` section in compose file

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -28,7 +28,10 @@
"react-dom": "^19.1.1",
"react-dropzone": "^14.3.8",
"react-hook-form": "^7.62.0",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.8.1",
"rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.1",
"zod": "^4.0.17",
"zustand": "^5.0.7"
},

View file

@ -29,6 +29,7 @@ import { BriefDetail } from './routes/briefs/BriefDetail';
import { LinguistQueue } from './routes/jobs/LinguistQueue';
import { ReviewerQueue } from './routes/jobs/ReviewerQueue';
import { Downloads } from './routes/Downloads';
import { Help } from './routes/Help';
import { ShareView } from './routes/ShareView';
import { AcceptInvite } from './routes/AcceptInvite';
import { NoAccess } from './routes/NoAccess';
@ -234,6 +235,11 @@ function AppContent() {
<Downloads />
</AuthenticatedRoute>
} />
<Route path="/help" element={
<AuthenticatedRoute>
<Help />
</AuthenticatedRoute>
} />
</Routes>
<ToastContainer toasts={toasts} onRemove={removeToast} />
</div>

View file

@ -222,6 +222,22 @@ export function Sidebar({ onMobileClose }: SidebarProps) {
)}
</nav>
{/* Help link — bottom of nav, above user info */}
<div className="px-3 pb-2 border-t border-gray-100 pt-2">
<Link
to="/help"
onClick={onMobileClose}
className={`flex items-center px-3 py-2 rounded-lg text-sm font-medium transition-all duration-150 ${
location.pathname === '/help'
? 'bg-indigo-50 text-indigo-700'
: 'text-gray-500 hover:text-gray-900 hover:bg-gray-50'
}`}
>
<span className="text-base mr-2.5">📖</span>
<span>Help</span>
</Link>
</div>
{/* User Info */}
<div className="px-4 py-3 border-t border-gray-200">
<div className="flex items-center space-x-2.5">

View file

@ -0,0 +1,263 @@
# Admin Guide
> **You are an Admin.** You have full access to the platform — everything that every other role can do, plus **User Management**, **Organisation Settings**, and the ability to delete any job or modify any data. With this power comes responsibility: audit your own actions and treat user data with care.
---
## Your Sidebar
Admins see every sidebar item. All routes are accessible without restriction.
| Item | What it does |
|------|-------------|
| 🏠 Dashboard | Home — QC and Final Review hero cards |
| 📋 All Jobs | Full list with bulk delete, reprocess, download |
| 📤 Upload Video | Upload for any client |
| 📝 My QC Queue | Your personally assigned QC tasks |
| 🔎 Reviewer Queue | Second-pass review queue |
| 🔍 QC Review | Organisation-wide QC queue |
| ✅ Final Review | Final approval queue |
| 👥 User Management | Create, edit, reset password, deactivate users |
| 🏢 Clients | Manage client accounts and glossaries |
| 📄 Briefs | Create and approve briefs |
| 🔥 Failures | Failed jobs with retry/recover tools |
| 📋 Audit Log | Full platform audit trail |
| ⚙️ Settings | Org settings (members, teams, invitations, general) |
| 📖 Help | This guide |
---
## 1. Your Dashboard
![Admin dashboard](/help-screenshots/admin/01-dashboard.png)
Admins share the Reviewer dashboard layout:
- **Quality Control** hero card (blue) — jobs in QC. **Left-click** to open QC Review queue.
- **Final Approval** hero card (purple) — jobs in Final Review. **Left-click** to open Final Review queue.
- **KPI tiles** at the top.
---
## 2. User Management
The most important admin-exclusive feature. Only Admins can create, edit, or deactivate users.
### Viewing the user list
**Step 1.** In the sidebar, **left-click** **User Management** (👥).
![User Management list](/help-screenshots/admin/02-user-list.png)
The table shows: User (avatar + name + email), Role, Auth Method, Status (Active/Inactive), Created date, Actions.
### Filtering users
- **Role** dropdown — **left-click** to filter by role.
- **Org** dropdown — **left-click** to filter by organisation (shows users from all orgs; useful for multi-tenant platforms).
- **Active users only** checkbox — **left-click** to toggle; hides inactive users when ticked.
### Viewing AI Cost Dashboard
**Left-click** the **AI Cost Dashboard** button (purple, top-right) to open the cost analytics view in a new tab.
---
## 3. Creating a New User
**Step 1.** On the User Management page, **left-click** **Create User** (blue button, top-right).
![Create User modal](/help-screenshots/admin/03-create-user-modal.png)
**Step 2.** Fill in the form:
- **Email** (required) — the user's login email
- **Full Name** (required) — displayed in the UI
- **Password** (required, minimum 8 characters) — the user's initial password; they should change it on first login
- **Role** dropdown — **left-click** and select one of: client, reviewer, linguist, production, project_manager, admin
**Step 3.** **Left-click** **Create User** to confirm. Or **left-click** **Cancel** to close without creating.
The new user appears in the list immediately. Send the email and password to the user by a secure channel outside the platform.
---
## 4. Editing a User
**Step 1.** Find the user in the list.
**Step 2.** **Left-click** **Edit** in the Actions column for that user. You are taken to the **User Detail** page.
![User Detail page](/help-screenshots/admin/04-user-detail.png)
The page has three sections:
**Main form** (left):
- **Email** — change the login email
- **Full Name** — edit display name
- **Role** dropdown — **left-click** to change the role
- **Active User** checkbox — **left-click** to toggle active/inactive status (unchecking deactivates the user)
- **Left-click** **Save Changes** to confirm, or **Left-click** **Cancel** to discard
**User Information card** (right sidebar):
- Displays: User ID, Auth Method badge (Local or Microsoft), Created date, Status
**Assignments card** (right sidebar):
- For **Project Managers**: a table of clients they are assigned to. **Left-click** **Assign****Assigned ✓** buttons to toggle PM assignment for each client.
- For other roles: team memberships by client. **Left-click** a **Client** to see their teams, then **left-click** **Add****Member ✓** to toggle membership.
**Actions card** (right sidebar):
- For local-auth users: **Reset Password** button (orange). **Left-click** to trigger a password reset — a new password is generated and shown. Copy it and send to the user.
- For Microsoft users: a banner explaining password management is handled by Azure AD.
---
## 5. Deactivating a User
Deactivating a user prevents them from logging in without deleting their history.
**Option A — from the user list:**
**Left-click** **Deactivate** (button in the Actions column) for the user. A confirmation prompt appears. **Left-click** **Confirm**.
**Option B — from User Detail:**
**Left-click** the **Active User** checkbox to uncheck it → **left-click** **Save Changes**.
To **reactivate**: edit the user and re-check **Active User** → save.
---
## 6. Resetting a User Password
**Step 1.** Go to User Management → **left-click** **Edit** for the user.
**Step 2.** In the **Actions** card (right sidebar), **left-click** **Reset Password** (orange button).
A new temporary password is generated and shown on screen. Copy it and send it to the user through a secure channel. The user should change it after first login.
> This option is only available for users with **local** authentication. Microsoft-authenticated users manage their password through Azure AD.
---
## 7. Organisation Settings
Manage your organisation's members, teams, and general settings.
**Step 1.** In the sidebar, **left-click** **Settings** (⚙️). You are taken to the Org Settings layout with four tabs.
![Org Settings — Members tab](/help-screenshots/admin/05-org-settings-members.png)
### Members tab
Shows a table of all organisation members with their names, emails, org-level roles, and the date they joined.
**Changing a member's org-level role:**
**Left-click** the **Role** dropdown for any member → **left-click** the new role (admin, manager, member, viewer). The change saves immediately.
**Removing a member:**
**Left-click** **Remove** (red text) for the member. A confirmation appears. Confirm to remove them from the org (they remain a platform user but lose org membership).
**Inviting new members:**
**Left-click** **Invite Member** (top-right). The Invite Member modal opens.
![Invite Member modal](/help-screenshots/admin/06-invite-modal.png)
Fill in email and role, then **left-click** **Send Invitation**. The invitee receives an email with a sign-up link.
### Teams tab
**Left-click** the **Teams** tab to manage teams within the organisation. Teams group users for assignment purposes.
You can create teams, rename them, and add/remove members. **Left-click** **Create Team** to add a new one.
### Invitations tab
Shows pending invitations. **Left-click** the **Invitations** tab.
- To cancel an invitation: **left-click** **Cancel** next to the pending invite.
- To resend: **left-click** **Resend** (if available).
### General tab
**Left-click** the **General** tab to edit the organisation's name, slug, and logo.
Edit the fields and **left-click** **Save** to apply changes.
---
## 8. All Platform Operations
As Admin you have access to all other platform features documented in the other role guides:
- **Uploading videos** — see Client Guide §3
- **QC Review and approval** — see Reviewer Guide §46
- **Bulk job operations** — see Production Guide §3
- **Failure retry** — see Production Guide §7
- **Brief management** — see Production Guide §8 and Project Manager Guide §6
- **Glossary management** — see Project Manager Guide §5
- **Final Review** — see Reviewer Guide §9 and Project Manager Guide §3
- **Audit Log** — see Production Guide §9
---
## 9. Deleting Jobs
As Admin you can delete any job. **This is irreversible.**
**From All Jobs:**
**Left-click** the **Delete** icon on any job row → confirmation modal → **left-click** the checkbox to confirm you understand the action is permanent → **left-click** **Delete**.
**Bulk delete:**
Select multiple jobs with checkboxes → Action dropdown → **Delete Selected****left-click** confirmation checkbox → **left-click** **Delete**.
---
## 10. Mouse Interactions Reference
| Action | Where | What happens |
|--------|-------|--------------|
| **Left-click** **Create User** | User Management | Opens create user modal |
| **Left-click** **Edit** | User list row | Opens User Detail page |
| **Left-click** **Deactivate** | User list row | Opens deactivation confirmation |
| **Left-click** **Reset Password** | User Detail Actions card | Generates and shows temporary password |
| **Left-click** role dropdown | User Detail form | Opens role selector |
| **Left-click** **Save Changes** | User Detail form | Saves user edits |
| **Left-click** **Assign** toggle | PM Assignments | Assigns/removes PM-client assignment |
| **Left-click** **Add/Member** toggle | Team Memberships | Adds/removes user from a team |
| **Left-click** **Settings** | Sidebar | Opens Org Settings layout |
| **Left-click** org settings tab | Settings header | Switches to Members/Teams/Invitations/General |
| **Left-click** member role dropdown | Members tab | Changes member's org-level role |
| **Left-click** **Remove** | Members tab | Removes member from org |
| **Left-click** **Invite Member** | Members tab | Opens invitation modal |
| **Left-click** **Send Invitation** | Invite modal | Sends email invitation |
| **Left-click** **Cancel** | Invitations tab | Cancels a pending invite |
| **Left-click** **Delete** icon | Job row | Opens delete confirmation |
| **Left-click** delete confirmation checkbox | Delete modal | Enables the Delete button |
| **Left-click** **Delete** | Delete modal | Permanently deletes the job |
| **Left-click** **AI Cost Dashboard** | User Management header | Opens cost analytics in new tab |
| **Left-click** org filter dropdown | User Management | Filters users by organisation |
---
## 11. Keyboard Shortcuts
All shortcuts from other roles apply to Admin as well:
| Key | Context | Action |
|-----|---------|--------|
| `A` | QC Detail | Approve current language |
| `R` | QC Detail | Open Request Changes modal |
| `Cmd/Ctrl + S` | QC Detail | Save VTT |
| `1` / `2` / `3` | QC Detail | Switch view modes |
| `Esc` | Any modal | Close modal |
| `Enter` | Job title edit | Save title change |
| `Cmd/Ctrl + Enter` | VTT cue edit | Save cue text |
---
## 12. Common Issues
| Issue | Cause | Fix |
|-------|-------|-----|
| Cannot create user — "Email already in use" | User already exists | Edit the existing user to change their role if needed |
| Password reset not available | User uses Microsoft auth | Password is managed by Azure AD — contact IT |
| Cannot remove org member | Member is the org owner | Owners cannot be removed; transfer ownership first |
| User can't log in after creation | Sent wrong credentials | Reset password from User Detail page |
| Delete confirmation checkbox won't enable Delete | Checkbox must be ticked | Left-click the "I understand" checkbox first |
| Org settings not saving | Form validation error | Check for red error messages below fields |

View file

@ -0,0 +1,283 @@
# Client Guide
> **You are a Client.** You upload videos, configure the outputs you need, track your jobs through processing, and download the completed accessibility files. You cannot edit captions, manage other users, or access review queues.
---
## Your Sidebar
| Item | What it does |
|------|-------------|
| 🏠 Dashboard | Your home screen — quick upload and job stats |
| 📋 All Jobs | List of all your submitted jobs |
| 📤 Upload Video | Upload a new video |
| 📄 Briefs | Create and submit job briefs |
| 📖 Help | This guide |
---
## 1. Logging In
**Step 1.** Open the platform URL in your browser. You see the **Sign In** screen.
![Login screen](/help-screenshots/global/01-login.png)
**Step 2.** **Left-click** the **Email** field → type your email address.
**Step 3.** **Left-click** the **Password** field → type your password.
**Step 4.** **Left-click** **Sign In**.
If your company uses Microsoft, **left-click** **Sign in with Microsoft** instead.
---
## 2. Your Dashboard
After login you land on the **Dashboard** — your home screen.
![Client dashboard](/help-screenshots/client/01-dashboard.png)
### What you see
- **Quick Upload panel** (blue/purple gradient) — shows a **Upload New Video** button. **Left-click** it to start a new upload immediately.
- **Platform Features** panel — three cards explaining AI Processing, Multi-Language Support, and Quality Assurance. These are informational — no action required.
- **Job status counters** at the top — shows counts for Total, Processing, In QC Review, and Completed jobs.
**Left-click** any status counter to jump to All Jobs filtered by that status.
---
## 3. Uploading a Single Video
**Step 1.** In the sidebar, **left-click** **Upload Video** (📤). You land on the **New Job** page.
![New Job page](/help-screenshots/client/02-new-job.png)
**Step 2.** In the **Drop video file here** zone, either:
- **Left-click** the drop zone to open a file picker — navigate to your video file and **left-click** **Open**, **or**
- **Drag** a video file from your file explorer and **drop** it onto the drop zone (the zone turns blue when the file is hovering over it).
![File drop zone with drag-over highlight](/help-screenshots/client/03-upload-dropzone.png)
Supported formats: `.mp4`. The file appears as a preview with its name and size.
**Step 3.** In the **Job Title** field, type a descriptive name for this job (e.g. "Product Launch Video — May 2026"). This is how the job appears in your list.
**Step 4.** Under **Requested Outputs**, **left-click** the checkboxes for the file types you need:
| Checkbox | What it produces |
|----------|-----------------|
| ☑ Captions VTT | Closed captions file |
| ☑ Audio Description VTT | Written audio description script |
| ☑ Audio Description MP3 | Spoken narration audio file |
| ☑ Accessible Video MP4 | Your video with narration embedded |
| ☑ SDH VTT | SDH captions for Deaf and Hard of Hearing |
**Left-click** each checkbox to toggle it on or off. A blue tick mark shows it is selected.
**Step 5.** If you selected **Accessible Video MP4**, a new section appears asking for the **Accessible Video Method**:
- **Pause Insert** (recommended) — pauses the video at natural break points to insert narration.
- **Overlay** — narration plays over the existing audio.
**Left-click** the radio button next to your preferred method.
![Accessible video method radio buttons](/help-screenshots/client/04-accessible-video-method.png)
**Step 6.** **Left-click** **Voice Settings** to expand voice and TTS preferences.
![Voice settings panel collapsed](/help-screenshots/client/05-voice-settings-collapsed.png)
![Voice settings panel expanded](/help-screenshots/client/06-voice-settings-expanded.png)
Here you can choose the voice style and language for audio narration. **Left-click** the dropdowns to change voice selections. If you are unsure, leave the defaults — your production team can update these later.
**Step 7.** Under **Target Languages**, you can add languages for translation. **Left-click** **Add Language** and select from the dropdown. Repeat for each language you need. To remove a language, **left-click** the **×** next to it.
![Language selector with added languages](/help-screenshots/client/07-languages.png)
**Step 8.** Optionally, **left-click** the **Deadline** date field and type or select a date. This helps the team prioritise your job.
**Step 9.** Optionally, type any **Brand Names** in the Brand Names field (comma-separated). This helps the AI spell proper nouns correctly.
**Step 10.** **Left-click** **Create Job** at the bottom of the page.
![Create Job button](/help-screenshots/client/08-create-job-button.png)
A progress bar appears while the file uploads. Do not close the browser tab during upload.
**Step 11.** When upload completes, you see a **success screen**:
- **Left-click** **View Job Details** to go straight to the job.
- **Left-click** **Create Another Job** to upload another video.
![Upload success screen](/help-screenshots/client/09-upload-success.png)
---
## 4. Uploading Multiple Videos at Once
**Step 1.** Go to **Upload Video** (📤) in the sidebar.
**Step 2.** In the drop zone, **drag** multiple video files from your file explorer at once and **drop** them. Or **left-click** the drop zone to open a file picker and **hold Cmd (Mac) / Ctrl (Windows)** while **left-clicking** multiple files.
The form switches to **multi-upload mode** — a file list appears with each video, its size, and a status indicator.
![Multi-upload file list](/help-screenshots/client/10-multi-upload.png)
**Step 3.** The settings (outputs, language, voice) apply to **all** files in the batch. Configure them as described in Section 3.
**Step 4.** **Left-click** **Upload N Videos** (where N is the number of files). All videos upload in sequence.
When complete, the result screen shows each file:
- ✅ Green checkmark → success, with a **View Job** link.
- ❌ Red X → upload failed. **Left-click** **Retry Failed Uploads** to retry only the failed ones.
---
## 5. Assigning a Client and Project
When uploading, you can optionally assign the job to a **Client** (organisation) and **Project** (a grouping within that client).
**Step 1.** **Left-click** the **Client** dropdown and select your client.
**Step 2.** **Left-click** the **Project** dropdown that appears. Select an existing project, or **left-click** **+ Create new project…** at the bottom of the list.
![Client and project dropdowns](/help-screenshots/client/11-client-project.png)
If you choose **Create new project**, a small inline form appears:
- Type the **Project Name**.
- Optionally set default languages, linguist, and reviewer.
- **Left-click** **Create project** to save, or **left-click** **Cancel**.
---
## 6. Tracking Your Job
**Step 1.** In the sidebar, **left-click** **All Jobs** (📋). You see a list of all your jobs.
![All Jobs list](/help-screenshots/client/12-jobs-list.png)
Each row shows: job name, date created, language count, status, deadline.
**Step 2.** **Left-click** a job row to open the **Job Detail** page.
![Job Detail page](/help-screenshots/client/13-job-detail.png)
### Job Detail tabs
| Tab | What it shows |
|-----|--------------|
| **Overview** (default) | Status, requested outputs, language assets, review notes |
| **Video Preview** | Video player with captions and audio description |
| **Assets** | All generated files for each language |
| **History** | Timeline of every status change |
| **Versions** | Previous VTT version snapshots |
**Left-click** a tab name to switch to it.
### Status timeline
At the top of the Overview tab, a horizontal progress bar shows where your job is in the pipeline. Completed steps are shown in green, the current step in blue, and upcoming steps in grey.
![Status timeline](/help-screenshots/client/14-status-timeline.png)
### When it's done
When your job reaches **Completed** status, a green **Download Files** banner appears at the top of the Overview tab.
**Left-click** **Download Files** to go to the Downloads page.
---
## 7. Downloading Completed Files
**Step 1.** Open the completed job (see Section 6).
**Step 2.** **Left-click** the green **Download Files** button. You land on the **Downloads** page.
![Downloads page](/help-screenshots/client/15-downloads.png)
**Step 3.** You see sections:
- **Source Video** — your original uploaded file.
- **One card per language** — each card lists all generated files for that language.
**Step 4.** To download a single file: **left-click** the **Download** button (indigo) next to it.
**Step 5.** To download everything at once: **left-click** the **Download All** button at the top. All files download sequentially, with a brief pause between each.
> **Note:** Download links expire after 24 hours. If they expire, return to the job detail page and click **Download Files** again to get fresh links.
---
## 8. Creating a Brief
A brief lets you request accessibility work without uploading the video yourself. You describe what you need, and the Production team handles the upload.
**Step 1.** In the sidebar, **left-click** **Briefs** (📄).
**Step 2.** **Left-click** **New Brief** (blue button, top-right).
![New Brief form](/help-screenshots/client/16-new-brief.png)
**Step 3.** Fill in the form:
- **Title** (required) — e.g. "Product Launch Video EN/FR/DE"
- **Description** — any notes for the Production team
- **Requested Outputs****left-click** each checkbox for the file types you need
- **Languages****left-click** each language code to toggle it on/off; a blue highlight shows selected languages
- **Deadline** — optional date
**Step 4.** **Left-click** **Create Brief** to save as a draft.
**Step 5.** To submit it for approval, open the brief and **left-click** **Submit for Approval** (blue button). The Production team is notified.
Once a brief is **Approved**, a **Create Job from Brief** button appears — **left-click** it to start the upload, pre-filled with your brief settings.
---
## 9. All Jobs — Filters and Search
In the **All Jobs** list, you can narrow down what you see.
![Jobs list with filters](/help-screenshots/client/17-jobs-filters.png)
- **Search bar****left-click** it and type a job name, username, or filename to search.
- **Created By** dropdown — **left-click** to filter by who uploaded the job.
- **Status** dropdown — **left-click** to show only jobs in a specific status.
- **Date Created** dropdown — **left-click** to filter by time range (All Time, Last 7 Days, Last 30 Days).
- **Clear Filters** button — **left-click** to reset all filters (only shown when filters are active).
**Pagination** — if you have many jobs, use the page number buttons at the bottom. **Left-click** a page number, or use **Previous** / **Next** arrows.
---
## 10. Mouse Interactions Reference
| Action | Where | What happens |
|--------|-------|--------------|
| **Left-click** job row | All Jobs list | Opens Job Detail page |
| **Left-click** tab | Job Detail | Switches to that tab |
| **Left-click** Download | Downloads page | Starts file download |
| **Left-click** Download All | Downloads page | Downloads all files sequentially |
| **Drag + drop file** | Upload drop zone | Adds file for upload |
| **Left-click** drop zone | Upload page | Opens file picker dialog |
| **Left-click** checkbox | Outputs selection | Toggles output type on/off |
| **Left-click** radio button | Accessible video method | Selects that method |
| **Left-click** language toggle | Language selector | Adds/removes language |
| **Left-click** × on language | Language selector | Removes that language |
| **Left-click** View Job Details | Upload success screen | Opens the new job |
| **Left-click** Submit for Approval | Brief detail | Submits brief to Production |
| **Left-click** Create Job from Brief | Brief detail (approved) | Opens upload pre-filled |
| **Hover** over status badge | Jobs list | Shows full status name tooltip |
| **Hover** over deadline | Jobs list | Shows ⚠ warning if overdue |
---
## 11. Common Errors
| Error | Cause | Fix |
|-------|-------|-----|
| "Invalid file type" | File is not an MP4 | Convert your video to MP4 first |
| "File too large" | Video exceeds size limit | Compress the file or contact Production |
| Upload stuck at 99% | Slow connection | Wait or try again on a faster connection |
| Job stuck in "AI Processing" | AI pipeline issue | Wait 10 minutes; if unchanged, contact Production |
| "No Access" after login | Not added to an org | Contact your platform administrator |
| Download links expired | More than 24h since generation | Revisit the job detail page to get fresh links |

View file

@ -0,0 +1,252 @@
# Platform Overview — Video Accessibility
> This guide is for **all users**. It explains what the platform does, how to log in, and how the shared interface works — sidebar, notifications, downloads, and more.
---
## 1. What This Platform Does
The Video Accessibility Platform automatically generates legally-required accessibility files from your video:
| Output file | What it is |
|-------------|-----------|
| **Closed Captions (VTT)** | Text showing dialogue and sound effects — appears on screen |
| **Audio Description Script (VTT)** | Written description of on-screen visuals for screen readers |
| **Audio Description Voiceover (MP3)** | Spoken narration describing what is happening on screen |
| **Accessible Video (MP4)** | Your original video with spoken narration embedded |
| **SDH Captions (VTT)** | Captions for the Deaf and Hard of Hearing — includes speaker labels, sounds, and music |
| **Descriptive Transcript (TXT)** | Plain text combining all dialogue and visual descriptions |
### How the pipeline works
```
Client uploads video
AI generates captions + audio descriptions (13 min)
Linguist edits and approves English content (QC Review)
System translates to all requested languages (25 min per language)
System generates audio voiceovers (automatic)
Reviewer validates all language versions (Final Review)
Client downloads completed files
```
**Typical total time:** 1545 minutes depending on video length and reviewer availability.
---
## 2. User Roles
Each user has exactly one role. The role controls which pages and actions are visible.
| Role | What they can do |
|------|-----------------|
| **Client** | Upload videos, track jobs, download completed files, create briefs |
| **Linguist** | Edit and approve VTT content for their assigned languages |
| **Reviewer** | Everything a Linguist can, plus second-pass review and final sign-off |
| **Production** | Upload on behalf of clients, bulk manage jobs, monitor pipeline and failures |
| **Project Manager** | Final approval of deliverables, manage clients, briefs, glossaries, audit log |
| **Admin** | Full access to everything — plus user management and org settings |
> **How to find your role:** look at the bottom of the left sidebar. Your name and role are displayed there.
---
## 3. Logging In
**Step 1.** Open the platform URL in your browser — you land on the **Sign In** screen.
![Login screen with email and password fields](/help-screenshots/global/01-login.png)
**Step 2.** **Left-click** the **Email** field and type your email address.
**Step 3.** **Left-click** the **Password** field and type your password.
**Step 4.** **Left-click** the **Sign In** button to log in.
![Sign In button highlighted](/help-screenshots/global/02-login-button.png)
**Step 5.** If your organisation uses Microsoft (Azure AD), **left-click** the **Sign in with Microsoft** button instead. You will be redirected to your Microsoft login page — enter your corporate credentials there and you will be brought back automatically.
![Microsoft login button](/help-screenshots/global/03-login-microsoft.png)
> **Tip:** If you see "No Access" after login, your account has not been added to any organisation yet. Contact your platform administrator.
---
## 4. The Interface Layout
After logging in you see three zones:
| Zone | Description |
|------|-------------|
| **Left sidebar** | Navigation links, your role, organisation name |
| **Top navbar** | Notifications bell, profile/logout |
| **Main content area** | The current page |
![Interface zones annotated](/help-screenshots/global/04-interface-overview.png)
---
## 5. The Left Sidebar
The sidebar is your main navigation. Items shown depend on your role.
![Sidebar with all items visible (admin view)](/help-screenshots/global/05-sidebar.png)
| Item | Icon | Who sees it |
|------|------|------------|
| Dashboard | 🏠 | Everyone |
| All Jobs | 📋 | Everyone |
| Upload Video | 📤 | Client, Production, Admin, PM |
| My QC Queue | 📝 | Linguist, Reviewer, Production, Admin |
| Reviewer Queue | 🔎 | Reviewer, Admin |
| QC Review | 🔍 | Reviewer, Linguist, Production, Admin |
| Final Review | ✅ | Reviewer, Linguist, Production, Admin, PM |
| User Management | 👥 | Admin only |
| Clients | 🏢 | Admin, PM |
| Briefs | 📄 | Client, Production, Admin, PM |
| Failures | 🔥 | Production, Admin |
| Audit Log | 📋 | Production, Admin, PM |
| Settings ⚙️ | ⚙️ | Any user belonging to an org |
| Help | 📖 | Everyone |
**Badges** — small number badges appear next to queue items showing how many jobs need attention. These update in real time.
**Left-click** any sidebar item to navigate to that page. The active page is highlighted in indigo.
### Organisation switcher
If you belong to multiple organisations, a **dropdown** appears below the logo. **Left-click** it to switch to a different organisation — the page reloads showing that organisation's data.
---
## 6. The Top Navbar
![Top navbar with notifications and avatar](/help-screenshots/global/06-navbar.png)
| Element | What it does |
|---------|-------------|
| **Bell icon** 🔔 | **Left-click** to open the notifications panel — shows recent job status changes, approvals, and mentions |
| **Connection dot** | Green = real-time connection active; yellow = reconnecting; red = disconnected |
| **Avatar / initial** | **Left-click** to open the user menu — shows your name, role, and a **Log Out** button |
---
## 7. Notifications
**Left-click** the bell icon 🔔 in the top navbar to open the notification panel.
![Notification panel open](/help-screenshots/global/07-notifications.png)
Notifications appear when:
- A job you submitted finishes processing
- A job is approved or rejected
- A job is assigned to you for review
- A system error occurs
**Left-click** a notification to navigate directly to the relevant job.
The connection indicator in the top-right corner reflects the WebSocket real-time connection. If it turns yellow or red, your browser has lost the live connection — notifications and badge counts won't update until it reconnects. This usually resolves automatically within a few seconds.
---
## 8. Job Statuses
Every job moves through the following states. You can see the current status on the job row and on the job detail page.
| Status | Meaning |
|--------|---------|
| **Created** | Job submitted, waiting to start |
| **Ingesting** | Video file being received and validated |
| **AI Processing** | Gemini AI generating captions and audio descriptions |
| **Translating** | Translating approved English content to requested languages |
| **TTS Generating** | Synthesising audio voiceovers |
| **Rendering Video** | Embedding voiceovers into accessible video |
| **Pending QC** | Waiting for a linguist to review the content |
| **QC Feedback** | Returned to QC after reviewer requested changes |
| **Pending Final Review** | Waiting for PM or Reviewer to give final approval |
| **Completed** | All files ready for download |
| **Rejected** | Job rejected at Final Review — must be resubmitted |
| **Failed (various)** | A processing step failed — Production/Admin can retry |
---
## 9. Downloads
When a job reaches **Completed** status, a green **Download Files** banner appears on the job detail page.
**Left-click** the **Download Files** button to go to the Downloads page, where you can download individual files or all files at once.
You can also navigate directly: **left-click** "All Jobs" in the sidebar → find the completed job → **left-click** its row → **left-click** the green **Download Files** button.
![Download files page](/help-screenshots/global/08-downloads.png)
**Download All** — **left-click** this button to download all files for this job sequentially (each starts downloading after the previous one). The button is disabled while downloads are in progress.
**Individual file download** — **left-click** the **Download** button (indigo) next to each file to save it individually.
> **Note:** Download links are signed, time-limited URLs. They expire after 24 hours. If a link expires, return to the job detail page and generate a fresh download.
---
## 10. Share Links
Any job that is in **Pending QC** or later status can be shared with a read-only link. This is useful for sending a preview to a client without giving them a platform account.
**Step 1.** Open the job in **QC Review** (`/admin/qc/{id}`) or **Job Detail**.
**Step 2.** **Left-click** the **Share Link** button.
**Step 3.** Optionally type a label (e.g. "Sent to ACME 2026-05-01") in the label field.
**Step 4.** **Left-click** **Generate**.
**Step 5.** **Left-click** **Copy** to copy the link to your clipboard. Send it to the recipient.
The recipient can open the link without logging in. They can preview captions and download VTT/MP3/MP4 files. The link expires after 30 days.
---
## 11. Logging Out
**Left-click** your avatar or initial in the top-right corner → **left-click** **Log Out** in the dropdown menu.
You will be returned to the login screen. Your session is ended securely.
---
## 12. Mouse Interactions — Global Reference
This table lists every non-obvious mouse interaction that applies across the whole platform.
| Action | Where | What happens |
|--------|-------|--------------|
| **Left-click** sidebar item | Sidebar | Navigates to that page |
| **Left-click** job row | Jobs list | Opens job detail page |
| **Left-click** bell icon | Navbar | Opens notification panel |
| **Left-click** avatar | Navbar | Opens user menu with Log Out |
| **Left-click** connection dot | Navbar | Shows WebSocket status tooltip |
| **Left-click** status badge | Any | No action — decorative |
| **Left-click** Download button | Downloads, Job detail | Starts file download |
| **Left-click** Share Link | QC Detail, Job Detail | Opens share link modal |
| **Left-click** screenshot/image | Help page | Opens full-size lightbox |
| **Left-click** outside modal | Modals | Closes the modal |
| **Hover** action icon | Tables | Shows tooltip with action name |
| **Scroll** main content | Any page | Scrolls page content |
---
## 13. Keyboard Shortcuts — Global
| Key | Action |
|-----|--------|
| `Esc` | Close any open modal or dialog |
| `Tab` | Move focus to next interactive element |
| `Shift+Tab` | Move focus to previous element |
| `Enter` | Activate focused button or link |
| `Space` | Toggle focused checkbox or button |

View file

@ -0,0 +1,264 @@
# Linguist Guide
> **You are a Linguist.** Your job is to review, correct, and approve VTT caption and audio description files for the language(s) you are assigned to. You do not upload videos or manage other users. After you approve content, a Reviewer performs a second-pass check before the job moves to Final Review.
---
## Your Sidebar
| Item | What it does |
|------|-------------|
| 🏠 Dashboard | Your home screen — quality control hero cards and KPI tiles |
| 📋 All Jobs | Full list of all jobs in your organisation |
| 📝 My QC Queue | Your personal list of assigned QC tasks (with badge count) |
| 🔍 QC Review | Organisation-wide QC queue |
| ✅ Final Review | Final review queue (read access) |
| 📖 Help | This guide |
After login, you are automatically redirected to **My QC Queue** — your main working area.
---
## 1. Your Dashboard
![Linguist dashboard](/help-screenshots/linguist/01-dashboard.png)
The dashboard shows:
- **Quality Control** hero card (blue) — shows the number of jobs currently in QC. **Left-click** it or the **Go to QC Review** button to open the QC Review queue.
- **Final Approval** hero card (purple) — shows jobs awaiting final review. **Left-click** it to view the Final Review queue.
- **KPI tiles** at the top — Total, Processing, In QC Review, Completed counts.
---
## 2. My QC Queue
This is where you work. It shows only the jobs assigned **to you**.
**Step 1.** In the sidebar, **left-click** **My QC Queue** (📝).
![My QC Queue page](/help-screenshots/linguist/02-qc-queue.png)
### Status tabs
At the top of the queue, **left-click** a tab to filter:
| Tab | Shows |
|-----|-------|
| **All** | Everything |
| **Pending** | Assigned but not yet started |
| **In Progress** | You have started work |
| **Pending Review** | Submitted, awaiting reviewer |
| **In Review** | Reviewer is checking |
| **Approved** | Approved by reviewer |
| **Rejected** | Reviewer requested changes |
### Table columns
Each row shows: Job name · Language code · QC Status · Job Status · Assigned date · Action.
**Left-click** the **Assigned** column header to sort by date ascending or descending. Click again to cycle: default → ascending (↑) → descending (↓).
**Step 2.** **Left-click** **Open →** on any row to open the VTT editor for that job and language.
---
## 3. Opening a Language for Review
When you **left-click** **Open →** from your queue, you are taken to the **QC Detail** page for that job.
![QC Detail page](/help-screenshots/linguist/03-qc-detail.png)
The page shows:
- Job title and source filename at the top.
- **Per-language cards** — one collapsible card per language. Yours is expanded by default.
- Below the language card: the **VTT Editor** for Captions and/or Audio Description.
### Language card status
The language card header shows a coloured status icon and label:
- 🟡 **Pending** — assigned, not started
- 🔵 **In Progress** — you have started
- 🟣 **Pending Review** — submitted, awaiting reviewer
- ✅ **Approved** — approved by reviewer
- 🔴 **Rejected** — reviewer sent changes back
**Left-click** the language card header to expand or collapse it.
### Starting work
**Left-click** the **Start work** button (yellow) on your language card. The status changes to **In Progress** and the VTT editors appear below.
![Start work button highlighted](/help-screenshots/linguist/04-start-work.png)
---
## 4. Using the VTT Editor
The VTT editor is where you correct captions and audio descriptions cue by cue.
![VTT Editor with cues](/help-screenshots/linguist/05-vtt-editor.png)
### Reading a cue
Each cue (caption or description) is a card containing:
- **Cue number** (orange circle) — sequential, e.g. ①, ②
- **Start time****End time** (e.g. `00:00:02.000 → 00:00:05.500`)
- **Duration** in milliseconds (grey, to the right)
- **Text content** — the caption or description text
- **CPS badge** (amber) — Characters Per Second — appears if the cue is too fast to read (>20 CPS). A tooltip explains the issue.
- **Validation warnings** (amber text) — overlap or timing issues
### Editing cue text
**Step 1.** **Hover** over the text of a cue — an **Edit text** button (blue) appears on the right.
**Step 2.** **Left-click** **Edit text**. The text becomes an editable textarea (the **CueEditor**).
![Cue in edit mode](/help-screenshots/linguist/06-cue-edit-mode.png)
**Step 3.** Edit the text as needed. The character count and CPS update live.
**Step 4.** **Left-click** **Save** to save the cue, or **left-click** **Cancel** to discard changes.
> **Keyboard shortcut:** Press **Cmd+Enter** (Mac) or **Ctrl+Enter** (Windows) to save. Press **Esc** to cancel.
### Editing timestamps
**Step 1.** **Left-click** the start time field (e.g. `00:00:02.000`). It becomes editable.
**Step 2.** Type the new timestamp in `HH:MM:SS.mmm` format.
**Step 3.** Press **Enter** to confirm, or press **Esc** to revert.
> **Tip:** Timestamps must not overlap with the next cue. A yellow warning appears if there is an overlap.
### Inserting a new cue
Between cues, a **gap row** appears if there is more than 0.5 seconds of blank space. **Left-click** the gap row to insert a new cue at that position.
You can also use the **Insert Before** or **Insert After** icon buttons on any cue (appear when you hover over the cue).
### Deleting a cue
**Left-click** the **Delete** icon button (🗑, red on hover) on the right side of the cue card.
A confirmation modal appears:
- **Left-click** **Confirm delete** to permanently remove the cue.
- **Left-click** **Cancel** to abort.
### Playing a cue
**Hover** over a cue — a **▶** play button appears on the timing row. **Left-click** ▶ to play the video from the start of that cue in the video preview above.
### Validation errors
If the editor detects errors, a red badge appears in the header: **Validation errors: N**. **Left-click** it to expand the full list. Fix each issue before submitting.
Common errors:
- **Overlap** — cue starts before the previous one ends. Adjust timestamps.
- **CPS > 20** — text is too long for the duration. Shorten the text or extend the end time.
- **Empty cue** — a cue has no text. Add text or delete the cue.
---
## 5. Adding a Comment
To leave a note for the Reviewer or PM, **left-click** the **💬 Comments (N)** button on your language card.
A comment text area expands. Type your comment and **left-click** **Send**.
![Comments expanded](/help-screenshots/linguist/07-comments.png)
---
## 6. Submitting for Review
When you are satisfied with your edits:
**Step 1.** **Left-click** **↑ Submit for review** (blue button) on your language card.
![Submit for review button](/help-screenshots/linguist/08-submit-for-review.png)
The job status changes to **Pending Review** and a Reviewer is notified.
> **Important:** The English (source) language must be **Approved** before you can submit translations of that language. If you see a "Source language must be approved first" error, the Reviewer needs to approve the English content before you can proceed.
---
## 7. Handling Reviewer Feedback
If a Reviewer sends changes back, the language status changes to **Rejected** (or **QC Feedback**) and the job reappears in your queue.
**Step 1.** Open the job from **My QC Queue**.
**Step 2.** Expand your language card. You will see the **Feedback categories** the reviewer selected (e.g. Timing, Mistranslation, Terminology) and any written notes.
![Reviewer feedback displayed](/help-screenshots/linguist/09-reviewer-feedback.png)
**Step 3.** Address all the issues in the VTT editor.
**Step 4.** **Left-click** **↑ Submit for review** again.
---
## 8. Viewing Glossaries
Glossaries contain client-approved brand terms, product names, and preferred translations. Active glossary terms are highlighted in the VTT editor with an **amber underline**.
**Hover** over a highlighted term to see the glossary entry tooltip (correct translation or note).
To view full glossary lists: **left-click** **Clients** in the sidebar (if you have access) → open a client → **left-click** **Glossaries**.
---
## 9. QC Review (Organisation-wide View)
In addition to your personal queue, you can see all jobs in QC across the organisation.
**Left-click** **QC Review** (🔍) in the sidebar. You see a list of all pending QC jobs.
**Left-click** any job row to open it in the QC Detail editor.
---
## 10. Mouse Interactions Reference
| Action | Where | What happens |
|--------|-------|--------------|
| **Left-click** **Open →** | My QC Queue row | Opens QC Detail for that language |
| **Left-click** column header (Assigned) | Queue table | Sorts by that column ascending/descending |
| **Left-click** language card header | QC Detail | Expands or collapses the language panel |
| **Left-click** **Start work** | Language card | Marks as In Progress, reveals editor |
| **Left-click** **Edit text** | Cue (on hover) | Opens text edit mode for that cue |
| **Left-click** **Save** | Cue editor | Saves changes to the cue |
| **Left-click** timestamp field | Cue | Makes timestamp editable |
| **Left-click** gap row | Between cues | Inserts a new cue |
| **Left-click** **Insert Before/After** icon | Cue (on hover) | Inserts cue above/below |
| **Left-click** **Delete** icon | Cue (on hover) | Opens delete confirmation modal |
| **Left-click** **Confirm delete** | Delete modal | Permanently deletes the cue |
| **Left-click** ▶ play button | Cue (on hover) | Plays video from that cue start |
| **Left-click** **💬 Comments** | Language card | Expands comment input |
| **Left-click** **Submit for review** | Language card | Submits for Reviewer check |
| **Hover** glossary underline | Cue text | Shows glossary entry tooltip |
---
## 11. Keyboard Shortcuts
| Key | Action |
|-----|--------|
| `Cmd/Ctrl + Enter` | Save current cue text edit |
| `Esc` | Cancel cue text edit or revert timestamp |
| `Enter` | Confirm timestamp (in timestamp field) |
---
## 12. Common Errors
| Error | Cause | Fix |
|-------|-------|-----|
| "Source language must be approved first" | English content not yet approved | Wait for Reviewer to approve English, then proceed |
| CPS warning (amber badge) | Text too long for cue duration | Shorten the text or extend the end timestamp |
| Overlap warning | Cue starts before previous ends | Adjust start or end timestamp to close the gap |
| "Saving…" stuck | Network interruption | Wait; if not resolved in 30s, refresh and re-edit |
| Empty queue | No jobs assigned to you | Check with Production or your team lead |

View file

@ -0,0 +1,327 @@
# Production Guide
> **You are a Production user.** You are the operational backbone — you upload videos on behalf of clients, monitor the AI pipeline, manage the QC workflow, handle failed jobs, and keep everything moving. You have access to everything except User Management and Admin-only settings.
---
## Your Sidebar
| Item | What it does |
|------|-------------|
| 🏠 Dashboard | Home — pipeline stats, failure count, queue depth |
| 📋 All Jobs | Full list of all jobs (with bulk actions) |
| 📤 Upload Video | Upload videos for any client |
| 📝 My QC Queue | Your personally assigned QC tasks (badge count) |
| 🔍 QC Review | Organisation-wide QC queue |
| ✅ Final Review | Final review queue (badge count) |
| 📄 Briefs | Brief management |
| 🔥 Failures | Failed jobs dashboard (badge count) |
| 📋 Audit Log | Full audit log of all platform actions |
| 📖 Help | This guide |
---
## 1. Your Dashboard
The Production dashboard gives you a real-time operational view.
![Production dashboard](/help-screenshots/production/01-dashboard.png)
### Dashboard widgets
| Widget | What it shows | Action |
|--------|--------------|--------|
| **KPI tiles** (top row) | Total, Processing, In QC Review, Completed | **Left-click** a tile to jump to filtered jobs |
| **AI Pipeline** | Count of jobs currently being processed by AI | **Left-click** to open All Jobs filtered to AI statuses |
| **Awaiting Upload** | Count of approved briefs waiting for a video upload | **Left-click** to open the briefs queue |
| **Pending QC Handoff** | Jobs that finished AI processing but have no QC assigned | **Left-click** to open filtered jobs list |
| **Failures** | Count of failed jobs (TTS/render/processing) | **Left-click** to open the Failures dashboard |
| **Celery Queue Depth** | Per-queue task counter (ingestion, AI, translation, TTS, render) — refreshes every 10 seconds | Monitor-only |
---
## 2. Uploading on Behalf of a Client
As Production, you can upload videos for any client in your organisation.
**Step 1.** In the sidebar, **left-click** **Upload Video** (📤).
**Step 2.** **Left-click** the drop zone or **drag** the video file onto it.
**Step 3.** Fill in the job title, requested outputs, voice settings, and target languages — same as a Client upload (see Client Guide §3 for full instructions).
**Step 4.** Under **Client**, **left-click** the dropdown and select the client this job belongs to.
**Step 5.** **Left-click** the **Project** dropdown to assign to an existing project, or **left-click** **+ Create new project…** to create one inline.
**Step 6.** Under **Team Assignment**, assign a **Linguist** and **Reviewer** for the job. These are pre-filled from project defaults but can be changed here.
**Step 7.** **Left-click** **Create Job** (or **Upload N Videos** for multi-upload).
---
## 3. All Jobs — Bulk Operations
The **All Jobs** page is more powerful for Production users — it supports bulk actions.
**Step 1.** In the sidebar, **left-click** **All Jobs** (📋).
![All Jobs with bulk actions bar](/help-screenshots/production/02-jobs-list-bulk.png)
### Quick filter presets
At the top, four quick filter buttons let you jump to common views:
- **Left-click** **Pending Final Review** → shows only jobs waiting for final sign-off.
- **Left-click** **In QC** → shows jobs in any QC stage.
- **Left-click** **TTS Failed** → shows TTS failure jobs.
- **Left-click** **Render Failed** → shows render failure jobs.
### Selecting jobs
- **Left-click** the **checkbox** at the start of a row to select that job.
- **Left-click** the **Select All** checkbox in the table header to select all jobs on the current page.
A **Bulk Actions bar** appears above the table when at least one job is selected, showing "X selected" and an **Action** dropdown.
### Available bulk actions
**Left-click** the **Action** dropdown → select an action:
| Action | What it does |
|--------|-------------|
| **Delete Selected** | Permanently deletes all selected jobs. **Irreversible.** |
| **Reprocess Selected** | Resets and restarts selected jobs from scratch. |
| **Download All Files** | Bulk downloads all completed files from selected jobs. |
| **Return to QC** | Returns selected jobs to the QC Review stage with a reason. |
**Left-click** the action-specific button that appears to apply it:
- **Delete** → confirmation modal with a checkbox you must tick before confirming.
- **Reprocess** → confirmation modal warning that progress will be lost.
- **Download** → shows a summary of eligible/ineligible jobs; **left-click** **Download** to proceed.
- **Return to QC** → text area to enter a reason (required); **left-click** **Apply**.
To deselect all: **left-click** **Clear** next to the action buttons.
---
## 4. Per-Job Actions
Each job row has action buttons (visible on hover or always visible for canManage users):
| Button | What it does |
|--------|-------------|
| **Review** (yellow) | Opens QC Review for that job (only on `pending_qc` jobs) |
| **Final Review** (green) | Opens Final Review (only on `pending_final_review` jobs) |
| **Download** (indigo) | Opens Downloads page (only on `completed` jobs) |
| **Edit** (pencil icon) | Opens rename modal — type new title, press Enter or left-click Save |
| **Clone** | Creates a duplicate job with the same settings |
| **Delete** | Opens delete confirmation modal |
---
## 5. Monitoring a Job — Job Detail
**Left-click** any job row in All Jobs to open **Job Detail**.
![Job Detail — Production view](/help-screenshots/production/03-job-detail.png)
As Production, you see additional controls on the job detail page:
### Processing status timeline
The top of the page shows a visual pipeline: each processing step as a node. Green = done, blue = in progress, grey = upcoming, red = failed.
### Error panel
If a job has failed, a red **Error** panel appears showing:
- The error type (processing_failed, tts_failed, render_failed)
- The error message
- Which languages or cue indexes caused the failure (if applicable)
**Left-click** **Retry** to restart the failed step.
### Stopping a job
**Left-click** the **Stop Process** button (red, in the Overview tab). A confirmation modal appears — **left-click** **Confirm** to stop or **left-click** **Cancel** to abort.
### Escalating
**Left-click** **Escalate** to open a pre-filled email to the platform support team with the job ID and error details.
### Inline title edit
**Left-click** the pencil icon next to the job title. The title becomes editable. Type the new name, then press **Enter** to save or **Esc** to cancel.
### Return to QC
If a job is in Final Review or Completed and needs to go back to QC:
**Step 1.** In the job detail, scroll to **Return to QC**.
**Step 2.** Type the reason in the textarea.
**Step 3.** **Left-click** **Return to QC** button.
---
## 6. QC Review
You can view and manage the full QC queue.
**Step 1.** **Left-click** **QC Review** (🔍) in the sidebar.
![QC List](/help-screenshots/production/04-qc-list.png)
**Step 2.** **Left-click** any job row to open its **QC Detail** page.
From QC Detail you can:
- Assign linguists and reviewers (see Reviewer Guide §5)
- Edit VTT content (see Linguist Guide §4)
- Approve or reject languages (see Reviewer Guide §4)
- Generate share links
---
## 7. Handling Failures
The **Failures** dashboard shows all jobs that have encountered processing errors.
**Step 1.** **Left-click** **Failures** (🔥) in the sidebar. The badge shows the count of failed jobs.
![Failures dashboard](/help-screenshots/production/05-failures.png)
### Browsing failures
Failures are grouped by **error type** in accordion sections. **Left-click** an error type header to expand that group and see the affected jobs.
Each row shows: job title, status badge, failed step, error message (2 lines), retry count, last updated date.
### Retrying a single job
**Left-click** **View** to open the job detail and inspect the error, then use the **Retry** button there.
### Bulk retry
**Step 1.** **Left-click** the **checkbox** on each failed job you want to retry. Or **left-click** **Select All**.
**Step 2.** In the **Strategy** dropdown, **left-click** to choose:
- **Auto** — retries from the failed step
- **From scratch** — re-ingests and restarts the whole job
**Step 3.** **Left-click** **Retry selected**.
### Uploading a final VTT manually
If AI processing has completely failed and you have a manually prepared VTT file, you can upload it directly:
**Step 1.** **Left-click** the **Upload VTT** button on the failed job row.
**Step 2.** In the modal:
- Type the **Language** code (e.g. `en-GB`)
- **Left-click** the **VTT Type** dropdown: Closed Captions or Audio Description
- **Left-click** the file upload area and select your `.vtt` file, or **drag** the file onto the upload area
- **Left-click** **Upload & Advance** to upload and push the job forward
---
## 8. Brief Management
You can manage job briefs from clients.
**Step 1.** **Left-click** **Briefs** (📄) in the sidebar. The badge count shows how many briefs are awaiting approval.
**Step 2.** **Left-click** a brief row to open its detail.
**Step 3.** For a **Submitted** brief:
- **Left-click** **Approve Brief** (green) to approve it — the client can now create a job from it.
- Or create the job yourself: **left-click** **Create Job from Brief** (if the brief is approved).
**Step 4.** To create a new brief on behalf of a client: **left-click** **New Brief** → fill in the form → **left-click** **Create Brief**.
---
## 9. Audit Log
The audit log records every action taken on the platform.
**Left-click** **Audit Log** (📋) in the sidebar.
![Audit Log page](/help-screenshots/production/06-audit-log.png)
### Tabs
| Tab | Shows |
|-----|-------|
| **All Events** | Every event, searchable and filterable |
| **Security Events** | Login attempts, permission denials, suspicious activity |
| **User Activity** | Activity by a specific user |
### Filtering All Events
- **Search** input — type keywords.
- **Action** dropdown — filter by event type (grouped by category).
- **Severity** dropdown — filter by Info, Warning, Error, Critical.
- **Success filter** dropdown — show Success+Failures, Success only, or Failures only.
- **Left-click** **Search** to apply, or **Left-click** **Clear** to reset.
### Expanding an event row
**Left-click** any row in the audit table to expand it and see full details: IP address, request ID, user agent, resource, error message (if failed), and raw detail JSON.
### Security tab
**Left-click** the **Security Events** tab. **Left-click** the **Last X hours** dropdown to filter by 1h, 6h, 24h, or 72h.
### User Activity tab
**Left-click** the **User Activity** tab. **Left-click** the **User** dropdown to select a specific user and see their activity timeline.
---
## 10. Mouse Interactions Reference
| Action | Where | What happens |
|--------|-------|--------------|
| **Left-click** job checkbox | All Jobs table | Selects job for bulk action |
| **Left-click** Select All | Table header | Selects all jobs on page |
| **Left-click** Action dropdown | Bulk actions bar | Shows available bulk actions |
| **Left-click** bulk action button | Bulk actions bar | Applies bulk action to selection |
| **Left-click** Delete confirmation checkbox | Delete modal | Must be checked to confirm |
| **Left-click** **Edit** icon | Job row (on hover) | Opens inline title rename |
| **Left-click** **Clone** | Job row (on hover) | Duplicates the job |
| **Left-click** error type header | Failures dashboard | Expands accordion showing failed jobs |
| **Left-click** **Select All** | Failures dashboard | Selects all visible failed jobs |
| **Left-click** Strategy dropdown | Failures bulk bar | Selects retry strategy (Auto/From scratch) |
| **Left-click** **Retry selected** | Failures bulk bar | Queues all selected jobs for retry |
| **Left-click** **Upload VTT** | Failure row | Opens manual VTT upload modal |
| **Left-click** VTT file upload area | Upload VTT modal | Opens file picker for .vtt file |
| **Drag** .vtt file | Upload VTT modal | Drops file into uploader |
| **Left-click** row in Audit Log | Audit Log table | Expands event details |
| **Left-click** **Stop Process** | Job Detail | Opens stop confirmation modal |
| **Left-click** pencil icon | Job title | Enters inline title edit mode |
| **Hover** job row | All Jobs table | Reveals Edit, Clone, Delete action buttons |
---
## 11. Keyboard Shortcuts
| Key | Action |
|-----|--------|
| `A` | Approve current QC language (on QC Detail page) |
| `R` | Request changes (on QC Detail page) |
| `Cmd/Ctrl + S` | Save VTT file (on QC Detail page) |
| `1` / `2` / `3` | Switch view modes in QC Detail |
| `Esc` | Close modal |
| `Enter` | Save inline title edit (on Job Detail) |
---
## 12. Common Issues
| Issue | Cause | Fix |
|-------|-------|-----|
| Job stuck in AI Processing | Celery worker backlog or error | Check Celery Queue Depth widget; retry from Failures if it shows an error |
| High retry count on a job | Repeated processing failures | Open the job detail, read the error message, escalate if >3 retries |
| "Upload VTT" option not available | Job not in a failed state | Only shown for jobs in processing_failed, tts_failed, render_failed |
| Brief badge count not clearing | Brief not yet approved | Open the brief and approve it, or assign it to Production to action |
| Audit log shows 403 events | User attempted access to a restricted resource | Normal for permission denials — investigate if repeated for the same user |

View file

@ -0,0 +1,242 @@
# Project Manager Guide
> **You are a Project Manager (PM).** You are the final quality gate and client relationship owner. You approve deliverables before they go to the client, manage client accounts and briefs, upload glossaries, and track overdue or stuck jobs.
---
## Your Sidebar
| Item | What it does |
|------|-------------|
| 🏠 Dashboard | Home — Final Review count, KPI tiles, Overdue/Stuck widgets |
| 📋 All Jobs | Full list of all jobs |
| 📤 Upload Video | Upload videos on behalf of clients |
| ✅ Final Review | Jobs awaiting your final approval (badge count) |
| 🏢 Clients | Client accounts and glossary management |
| 📄 Briefs | Brief creation and approval |
| 📋 Audit Log | Platform-wide action history |
| 📖 Help | This guide |
---
## 1. Your Dashboard
![PM Dashboard](/help-screenshots/project-manager/01-dashboard.png)
The PM dashboard shows:
| Widget | What it means |
|--------|--------------|
| **Final Review count** (green tile) | Jobs waiting for your approval. **Left-click** to jump to Final Review queue. |
| **In QC Review count** (amber tile) | Jobs currently being reviewed. **Left-click** to see the QC queue. |
| **New Job** (blue tile) | Quick shortcut to upload a video. **Left-click** → Upload Video page. |
| **Overdue** (red tile) | Jobs past their deadline. **Left-click** to see only overdue jobs in All Jobs. |
| **Stuck >24h** (yellow tile) | Jobs with no status change in over 24 hours. **Left-click** to see them. |
> **Overdue** jobs appear with a ⚠ warning icon in the jobs list. If the tile is red, take immediate action.
---
## 2. Final Review Queue
Your primary working area. All jobs that have been approved in QC and are ready for your final decision appear here.
**Step 1.** In the sidebar, **left-click** **Final Review** (✅). The badge number shows how many jobs are waiting.
![Final Review Queue](/help-screenshots/project-manager/02-final-list.png)
The queue shows two sections:
- **Pending Final Review** — jobs waiting for your decision.
- **Recently Completed** — the 10 most recently completed jobs.
Each row shows: status badge, job title (as a link), language count, MP3 indicator, source filename, duration, requested languages.
**Step 2.** **Left-click** any job row in the Pending section to open its **Final Detail** page.
---
## 3. Performing Final Review
The Final Detail page is where you make the delivery decision.
![Final Detail page](/help-screenshots/project-manager/03-final-detail.png)
### What to check
1. **Asset Validation Summary** — the top panel shows ✓ Passed (green) or ✗ Failed (red). If failed, expand the errors list — missing files must be resolved by Production before you can approve.
2. **Video Review Player** — play the accessible video in the language(s) you are approving. **Left-click** the language selector to switch between languages.
3. **Language Asset Cards** — for each language, check:
- **Translation type** badge (Original, Transcreated, Translated)
- **QA notes** — if red notes are present, a Linguist or Reviewer flagged an issue. Read them carefully.
- **VTT content** — you can read the captions and audio descriptions inline. As a PM, you can also edit them if needed.
- **Audio player****left-click** ▶ to listen to the audio description MP3.
### Approving for delivery
**Step 1.** After reviewing all languages, scroll to the **Final Review Decision** section.
**Step 2.** **Left-click** the **Approve for Client Delivery** radio button.
**Step 3.** **Left-click** **Confirm**.
![Approve for Client Delivery decision panel](/help-screenshots/project-manager/04-approve-decision.png)
The job status changes to **Completed**. The client receives a notification with download links.
### Returning to QC
**Step 1.** **Left-click** the **Return for Quality Control** radio button.
**Step 2.** Type your feedback in the **Review notes** textarea. Be specific — explain what needs to be fixed.
**Step 3.** **Left-click** **Confirm**.
The job is returned to the QC stage and the Production team is notified.
### Bulk approval
If you need to approve or return multiple jobs:
**Step 1.** In the Final Review queue, **left-click** checkboxes next to jobs, or **left-click** **Select All**.
**Step 2.** In the **Action** dropdown, **left-click** **Complete Selected** or **Return to QC**.
**Step 3.** Enter notes if returning.
**Step 4.** **Left-click** **Apply**.
---
## 4. Managing Clients
**Step 1.** In the sidebar, **left-click** **Clients** (🏢).
![Client list](/help-screenshots/project-manager/05-clients.png)
**Step 2.** **Left-click** any client row to open their **Client Detail** page.
The Client Detail page shows:
- Client name and contact details
- Associated jobs
- Glossaries for this client
---
## 5. Managing Glossaries
Glossaries ensure consistent terminology in translations and captions.
### Viewing glossaries
**Step 1.** **Left-click** **Clients** in the sidebar → **left-click** a client → **left-click** **Glossaries**.
![Glossary list](/help-screenshots/project-manager/06-glossary-list.png)
**Step 2.** **Left-click** any glossary row to view its terms.
### Uploading a new glossary
**Step 1.** From the Glossaries list, **left-click** **Upload Glossary** (top-right button).
![Glossary upload page](/help-screenshots/project-manager/07-glossary-upload.png)
**Step 2.** **Left-click** the file upload area and select a CSV file, or **drag** the CSV file onto the upload area.
**Step 3.** Fill in the glossary name and language pair.
**Step 4.** **Left-click** **Upload** to save.
### Editing glossary terms
**Step 1.** Open a glossary from the list.
**Step 2.** **Left-click** any term row to edit it inline. Type the corrected term and press **Enter** to save.
---
## 6. Brief Management
You review and approve briefs submitted by clients.
**Step 1.** **Left-click** **Briefs** (📄) in the sidebar. The badge shows how many briefs are awaiting approval.
**Step 2.** **Left-click** a brief row to open it.
**Step 3.** For a brief with **Submitted** status:
- **Left-click** **Approve Brief** (green) to approve it — the client is notified and can upload their video.
- Or reject by returning notes (if needed — contact the client directly; there is no in-app rejection button for PMs).
You can also create briefs: **left-click** **New Brief** (blue), fill in the form, and **left-click** **Create Brief**.
---
## 7. Uploading on Behalf of a Client
As PM, you can upload videos the same way Production does. See **Production Guide §2** for the full upload walkthrough.
---
## 8. Audit Log
The Audit Log records all platform actions for compliance and troubleshooting.
**Left-click** **Audit Log** (📋) in the sidebar.
Use the tab navigation to switch between **All Events**, **Security Events**, and **User Activity**. Use the filter controls to narrow by action type, severity, or date.
**Left-click** any row to expand and see full event details: actor, IP, resource, timestamp, and JSON payload.
---
## 9. Tracking Overdue and Stuck Jobs
### From the dashboard
- **Left-click** the **Overdue** tile → opens All Jobs filtered to overdue jobs (past deadline, not yet completed).
- **Left-click** the **Stuck >24h** tile → opens All Jobs filtered to jobs with no status change in 24+ hours.
### From All Jobs
In the jobs list:
- Jobs with a red ⚠ icon in the deadline column are overdue.
- **Left-click** any overdue job row to open it.
From the Job Detail page you can escalate to Production by using the job URL and sending it directly, or use the **Escalate** button (if present) to open a pre-filled email.
---
## 10. Mouse Interactions Reference
| Action | Where | What happens |
|--------|-------|--------------|
| **Left-click** Overdue tile | PM Dashboard | Jumps to overdue jobs in All Jobs |
| **Left-click** Stuck >24h tile | PM Dashboard | Jumps to stuck jobs in All Jobs |
| **Left-click** Final Review tile | PM Dashboard | Opens Final Review queue |
| **Left-click** job row | Final Review queue | Opens Final Detail page |
| **Left-click** language selector | Video review player | Switches video language |
| **Left-click** ▶ | Audio player | Plays the AD MP3 |
| **Left-click** Approve radio | Final Decision section | Selects Approve decision |
| **Left-click** Return radio | Final Decision section | Selects Return to QC decision |
| **Left-click** **Confirm** | Final Decision section | Applies the decision |
| **Left-click** job checkbox | Final Review queue | Selects job for bulk action |
| **Left-click** **Apply** | Bulk actions bar | Applies bulk Approve/Return |
| **Left-click** client row | Client list | Opens Client Detail |
| **Left-click** glossary row | Glossary list | Opens glossary term list |
| **Left-click** glossary upload area | Glossary upload page | Opens CSV file picker |
| **Drag** CSV file | Glossary upload area | Drops file into uploader |
| **Left-click** term row | Glossary detail | Opens inline term edit |
| **Left-click** Audit Log row | Audit Log table | Expands event details |
---
## 11. Common Issues
| Issue | Cause | Fix |
|-------|-------|-----|
| Asset Validation failed | Missing VTT or MP3 | Return to QC with a note for Production to re-render |
| No jobs in Final Review | QC not yet complete | Check the QC Review list — languages may still be In Progress |
| Overdue tile is red | Jobs past their deadlines | Open the tile, review each job, escalate to Production |
| Brief badge keeps growing | Clients submitted briefs without approval | Open each brief and approve or action |
| Cannot see Client detail | Client not added to your org | Contact Admin to add the client to your organisation |

View file

@ -0,0 +1,297 @@
# Reviewer Guide
> **You are a Reviewer.** You perform second-pass quality checks on VTT content after a Linguist has submitted it, then approve or request changes. You also handle **Final Review** — making the final go/no-go decision before files are delivered to the client.
---
## Your Sidebar
| Item | What it does |
|------|-------------|
| 🏠 Dashboard | Your home screen — QC and Final Review hero cards |
| 📋 All Jobs | Full list of all jobs |
| 📝 My QC Queue | Your personal assigned QC tasks |
| 🔎 Reviewer Queue | Jobs submitted by Linguists awaiting your second-pass review |
| 🔍 QC Review | Organisation-wide QC queue |
| ✅ Final Review | Jobs awaiting final approval (with badge count) |
| 📖 Help | This guide |
After login you are automatically redirected to **My QC Queue**.
---
## 1. Your Dashboard
![Reviewer dashboard](/help-screenshots/reviewer/01-dashboard.png)
- **Quality Control** hero card (blue) — count of jobs in QC. **Left-click** **Go to QC Review** to open the full queue.
- **Final Approval** hero card (purple) — count of jobs in Final Review. **Left-click** **Go to Final Review** to open the queue.
- **KPI tiles** at the top: Total, Processing, In QC Review, Completed.
---
## 2. My QC Queue
Your personal queue of jobs directly assigned to you.
**Step 1.** In the sidebar, **left-click** **My QC Queue** (📝).
![My QC Queue](/help-screenshots/reviewer/02-qc-queue.png)
Use the **status tabs** at the top to filter: All, Pending, In Progress, Pending Review, In Review, Approved, Rejected.
**Left-click** **Open →** on any row to open that job's QC Detail page.
---
## 3. Reviewer Queue
The Reviewer Queue shows jobs that have been **submitted by Linguists** and are now waiting for your second-pass check.
**Step 1.** In the sidebar, **left-click** **Reviewer Queue** (🔎).
![Reviewer Queue](/help-screenshots/reviewer/03-reviewer-queue.png)
This queue is locked to your **reviewer role** — you see only jobs where you are the assigned reviewer and the status is "Pending Review" or "In Review".
**Left-click** **Open →** on any row to open that job.
---
## 4. Performing a QC Review
When you open a job from your queue, you land on the **QC Detail** page.
![QC Detail page — reviewer view](/help-screenshots/reviewer/04-qc-detail.png)
### Expanding a language
**Left-click** the language card header to expand it. You see:
- The **Linguist assignment** (who did the first-pass)
- The **VTT Editor** showing captions and/or audio descriptions
- The **Approval percentage bar** — tracks how many languages are approved vs total
### Reviewing VTT content
Scroll through the cues carefully. You can:
- **Read** all cues in read-only mode (default)
- **Edit** any cue as needed — same interface as the Linguist's editor (see Linguist Guide §4 for full editing instructions)
### Approving a language
When you are satisfied with a language:
**Left-click** **✓ Approve** (green button) on the language card.
![Approve button highlighted](/help-screenshots/reviewer/05-approve-button.png)
The language status changes to **Approved**. If all languages are now approved and there are no validation errors, the job will automatically advance to **Final Review**.
### Requesting changes
If you find issues:
**Step 1.** **Left-click** **✕ Request changes** (red button) on the language card.
![Request changes modal](/help-screenshots/reviewer/06-request-changes-modal.png)
**Step 2.** In the modal, **left-click** one or more **category buttons** to tag the type of issue:
- **Timing** — cue timing is wrong
- **Mistranslation** — translation is incorrect
- **Terminology** — wrong use of client terms or brand names
- **Profanity** — inappropriate language
- **Length** — cue too long or too short
- **Other** — any other issue
**Step 3.** Type detailed feedback in the **Notes** textarea (required). Be specific — the Linguist will use this to make corrections.
**Step 4.** **Left-click** **Send feedback**. The job is returned to the Linguist with your notes. The status changes to **Rejected**.
To cancel without sending: **left-click** **Cancel** or press **Esc**.
---
## 5. Assigning Linguists and Reviewers
From the QC Detail page, you can assign or reassign team members to language slots.
### Assigning a single language
**Step 1.** Find the language card with "Unassigned" in the Linguist or Reviewer slot.
**Step 2.** **Left-click** **Assign** (or **Reassign** if someone is already assigned). The assignment modal opens.
![Assignment modal](/help-screenshots/reviewer/07-assign-modal.png)
**Step 3.** **Left-click** the person dropdown and select the team member.
> **Note:** If you assign a linguist who does not have the required language in their competencies, an **amber warning** appears. You can still assign them — it is a warning, not a block.
**Step 4.** Optionally set a **Deadline** date.
**Step 5.** **Left-click** **Assign** to confirm.
### Bulk assignment
To assign all unassigned languages at once:
**Step 1.** **Left-click** the **Bulk Assign** button (appears in the page header when languages are unassigned).
**Step 2.** In the modal, select a **Linguist** (required) and optionally a **Reviewer** and **Deadline**.
**Step 3.** Optionally check **Skip languages that already have a linguist assigned** to avoid overwriting existing assignments.
**Step 4.** **Left-click** **Assign N languages** to confirm.
---
## 6. The VTT Editor — Full Access
As a Reviewer, you have full edit rights in the VTT editor, same as a Linguist. See the **Linguist Guide § 4** for detailed instructions on editing cue text, timestamps, inserting, and deleting cues.
### Keyboard shortcuts (QC Detail page)
| Key | Action |
|-----|--------|
| `A` | Approve the currently selected language |
| `R` | Open the Request Changes modal |
| `Cmd/Ctrl + S` | Save the full VTT file |
| `1` | Switch to side-by-side view (editor + video) |
| `2` | Switch to video-only view |
| `3` | Switch to editor-only view |
| `Esc` | Close modal / cancel edit |
---
## 7. Adjusting Timing Offsets
If the entire captions track is shifted (e.g. all cues are 2 seconds early), you can apply a **bulk timing offset** instead of adjusting each cue individually.
**Step 1.** On the QC Detail page, scroll to the **Timing Adjustment** section.
**Step 2.** **Left-click** it to expand.
**Step 3.** Enter the offset in seconds (positive = shift forward, negative = shift back).
**Step 4.** **Left-click** the checkboxes to apply to **Captions** and/or **Audio Description**.
**Step 5.** **Left-click** **Adjust** to apply.
---
## 8. Sharing a Preview Link
You can share a read-only preview link with a client or stakeholder.
**Step 1.** On the QC Detail page, **left-click** the **Share Link** button (top-right of page).
**Step 2.** Optionally type a label in the input field.
**Step 3.** **Left-click** **Generate**.
**Step 4.** **Left-click** **Copy** to copy the link to clipboard.
The link is valid for 30 days and does not require a login.
---
## 9. Final Review
When all languages of a job are approved in QC, the job moves to **Pending Final Review**. You perform the final quality gate.
### Viewing the Final Review queue
**Step 1.** In the sidebar, **left-click** **Final Review** (✅).
![Final Review queue](/help-screenshots/reviewer/08-final-list.png)
**Step 2.** **Left-click** any pending job to open its **Final Detail** page.
### The Final Detail page
![Final Detail page](/help-screenshots/reviewer/09-final-detail.png)
You see:
- **Asset Validation Summary** — green ✓ if all files are present and valid, red ✗ if something is missing.
- **Video Review Player** — play the accessible video with captions and audio description. **Left-click** a language in the player to switch.
- **Per-language asset cards** — each shows: translation type (Original / Transcreated / Translated), QA notes if any, VTT editor (read-only by default), audio player, asset readiness indicators.
### Approving or rejecting
**Step 1.** Review all languages thoroughly — watch the video, read the captions, listen to the audio description.
**Step 2.** Scroll to the **Final Review Decision** section.
**Step 3.** **Left-click** the radio button for your decision:
- **Approve for Client Delivery** — sends the job to Completed and notifies the client.
- **Return for Quality Control** — sends the job back to QC with your notes.
**Step 4.** If returning, type your notes in the **Review notes** textarea (required).
**Step 5.** **Left-click** **Confirm** to apply your decision.
![Final Review decision panel](/help-screenshots/reviewer/10-final-decision.png)
---
## 10. QC Review — Organisation-wide Queue
The **QC Review** page (`/admin/qc`) shows all jobs currently in QC for your organisation — not just the ones assigned to you.
**Left-click** **QC Review** (🔍) in the sidebar. You can browse all jobs, bulk-approve or bulk-reject using the checkboxes, and open any job.
### Bulk actions
**Step 1.** **Left-click** checkboxes next to jobs, or **left-click** **Select All**.
**Step 2.** In the **Actions** dropdown, **left-click** **Approve Selected** or **Reject Selected**.
**Step 3.** For rejection, type your notes in the input field.
**Step 4.** **Left-click** **Apply**.
---
## 11. Mouse Interactions Reference
| Action | Where | What happens |
|--------|-------|--------------|
| **Left-click** **Open →** | Queue rows | Opens QC Detail for that language |
| **Left-click** language card header | QC Detail | Expands/collapses the language panel |
| **Left-click** **✓ Approve** | Language card | Approves that language |
| **Left-click** **✕ Request changes** | Language card | Opens feedback/rejection modal |
| **Left-click** feedback category | Rejection modal | Selects issue type (multi-select) |
| **Left-click** **Send feedback** | Rejection modal | Submits feedback to Linguist |
| **Left-click** **Assign/Reassign** | Language slot | Opens assignment modal |
| **Left-click** **Bulk Assign** | QC Detail header | Opens bulk assignment modal |
| **Left-click** job row | QC List / Final List | Opens that job |
| **Left-click** approval radio | Final Detail | Selects Approve or Return decision |
| **Left-click** **Confirm** | Final Detail decision | Applies the final review decision |
| **Left-click** **Share Link** | QC Detail | Opens share link generator |
| **Left-click** **Copy** | Share link modal | Copies link to clipboard |
| **Hover** over Linguist slot | Language card | Shows language competence tooltip |
---
## 12. Keyboard Shortcuts
| Key | Action |
|-----|--------|
| `A` | Approve current language (QC Detail) |
| `R` | Open Request Changes modal (QC Detail) |
| `Cmd/Ctrl + S` | Save full VTT file |
| `1` / `2` / `3` | Switch view modes (side-by-side / video / editor) |
| `Esc` | Close modal / cancel edit |
| `Cmd/Ctrl + Enter` | Save cue text (in edit mode) |
---
## 13. Common Errors
| Error | Cause | Fix |
|-------|-------|-----|
| "Validation errors: N" badge | VTT has timing or format issues | Expand the errors list and fix each one before approving |
| Cannot approve | Validation errors still present | Fix all validation errors first |
| "Source language not approved" | English not yet approved | Approve English before approving translations |
| Job not in Final Review queue | Not all languages approved in QC | Check QC list — one language may still be Pending/In Progress |
| Asset validation failed | Missing VTT or MP3 file | Return to QC; Production may need to re-render |

View file

@ -0,0 +1,471 @@
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { useSearchParams } from 'react-router-dom';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeRaw from 'rehype-raw';
import type { Components } from 'react-markdown';
import { useAuthStore } from '../lib/auth';
import type { UserRole } from '../types/api';
import globalContent from '../help-content/global.md?raw';
import clientContent from '../help-content/client.md?raw';
import linguistContent from '../help-content/linguist.md?raw';
import reviewerContent from '../help-content/reviewer.md?raw';
import productionContent from '../help-content/production.md?raw';
import projectManagerContent from '../help-content/project-manager.md?raw';
import adminContent from '../help-content/admin.md?raw';
interface TocEntry {
level: 2 | 3;
text: string;
slug: string;
}
interface RoleGuide {
key: string;
label: string;
icon: string;
content: string;
roles?: UserRole[];
}
const GUIDES: RoleGuide[] = [
{ key: 'global', label: 'Overview', icon: '🌐', content: globalContent },
{ key: 'client', label: 'Client', icon: '🎬', content: clientContent, roles: ['client'] },
{ key: 'linguist', label: 'Linguist', icon: '🌍', content: linguistContent, roles: ['linguist'] },
{ key: 'reviewer', label: 'Reviewer', icon: '🔎', content: reviewerContent, roles: ['reviewer'] },
{ key: 'production', label: 'Production', icon: '⚙️', content: productionContent, roles: ['production'] },
{ key: 'project-manager', label: 'Project Manager', icon: '📋', content: projectManagerContent, roles: ['project_manager'] },
{ key: 'admin', label: 'Admin', icon: '👑', content: adminContent, roles: ['admin'] },
];
function slugify(text: string): string {
return text
.toLowerCase()
.replace(/[^\w\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.trim()
.replace(/^-+|-+$/g, '');
}
function nodeToText(node: React.ReactNode): string {
if (typeof node === 'string' || typeof node === 'number') return String(node);
if (Array.isArray(node)) return node.map(nodeToText).join('');
if (node && typeof node === 'object' && 'props' in node) {
return nodeToText((node as React.ReactElement<{ children?: React.ReactNode }>).props.children);
}
return '';
}
function extractToc(markdown: string): TocEntry[] {
const toc: TocEntry[] = [];
const lines = markdown.split('\n');
for (const line of lines) {
const h2 = line.match(/^## (.+)/);
const h3 = line.match(/^### (.+)/);
if (h2) {
const text = h2[1].replace(/\*\*/g, '').replace(/`/g, '').trim();
toc.push({ level: 2, text, slug: slugify(text) });
} else if (h3) {
const text = h3[1].replace(/\*\*/g, '').replace(/`/g, '').trim();
toc.push({ level: 3, text, slug: slugify(text) });
}
}
return toc;
}
function defaultRoleKey(role: UserRole | undefined): string {
if (!role) return 'global';
const map: Partial<Record<UserRole, string>> = {
client: 'client',
linguist: 'linguist',
reviewer: 'reviewer',
production: 'production',
project_manager: 'project-manager',
admin: 'global',
};
return map[role] || 'global';
}
// Module-scope heading factory — stable identity, no remounts
function makeHeading(level: 1 | 2 | 3 | 4) {
const Tag = `h${level}` as 'h1' | 'h2' | 'h3' | 'h4';
const sizeClass =
level === 1
? 'text-3xl font-bold text-gray-900 mb-6 mt-0 pb-4 border-b border-gray-200'
: level === 2
? 'text-xl font-bold text-gray-900 mb-4 mt-8 pt-2'
: level === 3
? 'text-base font-semibold text-gray-800 mb-3 mt-6'
: 'text-sm font-semibold text-gray-700 mb-2 mt-4';
return function HeadingComponent({ children, ...props }: React.HTMLAttributes<HTMLHeadingElement>) {
const text = nodeToText(children);
const id = text ? slugify(text) : undefined;
return (
<Tag id={id} className={sizeClass} {...props}>
{children}
</Tag>
);
};
}
const HEADING_COMPONENTS = {
h1: makeHeading(1),
h2: makeHeading(2),
h3: makeHeading(3),
h4: makeHeading(4),
};
export function Help() {
const { user } = useAuthStore();
const [searchParams, setSearchParams] = useSearchParams();
const [activeRole, setActiveRole] = useState<string>(
searchParams.get('role') || defaultRoleKey(user?.role)
);
const [search, setSearch] = useState('');
const [activeSection, setActiveSection] = useState('');
const [lightboxSrc, setLightboxSrc] = useState<string | null>(null);
const [lightboxAlt, setLightboxAlt] = useState<string>('');
const contentRef = useRef<HTMLDivElement>(null);
const currentGuide = GUIDES.find(g => g.key === activeRole) || GUIDES[0];
const toc = useMemo(() => extractToc(currentGuide.content), [currentGuide.content]);
const filteredToc = useMemo(
() => (search ? toc.filter(e => e.text.toLowerCase().includes(search.toLowerCase())) : toc),
[toc, search]
);
const handleRoleChange = useCallback(
(key: string) => {
setActiveRole(key);
setSearch('');
setActiveSection('');
setSearchParams(prev => {
const next = new URLSearchParams(prev);
next.set('role', key);
return next;
});
contentRef.current?.scrollTo({ top: 0, behavior: 'smooth' });
},
[setSearchParams]
);
useEffect(() => {
const roleParam = searchParams.get('role');
if (roleParam && GUIDES.some(g => g.key === roleParam)) {
setActiveRole(roleParam);
}
}, [searchParams]);
// Scroll-spy — wait one frame for ReactMarkdown to paint headings
useEffect(() => {
const root = contentRef.current;
if (!root) return;
const observer = new IntersectionObserver(
entries => {
for (const entry of entries) {
if (entry.isIntersecting) {
setActiveSection(entry.target.id);
break;
}
}
},
{ root, rootMargin: '-10% 0px -80% 0px', threshold: 0 }
);
const raf = requestAnimationFrame(() => {
root.querySelectorAll('h2[id], h3[id]').forEach(h => observer.observe(h));
});
return () => {
cancelAnimationFrame(raf);
observer.disconnect();
};
}, [currentGuide.content]);
// Lightbox keyboard close
useEffect(() => {
if (!lightboxSrc) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setLightboxSrc(null);
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [lightboxSrc]);
const scrollToSection = useCallback((slug: string) => {
const el = document.getElementById(slug);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
}, []);
const openLightbox = useCallback((src: string, alt: string) => {
setLightboxSrc(src);
setLightboxAlt(alt);
}, []);
const components: Components = useMemo(
() => ({
...HEADING_COMPONENTS,
p({ children, ...props }) {
return <p className="text-gray-700 leading-relaxed mb-4" {...props}>{children}</p>;
},
ul({ children, ...props }) {
return <ul className="list-disc list-outside ml-5 mb-4 space-y-1 text-gray-700" {...props}>{children}</ul>;
},
ol({ children, ...props }) {
return <ol className="list-decimal list-outside ml-5 mb-4 space-y-1 text-gray-700" {...props}>{children}</ol>;
},
li({ children, ...props }) {
return <li className="leading-relaxed" {...props}>{children}</li>;
},
strong({ children, ...props }) {
return <strong className="font-semibold text-gray-900" {...props}>{children}</strong>;
},
em({ children, ...props }) {
return <em className="italic text-gray-600" {...props}>{children}</em>;
},
code({ children, className, ...props }) {
const isBlock = className?.includes('language-');
if (isBlock) {
return (
<pre className="bg-gray-900 text-green-400 rounded-lg p-4 mb-4 overflow-x-auto text-sm font-mono">
<code className={className} {...props}>{children}</code>
</pre>
);
}
return (
<code className="bg-gray-100 text-indigo-700 rounded px-1.5 py-0.5 text-sm font-mono" {...props}>
{children}
</code>
);
},
blockquote({ children, ...props }) {
return (
<blockquote className="border-l-4 border-indigo-400 pl-4 py-1 mb-4 bg-indigo-50 rounded-r-lg" {...props}>
<div className="text-gray-700">{children}</div>
</blockquote>
);
},
table({ children, ...props }) {
return (
<div className="overflow-x-auto mb-6">
<table className="min-w-full text-sm border-collapse" {...props}>
{children}
</table>
</div>
);
},
thead({ children, ...props }) {
return <thead className="bg-indigo-50" {...props}>{children}</thead>;
},
th({ children, ...props }) {
return (
<th className="px-4 py-2 text-left font-semibold text-indigo-800 border border-gray-200" {...props}>
{children}
</th>
);
},
td({ children, ...props }) {
return (
<td className="px-4 py-2 text-gray-700 border border-gray-200" {...props}>
{children}
</td>
);
},
tr({ children, ...props }) {
return <tr className="hover:bg-gray-50 transition-colors" {...props}>{children}</tr>;
},
hr() {
return <hr className="my-8 border-gray-200" />;
},
a({ href, children, ...props }) {
return (
<a
href={href}
className="text-indigo-600 hover:text-indigo-800 underline decoration-dotted"
target={href?.startsWith('http') ? '_blank' : undefined}
rel={href?.startsWith('http') ? 'noopener noreferrer' : undefined}
{...props}
>
{children}
</a>
);
},
img({ src, alt }) {
if (!src) return null;
return (
<span className="block my-4">
<button
type="button"
className="cursor-zoom-in p-0 border-0 bg-transparent block w-full text-left focus:outline-none focus:ring-2 focus:ring-indigo-400 rounded-xl"
onClick={() => openLightbox(src, alt ?? '')}
aria-label={`View full-size: ${alt ?? 'screenshot'}`}
>
<img
src={src}
alt={alt}
className="rounded-xl border border-gray-200 shadow-md max-w-full hover:shadow-lg hover:scale-[1.01] transition-all duration-200"
/>
</button>
{alt && (
<span className="block text-center text-xs text-gray-400 mt-1 italic">{alt}</span>
)}
</span>
);
},
}),
[openLightbox]
);
return (
<div className="flex h-full bg-gray-50 overflow-hidden">
{/* Left rail — role tabs + TOC */}
<aside className="w-52 flex-shrink-0 bg-white border-r border-gray-200 flex flex-col overflow-y-auto">
<div className="px-4 py-5 border-b border-gray-100">
<h2 className="text-sm font-bold text-gray-900 flex items-center gap-2">
<span className="text-lg">📖</span>
User Guides
</h2>
<p className="text-xs text-gray-400 mt-0.5">Select your role below</p>
</div>
<nav aria-label="Role guide selection" className="py-2">
{GUIDES.map(guide => (
<button
key={guide.key}
type="button"
onClick={() => handleRoleChange(guide.key)}
aria-current={activeRole === guide.key ? 'page' : undefined}
className={`w-full text-left flex items-center gap-2.5 px-4 py-2.5 text-sm transition-colors cursor-pointer focus:outline-none focus:bg-indigo-50 ${
activeRole === guide.key
? 'bg-indigo-50 text-indigo-700 font-semibold border-r-2 border-indigo-500'
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'
}`}
>
<span className="text-base" aria-hidden="true">{guide.icon}</span>
<span className="flex-1">{guide.label}</span>
{guide.roles && guide.roles.includes(user?.role as UserRole) && (
<span className="text-xs bg-indigo-100 text-indigo-600 px-1.5 py-0.5 rounded-full">You</span>
)}
</button>
))}
</nav>
{/* TOC */}
<div className="border-t border-gray-100 px-3 py-3 flex-1">
<div className="relative mb-2">
<input
type="text"
placeholder="Search sections…"
value={search}
onChange={e => setSearch(e.target.value)}
aria-label="Search guide sections"
className="w-full text-xs border border-gray-200 rounded-lg px-3 py-1.5 pr-7 focus:outline-none focus:border-indigo-400 bg-gray-50"
/>
{search && (
<button
type="button"
onClick={() => setSearch('')}
aria-label="Clear search"
className="absolute right-2 top-1.5 text-gray-400 hover:text-gray-700 text-xs cursor-pointer"
>
</button>
)}
</div>
<div className="text-xs font-semibold text-gray-500 uppercase tracking-wide px-1 mb-1">
Contents
</div>
<nav aria-label="Table of contents" className="space-y-0.5 max-h-64 overflow-y-auto">
{filteredToc.length === 0 && (
<p className="text-xs text-gray-400 px-1 py-1">No sections found</p>
)}
{filteredToc.map(entry => (
<button
key={entry.slug}
type="button"
onClick={() => scrollToSection(entry.slug)}
className={`w-full text-left text-xs py-1 px-1 rounded transition-colors truncate cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-300 ${
entry.level === 3 ? 'pl-3' : ''
} ${
activeSection === entry.slug
? 'text-indigo-600 font-semibold bg-indigo-50'
: 'text-gray-500 hover:text-gray-800 hover:bg-gray-50'
}`}
>
{entry.level === 3 && <span className="mr-1 text-gray-300" aria-hidden="true"></span>}
{entry.text}
</button>
))}
</nav>
</div>
</aside>
{/* Main content area */}
<main
ref={contentRef}
className="flex-1 overflow-y-auto"
id="help-main-content"
tabIndex={-1}
>
<div className="max-w-4xl mx-auto px-8 py-10 pb-24">
<div className="mb-6 flex items-center gap-3">
<span className="text-3xl" aria-hidden="true">{currentGuide.icon}</span>
<div>
<h1 className="text-2xl font-bold text-gray-900">{currentGuide.label} Guide</h1>
<p className="text-sm text-gray-400">
{currentGuide.key === 'global'
? 'Platform overview for all users'
: `Step-by-step guide for ${currentGuide.label} role`}
</p>
</div>
</div>
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 p-8">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw]}
components={components}
>
{currentGuide.content}
</ReactMarkdown>
</div>
</div>
</main>
{/* Lightbox */}
{lightboxSrc && (
<div
role="dialog"
aria-modal="true"
aria-label="Image preview"
className="fixed inset-0 z-50 bg-black/80 flex items-center justify-center p-4"
onClick={() => setLightboxSrc(null)}
>
<div className="relative max-w-6xl max-h-full" onClick={e => e.stopPropagation()}>
<button
type="button"
onClick={() => setLightboxSrc(null)}
className="absolute -top-10 right-0 text-white text-sm hover:text-gray-300 font-bold cursor-pointer focus:outline-none focus:ring-2 focus:ring-white rounded"
autoFocus
>
Close (Esc)
</button>
<img
src={lightboxSrc}
alt={lightboxAlt || 'Screenshot'}
className="max-w-full max-h-[85vh] rounded-xl shadow-2xl object-contain"
/>
</div>
</div>
)}
</div>
);
}

View file

@ -1 +1,6 @@
/// <reference types="vite/client" />
declare module '*?raw' {
const content: string;
export default content;
}

View file

@ -0,0 +1,370 @@
/**
* Screenshot capture script for the Help guides.
*
* Usage (run from frontend/ directory needs frontend/node_modules/@playwright/test):
* cd frontend && npx tsx ../tools/capture-help-screenshots.ts
*
* Requires a .env.screenshots file at the repo root (see .env.screenshots.example).
* Before running, seed test users on optical-dev:
* docker compose exec backend python scripts/seed_test_users.py
*/
import { chromium, type Page, type BrowserContext } from '@playwright/test';
import * as fs from 'fs';
import * as path from 'path';
import * as dotenv from 'dotenv';
// Load credentials from .env.screenshots
dotenv.config({ path: path.join(__dirname, '../.env.screenshots') });
const BASE_URL = process.env.BASE_URL || 'https://optical-dev.oliver.solutions/video-accessibility';
const OUT_DIR = path.join(__dirname, '../frontend/public/help-screenshots');
// Ensure output directories exist
const ROLES = ['global', 'client', 'linguist', 'reviewer', 'production', 'project-manager', 'admin'];
for (const role of ROLES) {
fs.mkdirSync(path.join(OUT_DIR, role), { recursive: true });
}
interface AnnotationRect {
x: number;
y: number;
width: number;
height: number;
label?: string;
}
async function annotateAndScreenshot(
page: Page,
outputPath: string,
annotations: AnnotationRect[] = []
) {
if (annotations.length > 0) {
// Inject red rectangles as overlay divs
await page.evaluate((rects) => {
for (const rect of rects) {
const div = document.createElement('div');
div.style.cssText = `
position: fixed;
left: ${rect.x}px;
top: ${rect.y}px;
width: ${rect.width}px;
height: ${rect.height}px;
border: 3px solid #EF4444;
border-radius: 4px;
z-index: 99999;
pointer-events: none;
box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.3);
`;
if (rect.label) {
const label = document.createElement('span');
label.textContent = rect.label;
label.style.cssText = `
position: absolute;
top: -22px;
left: 0;
background: #EF4444;
color: white;
font-size: 11px;
font-weight: bold;
padding: 2px 6px;
border-radius: 3px;
white-space: nowrap;
font-family: system-ui, sans-serif;
`;
div.appendChild(label);
}
document.body.appendChild(div);
}
}, annotations);
}
await page.screenshot({ path: outputPath, fullPage: false });
// Clean up overlays
if (annotations.length > 0) {
await page.evaluate(() => {
document.querySelectorAll('[data-annotation-overlay]').forEach(el => el.remove());
});
}
console.log(`${path.relative(OUT_DIR, outputPath)}`);
}
async function getBBox(page: Page, selector: string): Promise<AnnotationRect | null> {
const el = page.locator(selector).first();
try {
const bb = await el.boundingBox({ timeout: 3000 });
if (!bb) return null;
return { x: bb.x, y: bb.y, width: bb.width, height: bb.height };
} catch {
return null;
}
}
async function login(page: Page, email: string, password: string) {
await page.goto(`${BASE_URL}/login`);
await page.waitForLoadState('networkidle');
// The login page defaults to Microsoft SSO view.
// Click "Local login" to reveal the email/password form.
try {
const toggle = page.getByRole('button', { name: /local login/i });
await toggle.click({ timeout: 3000 });
await page.waitForSelector('input[type="email"]', { timeout: 3000 });
} catch {
// Toggle absent (form already visible or different build) — proceed
}
await page.fill('input[type="email"]', email);
await page.fill('input[type="password"]', password);
await page.click('button[type="submit"]');
await page.waitForURL(url => !url.toString().includes('/login'), { timeout: 10000 });
await page.waitForLoadState('networkidle');
}
async function captureGlobal(context: BrowserContext) {
console.log('\n📸 Global screenshots...');
const page = await context.newPage();
// 01 — Login screen
await page.goto(`${BASE_URL}/login`);
await page.waitForLoadState('networkidle');
await annotateAndScreenshot(page, path.join(OUT_DIR, 'global/01-login.png'));
// 02 — Email field highlighted
const emailBB = await getBBox(page, 'input[type="email"]');
if (emailBB) {
await annotateAndScreenshot(page, path.join(OUT_DIR, 'global/02-login-email.png'), [
{ ...emailBB, label: 'Left-click here to type email' }
]);
}
// Login as test-admin to capture authenticated global screenshots
await login(page, process.env.TEST_ADMIN_EMAIL!, process.env.TEST_ADMIN_PASSWORD!);
// 03 — Interface overview
await page.goto(`${BASE_URL}/`);
await page.waitForLoadState('networkidle');
await page.waitForTimeout(500);
await annotateAndScreenshot(page, path.join(OUT_DIR, 'global/04-interface-overview.png'));
// 04 — Sidebar
const sidebarBB = await getBBox(page, 'nav');
await annotateAndScreenshot(page, path.join(OUT_DIR, 'global/05-sidebar.png'));
// 05 — Navbar
await annotateAndScreenshot(page, path.join(OUT_DIR, 'global/06-navbar.png'));
// 06 — Notifications (click bell)
const bell = page.locator('button[aria-label*="notification"], button:has-text("🔔")').first();
try {
await bell.click({ timeout: 3000 });
await page.waitForTimeout(300);
await annotateAndScreenshot(page, path.join(OUT_DIR, 'global/07-notifications.png'));
await page.keyboard.press('Escape');
} catch {
// Bell might not exist or be differently structured
}
await page.close();
}
async function captureClient(context: BrowserContext) {
console.log('\n📸 Client screenshots...');
const page = await context.newPage();
await login(page, process.env.TEST_CLIENT_EMAIL!, process.env.TEST_CLIENT_PASSWORD!);
// Dashboard
await page.goto(`${BASE_URL}/`);
await page.waitForLoadState('networkidle');
await page.waitForTimeout(500);
await annotateAndScreenshot(page, path.join(OUT_DIR, 'client/01-dashboard.png'));
// New Job page
await page.goto(`${BASE_URL}/jobs/new`);
await page.waitForLoadState('networkidle');
await page.waitForTimeout(500);
await annotateAndScreenshot(page, path.join(OUT_DIR, 'client/02-new-job.png'));
// Drop zone
const dropzoneBB = await getBBox(page, '[data-testid="upload-dropzone"], .dropzone, input[type="file"]');
if (dropzoneBB) {
await annotateAndScreenshot(page, path.join(OUT_DIR, 'client/03-upload-dropzone.png'), [
{ ...dropzoneBB, label: 'Drag file here or left-click to browse' }
]);
}
// Jobs list
await page.goto(`${BASE_URL}/jobs`);
await page.waitForLoadState('networkidle');
await page.waitForTimeout(500);
await annotateAndScreenshot(page, path.join(OUT_DIR, 'client/12-jobs-list.png'));
// Briefs list
await page.goto(`${BASE_URL}/briefs`);
await page.waitForLoadState('networkidle');
await page.waitForTimeout(500);
await annotateAndScreenshot(page, path.join(OUT_DIR, 'client/16-new-brief.png'));
await page.close();
}
async function captureLinguist(context: BrowserContext) {
console.log('\n📸 Linguist screenshots...');
const page = await context.newPage();
await login(page, process.env.TEST_LINGUIST_EMAIL!, process.env.TEST_LINGUIST_PASSWORD!);
// Dashboard
await page.goto(`${BASE_URL}/`);
await page.waitForLoadState('networkidle');
await page.waitForTimeout(500);
await annotateAndScreenshot(page, path.join(OUT_DIR, 'linguist/01-dashboard.png'));
// QC Queue
await page.goto(`${BASE_URL}/qc/queue`);
await page.waitForLoadState('networkidle');
await page.waitForTimeout(500);
await annotateAndScreenshot(page, path.join(OUT_DIR, 'linguist/02-qc-queue.png'));
await page.close();
}
async function captureReviewer(context: BrowserContext) {
console.log('\n📸 Reviewer screenshots...');
const page = await context.newPage();
await login(page, process.env.TEST_REVIEWER_EMAIL!, process.env.TEST_REVIEWER_PASSWORD!);
await page.goto(`${BASE_URL}/`);
await page.waitForLoadState('networkidle');
await page.waitForTimeout(500);
await annotateAndScreenshot(page, path.join(OUT_DIR, 'reviewer/01-dashboard.png'));
await page.goto(`${BASE_URL}/qc/queue`);
await page.waitForLoadState('networkidle');
await page.waitForTimeout(500);
await annotateAndScreenshot(page, path.join(OUT_DIR, 'reviewer/02-qc-queue.png'));
await page.goto(`${BASE_URL}/qc/reviewer-queue`);
await page.waitForLoadState('networkidle');
await page.waitForTimeout(500);
await annotateAndScreenshot(page, path.join(OUT_DIR, 'reviewer/03-reviewer-queue.png'));
await page.goto(`${BASE_URL}/admin/final`);
await page.waitForLoadState('networkidle');
await page.waitForTimeout(500);
await annotateAndScreenshot(page, path.join(OUT_DIR, 'reviewer/08-final-list.png'));
await page.close();
}
async function captureProduction(context: BrowserContext) {
console.log('\n📸 Production screenshots...');
const page = await context.newPage();
await login(page, process.env.TEST_PRODUCTION_EMAIL!, process.env.TEST_PRODUCTION_PASSWORD!);
await page.goto(`${BASE_URL}/`);
await page.waitForLoadState('networkidle');
await page.waitForTimeout(500);
await annotateAndScreenshot(page, path.join(OUT_DIR, 'production/01-dashboard.png'));
await page.goto(`${BASE_URL}/jobs`);
await page.waitForLoadState('networkidle');
await page.waitForTimeout(500);
await annotateAndScreenshot(page, path.join(OUT_DIR, 'production/02-jobs-list-bulk.png'));
await page.goto(`${BASE_URL}/admin/failures`);
await page.waitForLoadState('networkidle');
await page.waitForTimeout(500);
await annotateAndScreenshot(page, path.join(OUT_DIR, 'production/05-failures.png'));
await page.goto(`${BASE_URL}/admin/audit-log`);
await page.waitForLoadState('networkidle');
await page.waitForTimeout(500);
await annotateAndScreenshot(page, path.join(OUT_DIR, 'production/06-audit-log.png'));
await page.close();
}
async function captureProjectManager(context: BrowserContext) {
console.log('\n📸 Project Manager screenshots...');
const page = await context.newPage();
await login(page, process.env.TEST_PM_EMAIL!, process.env.TEST_PM_PASSWORD!);
await page.goto(`${BASE_URL}/`);
await page.waitForLoadState('networkidle');
await page.waitForTimeout(500);
await annotateAndScreenshot(page, path.join(OUT_DIR, 'project-manager/01-dashboard.png'));
await page.goto(`${BASE_URL}/admin/final`);
await page.waitForLoadState('networkidle');
await page.waitForTimeout(500);
await annotateAndScreenshot(page, path.join(OUT_DIR, 'project-manager/02-final-list.png'));
await page.goto(`${BASE_URL}/admin/clients`);
await page.waitForLoadState('networkidle');
await page.waitForTimeout(500);
await annotateAndScreenshot(page, path.join(OUT_DIR, 'project-manager/05-clients.png'));
await page.close();
}
async function captureAdmin(context: BrowserContext) {
console.log('\n📸 Admin screenshots...');
const page = await context.newPage();
await login(page, process.env.TEST_ADMIN_EMAIL!, process.env.TEST_ADMIN_PASSWORD!);
await page.goto(`${BASE_URL}/`);
await page.waitForLoadState('networkidle');
await page.waitForTimeout(500);
await annotateAndScreenshot(page, path.join(OUT_DIR, 'admin/01-dashboard.png'));
// User Management
await page.goto(`${BASE_URL}/admin/users`);
await page.waitForLoadState('networkidle');
await page.waitForTimeout(500);
await annotateAndScreenshot(page, path.join(OUT_DIR, 'admin/02-user-list.png'));
// Create User modal
const createBtn = page.getByRole('button', { name: /create user/i });
try {
await createBtn.click({ timeout: 3000 });
await page.waitForTimeout(400);
await annotateAndScreenshot(page, path.join(OUT_DIR, 'admin/03-create-user-modal.png'));
await page.keyboard.press('Escape');
} catch { /* modal may not have opened */ }
await page.close();
}
async function main() {
console.log('🚀 Capturing help screenshots...');
console.log(` Target: ${BASE_URL}`);
console.log(` Output: ${OUT_DIR}\n`);
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({
viewport: { width: 1440, height: 900 },
deviceScaleFactor: 1,
});
try {
await captureGlobal(context);
await captureClient(context);
await captureLinguist(context);
await captureReviewer(context);
await captureProduction(context);
await captureProjectManager(context);
await captureAdmin(context);
} finally {
await context.close();
await browser.close();
}
console.log('\n✅ All screenshots captured!');
}
main().catch(err => {
console.error('Screenshot capture failed:', err);
process.exit(1);
});