Skip to content

Platform Overview & Role Architecture

This page is the single authoritative reference for how the platform is structured. It covers business context, the full role and permission model, every column in the users and agent_types tables, how auth tokens are built, known gaps in the current implementation, and what has been planned but not yet built. If you’re starting fresh or onboarding, read this before touching any code.


Shanvi Travels is a B2B travel and visa operations platform — not a consumer booking site. External partner agents (travel agencies, educational consultancies) use it to serve their own customers. Shanvi’s internal staff use it to process those requests.

The three current services:

ServiceStatusDescription
Fixed Departure (FD) TicketingLiveShanvi-managed flight inventory. Agents search, book, and reserve seats.
VFS Document ProcessingLive8-stage document pipeline from agent submission through VFS office and back.
Thai Visa SubmissionsLiveEnd-to-end visa application management with payment and document tracking.

The product is designed to evolve across three phases. The codebase is currently in Phase 1, with Phase 2 architecture being planned.

Phase 1 — Current: Shanvi as sole operator Shanvi manages all inventory and processing. Agents submit requests through the portal. No external seller access.

Phase 2 — Marketplace: FD inventory opens to external sellers Verified partner agencies can upload their own FD ticket inventory. Shanvi becomes the distribution layer. Multiple sellers compete; agents book from any seller. Shanvi earns a commission on each transaction.

Phase 3 — Ecosystem Commission management, agent digital wallets, credit systems, performance analytics, regional expansion.


LayerTechnology
FrontendReact (Vite), TypeScript
BackendNode.js + Express, deployed as a Cloudflare Worker via @cloudflare/workers-types
Database (both environments)Cloudflare D1 (SQLite dialect) — local dev via wrangler dev, production via Cloudflare
File storage (local dev)Local disk (/uploads/) via multer
File storage (production)Cloudflare R2 (S3-compatible)
AuthJWT (access token) + JWT (refresh token), jsonwebtoken + bcryptjs
HostingCloudflare Pages (frontend) + Cloudflare Workers (backend)
DocsAstro Starlight SSG (/docs subdirectory in same repo)

D1 is used in both local development and production. Run wrangler dev locally — it provisions a local D1 SQLite file under .wrangler/state/v3/d1/ and injects D1_DB into globalThis exactly as it is in production. There is no MySQL dependency.

Note on config/db.js: The file still contains a MySQL pool fallback (for when globalThis.D1_DB is not set). This code is now dead — it will never run if you always use wrangler dev. It should be cleaned up, but it is harmless to leave in place for now.


db.run(sql, params, callback) — INSERT / UPDATE / DELETE
db.get(sql, params, callback) — SELECT returning one row
db.all(sql, params, callback) — SELECT returning array
If globalThis.D1_DB exists → uses D1 bindings (Cloudflare Worker)
Otherwise → uses MySQL connection pool (local dev / traditional server)

Callback receives (err, result). For run, this.lastID and this.changes are available in the callback context (mirrored from sqlite3 convention for D1 compatibility).

Known issue: The db.run/get/all promise wrapper is copy-pasted into several route files rather than imported from a shared utility. See Build Backlog.


The users table is created by the database on first startup. Additional columns are added via ALTER TABLE in ensureUserColumnsExist() (called by index.js on every startup).

ColumnTypeValues / Notes
idINT, PK, AUTO_INCREMENT
usernameVARCHAR, UNIQUE3–20 alphanumeric + underscore
emailVARCHAR, UNIQUElowercase-normalized on save
passwordVARCHARbcrypt hash, 10 rounds
roleENUM/TEXTUSER | AGENT | ADMIN
agentTypeTEXTName string (FK bug — see below)
statusTEXTACTIVE | PENDING | SUSPENDED | DEACTIVATED
createdAtTIMESTAMPAuto-set
kyc_statusTEXTNOT_SUBMITTED | SUBMITTED | APPROVED | REJECTED (inferred from admin usage)
kyc_locationTEXTLocation text from KYC step 1
kyc_registration_fileTEXTURL to uploaded business registration doc
kyc_pan_fileTEXTURL to uploaded PAN/tax doc
consultancy_nameTEXTBusiness name from KYC step 1
consultancy_addressTEXTBusiness address
consultancy_phoneTEXTPrimary mobile (required in KYC)
consultancy_telTEXTLandline (optional)
consultancy_emailTEXTBusiness email (optional, validated)

