Skip to content

API Overview

The Alhuda Travel ERP exposes its entire server API through a single handler monolith at src/lib/api.ts (28,238 lines as of this writing). Every route on the frontend — booking creation, journal approval, visa status changes, partner portal queries — funnels through one exported function, apiFetch<T>(path, options).

Why a monolith?

The codebase deliberately keeps every handler in one file instead of splitting per-module routers. This is intentional and documented in src/lib/api.ts:50-88:

  • Contracts are co-located. The permission check, input validation, DB work, side-effects (audit log, emailer, push notification), and return shape all live next to each other. When you change the contract you touch one place.
  • Refactors are greppable. Renaming a permission, deprecating a column, or swapping a finance-posting helper is a single-file change. Every call path for a given route is visible in a local scroll.
  • Every route uses the same auth surface. No router has to re-implement JWT extraction, role resolution, period-lock checking, or idempotency key construction. All handlers share the module-level helpers (requirePermission, getCurrentUserId, requireOk, logAudit, createJournalWithLines, etc.).
  • The dispatcher is flat. _apiFetchInternal (at src/lib/api.ts:27822) is a giant cascading if-ladder on routePath. Reading it end-to-end is the canonical endpoint catalog.

The downside — one large file — is intentional. Breaking it up would fragment the auth/audit/ idempotency patterns that depend on every route sitting next to every other route.


How to call it (from the frontend)

All frontend services go through the exported apiFetch wrapper:

import { apiFetch } from '@/lib/api';

// GET
const bookings = await apiFetch<Booking[]>('/sales/bookings');

// POST with a body
const booking = await apiFetch<Booking>('/sales/bookings', {
  method: 'POST',
  body: JSON.stringify({ customerId, groupId, totalAmount, /* ... */ }),
});

// PATCH with a path parameter
await apiFetch(`/sales/bookings/${id}`, {
  method: 'PATCH',
  body: JSON.stringify({ status: 'ON_HOLD' }),
});

apiFetch is defined at src/lib/api.ts:27794. Under the hood:

  1. It parses the ?query=string out of the path to derive routePath.
  2. GET requests for identical paths are deduplicated via an in-flight promise map — two concurrent GET /sales/bookings calls share one underlying query.
  3. Non-GET calls bypass the dedup map.
  4. The request is dispatched by _apiFetchInternal through the cascade at src/lib/api.ts:27822-28237.

What "routes" look like

Routes are URL-style strings dispatched by a cascading if-ladder, not REST verbs on a true HTTP server. For example:

  • GET /sales/bookings — list bookings
  • POST /sales/bookings — create a booking
  • PATCH /sales/bookings/:id — update a booking
  • POST /finance/bookings/:id/finance-approve — finance approval action
  • POST /finance/journals/:id/approve — approve a journal
  • POST /finance/journals/approve-bulk — bulk-approve journals

The full catalog is in this section's sub-pages (finance, bookings, customers, inventory, admin, auth).


Authentication

Every call carries a Supabase JWT via the global Supabase client (@/integrations/supabase/client). Handlers read the current user via supabase.auth.getUser() — no custom auth middleware.

Helpers at src/lib/api.ts:72-77:

async function getCurrentUserId(): Promise<string | null> {
  const { data } = await supabase.auth.getUser();
  return data?.user?.id ?? null;
}

If the JWT is missing or expired, getCurrentUserId() returns null, and requirePermission() throws ApiError(401, { message: 'Authentication required' }).

