Skip to content

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):

NOT_STARTED, APPLIED, UNDER_PROCESS, SENT_TO_EMBASSY, ISSUED, COLLECTED, REJECTED

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) when bookingPassengerId 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):

  1. User picks a visa case, selects a file, chooses a fileType (VISA_PDF, PASSPORT_SCAN, APPROVAL_EMAIL, or OTHER).
  2. The client calls uploadVisaFile → storage, then uploadVisaDocument/visa/:id/upload.
  3. The API handler inserts a VisaDocument row.

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.bookingId is required — the case always belongs to a booking.
  • VisaCase.customerId is 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 VisaCase with status NOT_STARTED. This action requires visa.create (see PERMISSIONS.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.

  • VisaCase — core row; created in 20260113130121_remote_schema.sql, per-passenger split in 20260414110000_visa_per_passenger.sql.
  • VisaDocument — uploads; created in 20260113130121_remote_schema.sql, Drive fields in 20260410110000_visa_document_drive_fields.sql.
  • VisaStatusHistory — audit trail of state transitions.
  • VisaStatus enum — DB-level enum; values corrected in 20260410100000_fix_visa_status_values.sql.