Extended columns (added by ensureUserColumnsExist migration)

Section titled “Extended columns (added by ensureUserColumnsExist migration)”

These are added via ALTER TABLE … ADD COLUMN on startup. Safe to run repeatedly — errors for “duplicate column” are silently ignored.

ColumnTypeNotes
mobileNumberTEXTOperational contact (separate from KYC phone)
mobileNumber2TEXTSecondary contact
landlineNumberTEXTOffice landline
locationTEXTGeographic location / branch
outletIdTEXTOutlet or branch identifier
nameTEXTDisplay name (separate from username)
maxCommissionINTEGER DEFAULT 0Max commission percentage, 0–100
avatarTEXT DEFAULT NULL'boy' | 'girl' — two static avatar options
ColumnTypePurpose
userTierTEXTINTERNAL | EXTERNAL — separates Shanvi staff from partner agents
agentTypeIdINT FKReplaces agentType name string with FK to agent_types.id
trustLevelTEXTSTANDARD | HIGH — controls FD upload approval workflow

Agent types are the primary RBAC mechanism for agents. Each agent is assigned one agent type; their permissions derive entirely from that type.

ColumnTypeNotes
idINT, PK, AUTO_INCREMENT
nameVARCHAR, UNIQUEDisplay name — also used as lookup key in users.agentType
descriptionTEXTHuman-readable description
permissionsTEXT (JSON array)Serialized array of permission strings
categoryTEXTUsed as fallback when systems is empty
systemsTEXT (JSON array)Added via ensureAgentTypeColumnsExist migration on startup
isActiveINTEGER (0/1)Default 1 (active)

The systems column was added after initial table creation. It is added via ALTER TABLE agent_types ADD COLUMN systems TEXT DEFAULT NULL on startup, silently skipped if already present.

NameTierSystemsPurpose
HEAD_OFFICEInternalVFS, TICKETINGShanvi management — full access to all operations
DOCUMENT_RECEIVERInternalVFSReceives incoming physical documents from couriers
DOCUMENT_VERIFIERInternalVFSAdditional document verification step
VFS AgentInternalVFSManages VFS pipeline stage transitions
ConsultancyExternalVFS, TICKETINGEducational consultancies submitting visa applications for students
Travel AgentExternalTICKETINGAgencies booking FD tickets and travel services

Important: users.agentType stores the name string, not the ID. This means if an admin renames an agent type from Consultancy to Education Partner, all users assigned to Consultancy silently lose their permissions because the lookup by name returns nothing. Fix tracked in Build Backlog.


Permissions are stored as a JSON array in agent_types.permissions. At login, enrichUserData() fetches them and embeds them into the user response and token. Frontend uses them to conditionally render UI. Backend route files guard operations by checking req.user.permissions.

PermissionUsed For
MANAGE_TICKETSCreate, edit, and manage FD tickets (Shanvi staff)
VIEW_ALL_TICKETSView all FD tickets across all agents
CREATE_TASKCreate new VFS tracking tasks / submissions
VIEW_ALL_DOCUMENTSView all documents in the VFS pipeline
DOCUMENT_RECEIVERMark a document as received at the Shanvi office (VFS Stage 2)
DOCUMENT_AT_SHANVIMark a document as physically at Shanvi (VFS Stage 3)
VFS_RECEIVEDMark a document as received by VFS office (VFS Stage 4)
VFS_AFTER_SHANVIMark a document as back at Shanvi after VFS (VFS Stage 6)
CONSULTANCY_RECEIVEDMark a document as received back by the consultancy (VFS Stage 7)
TASK_CLOSEClose a VFS task after it completes all stages
REJECT_TASKReject / fail a VFS task

HEAD_OFFICE hardcoded fallback: If agent_types lookup fails for a user whose agentType is HEAD_OFFICE or HEAD OFFICE (case/underscore insensitive), the system falls back to a hardcoded full-permission set rather than returning empty permissions. This is a legacy safety net in enrichUserData(). External agents that fail lookup get empty permissions with no fallback.