Partner (agent) and customer portal routes live under /portals/agent/* and /portals/customer/* — those are JWT-authenticated but skip requirePermission because they scope every query to the authenticated agent's or customer's own rows (see resolveAgentForCurrentUser helpers). See Auth for details.


Permission pattern

Every write handler begins with an explicit permission check. The helper is defined at src/lib/api.ts:136:

async function requirePermission(name: string): Promise<void> {
  const userId = await getCurrentUserId();
  if (!userId) throw new ApiError(401, { message: 'Authentication required' });
  if (!(await userHasPermission(userId, name))) {
    throw new ApiError(403, { message: `Permission denied: ${name}` });
  }
}

Resolution order inside userHasPermission (src/lib/api.ts:99-134):

  1. UserPermission overrideallowed=false blocks; allowed=true grants.
  2. Role-based grant via RolePermission for any of the user's roles.
  3. Otherwise → denied.

No role-name bypasses. CEO / GM / IT_ADMIN get every permission because they are seeded every RolePermission row explicitly by supabase/migrations/20260416020000_remove_permission_bypass.sql — not by a hardcoded short-circuit. If you need a user to have a permission, grant it; do not branch on role name.

Inside handlers the pattern is always:

if (path === '/sales/bookings' && method === 'POST') {
  await requirePermission('bookings.create');
  // ...everything else
}

The permission name must exist in docs/PERMISSIONS.md §4. The drift test src/test/permissions.matrix.test.ts fails the build if code references a permission not in the matrix, or if the matrix declares a permission no code references.

A handful of handlers are intentionally un-gated (marked with a @no-permission: comment in the source). These are either:

  • Pre-login: /auth/login, /auth/register, /auth/partner-signup, /auth/resolve-identifier, /auth/2fa/status, /auth/otp/send, /auth/otp/verify.
  • Self-service within an authenticated portal: agent updating own profile (PATCH /portals/agent/profile), customer creating own request, 2FA settings updates, POST /storage/upload (ownership enforced in handler).
  • Deprecated endpoints: POST /accounts, PATCH /accounts/:id throw immediately.

These are documented explicitly in each section below.


Idempotency conventions

Write handlers follow one of two idempotency patterns.

1. Terminal-state short-circuit

Approvals and state transitions short-circuit if the resource is already in the target state. Reference implementations:

  • POST /operations/bookings/:id/ops-approvesrc/lib/api.ts:13582
    if (bookingSnapshot?.opsApprovalStatus === 'approved') {
      return { ok: true, alreadyApproved: true };
    }
    
  • POST /finance/bookings/:id/finance-approvesrc/lib/api.ts:13789
    if (currentNorm === requestedStatus) {
      return { ok: true, alreadyFinalized: true };
    }
    
  • POST /finance/journals/:id/approvesrc/lib/api.ts:22028 returns { ok: true, alreadyApplied: true } when already approved.

These patterns protect against double-clicks, network retries, and concurrent approvers.

2. Voucher idempotency key

Finance vouchers that post journal entries (customer on-account receipts, supplier refunds, debit/credit notes) hash (referenceType, partyId, amount, method, sourceAccount, reference, transactionDate) into an idempotency key, stash it in the ledger entry's notes field, and short-circuit if a prior entry with the same key exists:

const idempotencyKey = buildVoucherIdempotencyKey([
  'customer_on_account', customerId, amount.toFixed(2), methodNormalized, ...
]);
const existingReceipt = await findVoucherLedgerEntryByIdempotencyKey({ ... });
if (existingReceipt) {
  return { ok: true, ledgerEntryId: existingReceipt.id, deduplicated: true };
}

Reference: src/lib/api.ts:11316-11342 (customer on-account receipt).

3. Concurrency guards on approvals

Journal approve/reject/reverse use a conditional UPDATE (UPDATE ... WHERE status='pending' RETURNING ...). Two concurrent approves on the same entry: the second one matches zero rows, re-fetches the current state, and responds idempotently (or with 409 Conflict if the state moved to an unexpected terminal value). See src/lib/api.ts:22040-22060.

Concurrent reversals also guard against winning-race duplicates by re-querying after insert and rolling back the loser (src/lib/api.ts:21945-21975).

Maker-checker

Journal approvals, rejections, and reversals enforce segregation of duties: the user who created a journal cannot approve, reject, or reverse it themselves. The override permission finance.journals.approve_own (and finance.journals.reverse_own) bypasses this — reserved for super-admin break-glass.


Error shape

Every handler throws ApiError defined at src/lib/api.ts:58-67:

export class ApiError extends Error {
  status: number;   // HTTP-ish code
  data: unknown;    // Original payload (string | object | Postgres error)
  constructor(status: number, data: unknown) { ... }
}

Status codes used:

Status When thrown
400 Validation error (missing required field, invalid enum, invalid date, period-lock violation, balance-overdraft)
401 Missing or expired JWT
403 Permission denied (missing permission, maker-checker self-approval without override)
404 Resource not found, unknown route
405 Method not supported for this path
409 Conflict (duplicate passport, concurrent transition, already-reversed journal)
429 Rate limited (OTP verification lockout)
500 Unrecoverable DB or Postgrest error
501 Unmigrated endpoint (frontend calling a route the backend hasn't implemented yet)

Callers typically unwrap the message via err?.data?.message || err?.message.

Success responses are plain JSON — the handler's return value is serialized. There is no envelope (no { ok: true, data: ... } convention across the board); individual handlers sometimes include ok: true for void-ish actions.


How to add a new route

Follow this checklist in order:

  1. Design the route name.
  2. Use URL-style paths (/domain/resource/:id/action), not case keys.
  3. Match the module folder on the frontend (/sales/*, /finance/*, /hotels/*, etc.).

  4. Add the dispatch entry in _apiFetchInternal at src/lib/api.ts:27822.

  5. Keep the cascade's existing grouping (SALES, GROUPS, FINANCE, ADMIN, INVENTORY, etc).
  6. Regex-match ID patterns: /^\/resource\/([^/]+)$/.

  7. Implement the handler function.

  8. Named handle<Domain><Action> (e.g. handleFinanceJournals, handleSalesBookings).
  9. First line must be await requirePermission('x.y') for any write path.
  10. Read session user with getCurrentUserId() for audit + createdBy fields.
  11. Guard date inputs with assertAccountingPeriodAllowsPosting(entryDate) if the write touches JournalEntry / LedgerEntry.
  12. Write using requireOk<T>(supabase.from(...).insert(...).select('*').single()).
  13. Emit audit via logAudit(action, entityType, entityId, meta).

  14. Register the permission per CLAUDE.md:

  15. Add a row to docs/PERMISSIONS.md §6.
  16. If new, also §4 (catalog) and §5 (role grants).
  17. Add a Supabase migration under supabase/migrations/<timestamp>_description.sql:
    • INSERT INTO "Permission" (name, description) VALUES (...) ON CONFLICT DO NOTHING;
    • INSERT INTO "RolePermission" ... for each role that should hold it.
  18. Add the frontend gate: <PermissionGate permission="x.y"> around the UI control.

  19. Write tests.

  20. Unit test the handler against mocked supabase in src/lib/api.*.test.ts.
  21. If the handler posts journals, test that the debit/credit lines balance and that period locks are enforced.

  22. Run the full suite before committing.

  23. npm test — 570+ tests; the permissions drift test will catch any permission name that isn't in docs/PERMISSIONS.md.
  24. npx tsc --noEmit — must be clean before review.

  25. Document the route — add a row to the relevant sub-page of docs/api/*.md.

Period locks

Handlers that post journal entries must call assertAccountingPeriodAllowsPosting(date) (defined at src/lib/api.ts:164) before writing. Posting into a locked or closed period throws ApiError(400, { message: 'Period "..." is locked' }). This is enforced on every finance voucher handler and should be on any new one that writes JournalEntry.

The 501 Unmigrated endpoint escape hatch

If the dispatch cascade falls through without matching, the final throw at src/lib/api.ts:28235 reports 501 Unmigrated endpoint. This surfaces the old /api backend that was decommissioned — any UI code hitting an unknown path still gets a readable error so the missing migration is easy to find.