Visa
Visa case tracking — a pipeline view that follows each visa application from "pending docs" through "collected" (or "rejected"), with document uploads, per-passenger cases, and audited status history.
Scope
The Visa module is an operational tracker, not a submission portal. Cases are keyed to a booking (optionally a specific passenger on that booking) and moved through a fixed set of stages as the VFS/embassy workflow progresses. Document artifacts (passport scans, approval emails, the issued visa PDF) are attached to each case.
Pages
src/pages/visa/VisaPipeline.tsx— primary page (~1,200 lines). Column view keyed by stage, bulk status updates, per-row upload, create dialog. Route:/visa.src/pages/visa/VisaCaseDetail.tsx— read-only detail drawer showing status history and documents for a single case. Route:/visa/:id.src/pages/visa/visaMath.ts+visaMath.test.ts— pure helpers used by the pipeline for stage counts, filter predicates, and group-option building. The tests live next to the file.
Visa case states
Two parallel vocabularies exist — the UI uses lowercase friendly names, the database uses uppercase enum values. The service layer (src/services/visaService.ts) translates between them.
UI status (VisaStatus) |
DB status (public.VisaStatus enum) |
Meaning |
|---|---|---|
pending_docs |
NOT_STARTED |
Case created, waiting for the customer to submit passport + supporting documents |
docs_received |
APPLIED |
Documents are in; ready to be submitted to the consulate/VFS |
submitted |
UNDER_PROCESS or SENT_TO_EMBASSY |
Application is with the authority for processing |
approved |
ISSUED |
Visa has been issued by the authority |
collected |
COLLECTED |
Physical visa / digital PDF received by the agency |
rejected |
REJECTED |
Application refused |
The mapping lives in VISA_STATUS_MAP and normalizeVisaStatus() in src/lib/api.ts (line ~2872). Allowed DB values (per 20260410100000_fix_visa_status_values.sql):
Stage ordering in the UI
The pipeline page (VisaPipeline.tsx line ~47) presents stages in this fixed order: Pending Docs → Docs Received → Submitted → Approved → Collected → Rejected. Stage counts and column totals come from computeStageCounts() in visaMath.ts.
Every status change writes a row to VisaStatusHistory (visaCaseId, fromStatus, toStatus, changedAt) — see the PATCH /visa/:id/status handler in src/lib/api.ts (line ~14346).
Notifications on status change
When a status is changed via API, the handler calls sendMailer({ type: 'visa_status_change', ... }) to both the booking customer and agent (if present). Email failures are logged and swallowed — they do not block the state transition. See src/lib/api.ts around line 14384.
Per-passenger cases
Originally there was one visa case per booking (enforced by a unique index on VisaCase.bookingId). The 20260414110000_visa_per_passenger.sql migration relaxed this so each BookingPassenger can have its own case:
VisaCase.bookingPassengerId— nullable link to the specific passenger.VisaCase.passengerName— denormalized snapshot of the passenger's name.- New unique index:
VisaCase (bookingId, bookingPassengerId)whenbookingPassengerId IS NOT NULL. - Legacy rows (one-case-per-booking) remain valid because of the partial index condition.
Document upload
Documents attach to a case through the VisaDocument table. Each row carries a fileType (a DB enum FileType), fileName, storageKey pointing at Supabase Storage, and an optional mimeType. Drive-sync fields were added in 20260410110000_visa_document_drive_fields.sql for Google Drive mirroring.
Upload flow (from the pipeline UI):
- User picks a visa case, selects a file, chooses a
fileType(VISA_PDF,PASSPORT_SCAN,APPROVAL_EMAIL, orOTHER). - The client calls
uploadVisaFile→ storage, thenuploadVisaDocument→/visa/:id/upload. - The API handler inserts a
VisaDocumentrow.
Permission drift on upload
The POST /visa/:id/upload handler currently calls requirePermission('tickets.edit') instead of visa.edit — see src/lib/api.ts line 14409. This is almost certainly a copy-paste bug from the ticket document handler; the UI gates the upload button on visa.edit.
Documents can also be deleted via deleteVisaDocument — the UI gates this on visa.edit and the server-side delete goes through the same handler family.
Relationship to bookings
Booking ──(1)──▶ VisaCase ──(0..N)──▶ VisaDocument
│ └──(N)─▶ VisaStatusHistory
└──(optional)──▶ BookingPassenger (per-passenger case)
VisaCase.bookingIdis required — the case always belongs to a booking.VisaCase.customerIdis denormalized from the booking's customer at creation (falls back to a direct lookup if the client omits it).- From the booking detail page, ops can click "Request visa case" — this creates a new
VisaCasewith statusNOT_STARTED. This action requiresvisa.create(seePERMISSIONS.md §6.4).
Permissions
Route guard: visa.view (src/App.tsx:167–168).
| Action | Permission | Enforcement |
|---|---|---|
View /visa pipeline |
visa.view |
<ProtectedRoute> |
View /visa/:id detail |
visa.view |
<ProtectedRoute> |
| Create new visa case (pipeline header + booking action) | visa.create |
<PermissionGate> + server requirePermission('visa.create') on POST /visa |
| Update case status (per-row, bulk) | visa.edit |
<PermissionGate> + server requirePermission('visa.edit') on PATCH /visa/:id/status |
| Upload document | visa.edit (UI) / tickets.edit (server drift, see above) |
<PermissionGate> in UI; server check is the wrong permission |
| Delete document | visa.edit |
<PermissionGate> + server handler |
| Sync visa cases | visa.edit |
<PermissionGate> |
See docs/PERMISSIONS.md §6.7 for the authoritative matrix. The dedicated role VISA_OFFICER carries visa.view + visa.create + visa.edit.
Related tables (Supabase)
VisaCase— core row; created in20260113130121_remote_schema.sql, per-passenger split in20260414110000_visa_per_passenger.sql.VisaDocument— uploads; created in20260113130121_remote_schema.sql, Drive fields in20260410110000_visa_document_drive_fields.sql.VisaStatusHistory— audit trail of state transitions.VisaStatusenum — DB-level enum; values corrected in20260410100000_fix_visa_status_values.sql.