PermissionPurpose
UPLOAD_FD_INVENTORYGrants FD seller capabilities to an agent — unlocks inventory management section

How permissions reach the frontend (enrichUserData)

Section titled “How permissions reach the frontend (enrichUserData)”

enrichUserData(userData) in routes/auth.js is called every time a token is verified or a user logs in. It looks up agent_types by the user’s agentType name string and attaches permissions[] and systems[] to the user object.

Login / Verify flow:
1. User authenticates (login or /verify)
2. DB row fetched: id, username, email, role, agentType, status, etc.
3. enrichUserData(rawUser) called
a. If agentType is null → permissions: [], systems: []
b. Else → SELECT permissions, systems, category FROM agent_types WHERE name = ?
- If found → parse JSON from row, attach to user object
- If not found AND agentType is HEAD_OFFICE → hardcoded fallback permissions
- If not found AND agentType is anything else → permissions: [], systems: []
4. Enriched user object returned in response body

Permissions are not embedded in the JWT itself — only id, username, email, role, and agentType are signed into the token. Permissions are always fetched live from the DB on each /verify call. This means permission changes take effect immediately on the next request without requiring a re-login.


FieldValue
Payload{ id, username, email, role, agentType }
SecretSECRET_KEY (from middleware/auth.js, sourced from JWT_SECRET env var)
Expiry6h for AGENT and ADMIN roles; 30m for USER role
FieldValue
Payload{ id, type: 'refresh' }
SecretREFRESH_SECRET_KEY (from REFRESH_SECRET_KEY env var — must differ from JWT_SECRET in production)
Expiry7d

Refresh tokens are used via POST /api/auth/refresh. They return a new access token. There is no token blacklist — logout is client-side only (delete tokens from storage). If a refresh token is compromised, there is no server-side revocation mechanism. This is a known gap.

Critical issue: Both JWT_SECRET and REFRESH_SECRET_KEY are currently hardcoded in backend/wrangler.toml in plaintext. These values are burned and must be rotated by moving them to Cloudflare Secrets (wrangler secret put). See Build Backlog.


RoleWhoAccess Level
USERAnyone who registersDefault role. Can browse public content, track their own submissions, complete KYC.
AGENTVerified business partnersPromoted by admin. Full B2B access: book tickets, submit visa applications, view B2B pricing. Permissions further defined by their assigned agent type. Also used for all Shanvi internal staff — this conflation is a known architectural problem (see below).
ADMINShanvi admin staffFull platform management. User CRUD, agent type management, KYC review, role assignment.
  • An admin cannot demote themselves (PUT /admin/users/:id/role blocks self-demotion)
  • The last active admin cannot be demoted (query checks COUNT(*) WHERE role='ADMIN' AND status='ACTIVE')
  • The last active admin cannot be deleted (same count check)
  • The admin username account cannot be deleted (hardcoded guard on username=‘admin’)
  • An admin cannot suspend or deactivate themselves (PUT /admin/users/:id/status)

Current (problematic): Both Shanvi internal staff and external partner agents share role = AGENT. The only distinction is agentType — internal staff have types like HEAD_OFFICE, DOCUMENT_RECEIVER, etc. This creates confusing permission checks throughout the codebase that have to string-match on agentType to know whether they’re dealing with a Shanvi employee or an external partner.

Planned fix: Add userTier TEXT column to users:

TierWhoAssigned to
INTERNALShanvi employeesStaff accounts: HEAD_OFFICE, DOCUMENT_RECEIVER, VFS Agent, etc.
EXTERNALPartner businessesConsultancy, Travel Agent, FD sellers

Until this column is added, code that needs to distinguish internal from external agents must check agentType name strings.


POST /api/auth/register
Body: { username, email, password }
Validates:
- username: 3-20 alphanumeric + underscore, not reserved
Reserved: admin, administrator, root, superuser, system, support
- email: standard format
- password: min 8 chars, at least one uppercase, lowercase, digit
Creates: users row with role='USER', status='ACTIVE', kyc_status='NOT_SUBMITTED'
Returns: JWT access token (30m), refresh token (7d), user object

