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(atsrc/lib/api.ts:27822) is a giant cascadingif-ladder onroutePath. 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:
- It parses the
?query=stringout of the path to deriveroutePath. GETrequests for identical paths are deduplicated via an in-flight promise map — two concurrentGET /sales/bookingscalls share one underlying query.- Non-
GETcalls bypass the dedup map. - The request is dispatched by
_apiFetchInternalthrough the cascade atsrc/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 bookingsPOST /sales/bookings— create a bookingPATCH /sales/bookings/:id— update a bookingPOST /finance/bookings/:id/finance-approve— finance approval actionPOST /finance/journals/:id/approve— approve a journalPOST /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):
- UserPermission override —
allowed=falseblocks;allowed=truegrants. - Role-based grant via
RolePermissionfor any of the user's roles. - 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/:idthrow 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-approve—src/lib/api.ts:13582POST /finance/bookings/:id/finance-approve—src/lib/api.ts:13789POST /finance/journals/:id/approve—src/lib/api.ts:22028returns{ 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:
- Design the route name.
- Use URL-style paths (
/domain/resource/:id/action), not case keys. -
Match the module folder on the frontend (
/sales/*,/finance/*,/hotels/*, etc.). -
Add the dispatch entry in
_apiFetchInternalatsrc/lib/api.ts:27822. - Keep the cascade's existing grouping (SALES, GROUPS, FINANCE, ADMIN, INVENTORY, etc).
-
Regex-match ID patterns:
/^\/resource\/([^/]+)$/. -
Implement the handler function.
- Named
handle<Domain><Action>(e.g.handleFinanceJournals,handleSalesBookings). - First line must be
await requirePermission('x.y')for any write path. - Read session user with
getCurrentUserId()for audit + createdBy fields. - Guard date inputs with
assertAccountingPeriodAllowsPosting(entryDate)if the write touchesJournalEntry/LedgerEntry. - Write using
requireOk<T>(supabase.from(...).insert(...).select('*').single()). -
Emit audit via
logAudit(action, entityType, entityId, meta). -
Register the permission per
CLAUDE.md: - Add a row to
docs/PERMISSIONS.md§6. - If new, also §4 (catalog) and §5 (role grants).
- 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.
-
Add the frontend gate:
<PermissionGate permission="x.y">around the UI control. -
Write tests.
- Unit test the handler against mocked
supabaseinsrc/lib/api.*.test.ts. -
If the handler posts journals, test that the debit/credit lines balance and that period locks are enforced.
-
Run the full suite before committing.
npm test— 570+ tests; the permissions drift test will catch any permission name that isn't indocs/PERMISSIONS.md.-
npx tsc --noEmit— must be clean before review. -
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.