Step 2: KYC submission → status SUBMITTED

Section titled “Step 2: KYC submission → status SUBMITTED”

KYC is a two-step process. Step 1 submits text info; Step 2 uploads files.

POST /api/auth/kyc-submit-info (authenticated)
Body: { location, consultancy_name, consultancy_address,
consultancy_phone, consultancy_tel, consultancy_email }
Effect: Updates users row with consultancy fields. kyc_status stays as-is.
POST /api/auth/kyc-submit-files (authenticated, multipart)
Files: registration_file, pan_file
Effect: Saves files to storage, updates kyc_status='SUBMITTED',
sets kyc_registration_file and kyc_pan_file URLs.
Admin dashboard shows users with kyc_status='SUBMITTED'
Admin can:
- Approve: PUT /api/admin/users/:id/role
{ role: 'AGENT', agentType: '<type>', kyc_status: 'APPROVED' }
- Reject: POST /api/admin/users/:id/kyc-reject
Resets kyc_status='NOT_SUBMITTED' so user can resubmit

Step 4 (optional): Grant FD seller permission

Section titled “Step 4 (optional): Grant FD seller permission”

Not yet implemented in the codebase. Planned mechanism: admin grants UPLOAD_FD_INVENTORY permission via the agent type’s permission set or a dedicated user-level permission override.

Not yet implemented. Will be controlled by trustLevel column on users: STANDARD (uploads need admin approval) vs HIGH (uploads go live immediately).


All admin routes require Authorization: Bearer <token> with role = ADMIN. The adminOnly middleware rejects any other role with 403.

MethodPathDescription
GET/api/admin/usersList all users with full profile, KYC status and files
PUT/api/admin/users/:id/statusSet user status: ACTIVE, PENDING, SUSPENDED, DEACTIVATED
PUT/api/admin/users/:id/rolePromote / demote role, assign agentType, optionally set status and kyc_status
PUT/api/admin/users/:id/reset-passwordForce-reset a user’s password (same strength rules as registration)
POST/api/admin/users/:id/kyc-rejectReject KYC, reset to NOT_SUBMITTED
DELETE/api/admin/users/:idDelete user (guards: can’t delete self, last admin, or username=‘admin’)
GET/api/admin/agent-typesList all agent types
POST/api/admin/agent-typesCreate new agent type
PUT/api/admin/agent-types/:idUpdate agent type (name, description, permissions, systems, isActive)
DELETE/api/admin/agent-types/:idDelete agent type

MethodPathAuth requiredDescription
POST/api/auth/registerNoCreate new USER account
POST/api/auth/loginNoAuthenticate, returns access + refresh tokens
POST/api/auth/refreshNo (refresh token in body)Exchange refresh token for new access token
GET/api/auth/verifyBearer tokenVerify token, return enriched user object (permissions included)
GET/api/auth/profileBearer tokenFetch current user’s full profile
PUT/api/auth/profileBearer tokenUpdate profile fields (email, password, contacts, avatar, etc.)
POST/api/auth/kyc-submit-infoBearer tokenSubmit KYC text fields
POST/api/auth/kyc-submit-filesBearer tokenUpload KYC documents (registration + PAN)
POST/api/auth/logoutNo-op (client deletes tokens; no server blacklist)

An FD seller is an agent with the UPLOAD_FD_INVENTORY permission — not a separate top-level role. This is deliberate: an FD seller almost always also books tickets and submits visa applications. A separate role would require dual-account support.

How it works (planned — not yet in code)

Section titled “How it works (planned — not yet in code)”
  1. User registers → USER
  2. Admin promotes to AGENT with an appropriate agent type
  3. Admin separately grants UPLOAD_FD_INVENTORY permission (via agent type or direct user-level override)
  4. Agent’s dashboard shows an Inventory Management section
Trust LevelWhoUpload behavior
STANDARDNew / unproven sellersUpload → PENDING_REVIEW → Admin approves → Live
HIGHEstablished, verified sellersUpload → Live immediately

Admin promotes a seller from STANDARD to HIGH after a track record is established.

Even HIGH trust sellers will be subject to:

  • Duplicate detection — same route + date + airline check before insert (returns 409 on conflict)
  • uploadedBy FKfd_tickets.uploadedBy references users.id for full audit trail
  • Expiry enforcement — tickets past departure date auto-delist
  • Admin approval queue — admin view of all PENDING_REVIEW tickets from STANDARD sellers

These are real problems in the current codebase, not just planned improvements. Be aware of them when writing new code.

users.agentType contains a plain text name like "Consultancy". The lookup in enrichUserData() does WHERE name = ?. Renaming an agent type via the admin UI updates agent_types.name but does not update all the user rows pointing to that name. Those users silently lose all permissions on next login. Fix: migrate to agentTypeId INT FK.

2. Internal and external agents share role = AGENT

Section titled “2. Internal and external agents share role = AGENT”

Shanvi staff (HEAD_OFFICE, DOCUMENT_RECEIVER, etc.) have the same database role as external partner consultancies. Any code that needs to distinguish them must do string matching on agentType, which is fragile. Fix: add userTier: INTERNAL | EXTERNAL column.

3. VFS status updates have no server-side permission enforcement

Section titled “3. VFS status updates have no server-side permission enforcement”

PUT /api/documents/:id/status accepts any authenticated agent setting any status. The frontend only shows relevant buttons per role, but the backend doesn’t enforce which agent types can set which statuses. The tickets.js file has a checkCanManageTickets() pattern that should be mirrored for VFS. Fix: add checkCanUpdateVfsStatus().

JWT_SECRET and REFRESH_SECRET_KEY are in plaintext in backend/wrangler.toml. Anyone with read access to the repo can forge tokens. Must move to wrangler secret put and regenerate both values immediately.

5. Rate limiting installed but not applied

Section titled “5. Rate limiting installed but not applied”

express-rate-limit is a dependency but is not applied to /api/auth/login or /api/auth/register. These endpoints are open to brute-force attacks. The submit-payment fix already shows how to wire it up — the same pattern applies to auth endpoints.

getFileUrl() returns /uploads/filename. This path works in local dev (Express serves the uploads/ directory as static). In the Cloudflare Worker environment, no local filesystem exists — all files are in R2. The URL must point to the R2 public domain. Fix: set bucket to public, configure custom domain, update getFileUrl.

7. Schema migrations scattered across route files

Section titled “7. Schema migrations scattered across route files”

ALTER TABLE guards are embedded in ensureUserColumnsExist(), ensureAgentTypeColumnsExist(), initializeTicketsDb(), initializeSubmissionsDb() — each called from index.js on startup. There is no migration history, no rollback capability, and no way to know what version of the schema is deployed. Fix: adopt Drizzle ORM + Drizzle Kit for versioned migrations.

All changes deploy directly to production. There is no staging Worker, no staging D1 database, and no Cloudflare Pages preview-branch setup. Any bad deploy goes live immediately.


Full details, file references, and status in the Build Backlog. Summary for reference:

Critical — fix before anything else:

  1. Rotate JWT secrets — move from wrangler.toml to Cloudflare Secrets
  2. Add server-side VFS permission enforcement on PUT /api/documents/:id/status
  3. Apply express-rate-limit to /api/auth/login and /api/auth/register

Role architecture foundation (must ship together): 4. userTier column on usersINTERNAL | EXTERNAL 5. agentTypeId INT FK on users — replace name string 6. trustLevel column on usersSTANDARD | HIGH 7. UPLOAD_FD_INVENTORY permission — new value in agent_types permission set 8. uploadedBy FK on fd_tickets — track which seller uploaded which ticket 9. SUPER_ADMIN role — manage other admins, full audit log access 10. FD upload approval queue — admin view for PENDING_REVIEW tickets 11. Duplicate detection on FD upload — check route + date + airline before insert

VFS tracking: 12. dispatch_batches + batch_documents tables and batch creation endpoint 13. Batch UI for Shanvi staff 14. vfsOutcome column on documentsAPPROVED | REJECTED separate from pipeline status 15. Fix duplicate stage labels in VFS tracking UI

Infrastructure: 16. Fix R2 file URLs in production 17. Set up staging environment 18. Adopt Drizzle ORM for versioned migrations