Operations screens API
Each operations screen loads with one request: a GET /screens/... route that
calls one database function and returns everything the screen shows first. The
rule is PRF-010.
Handler: handleOpsScreens in src/lib/api.ts. Client: src/services/opsScreenService.ts.
Database: supabase/migrations/20260930210000_operations_are_one_call.sql.
How access works
Every function is SECURITY INVOKER. Each read inside it runs as the signed-in
person, through the same row-level security as the separate reads it replaced.
Where one of those reads' routes asked for a permission first, the function asks
for the same one with auth_user_has_permission():
- the screen's own list — the call is refused (
42501, answered as 403), as the route was; - a side list the screen can live without — the section comes back
null, and the screen shows it empty, as it did when that route answered 403.
The anonymous key cannot call any of them. The functions write nothing.
The answers are shaped with the same code the separate routes use, so each part
of a screen equals what its separate route returned (src/lib/api.opsScreens.test.ts).
A database without these functions answers 501; the client then falls back to the separate routes.
Routes
| Route | Function | Refused without | Sections null without |
|---|---|---|---|
GET /screens/visa |
visa_list_screen |
visa.view |
lookups.visaGroups: visa.groups.view |
GET /screens/visa/:id |
visa_case_screen |
visa.view |
— |
GET /screens/visa-groups/:id |
visa_group_screen |
visa.groups.view |
— |
GET /screens/approvals |
approvals_screen |
what booking_page refuses |
— |
GET /screens/airline-blocks |
airline_blocks_screen |
inventory.view |
glAccounts: finance.view; currencies: admin.currency.view; drafts: inventory.create or inventory.edit; bookings: what booking_page refuses |
GET /screens/seat-releases |
seat_releases_screen |
inventory.view |
— |
GET /screens/inventory-holds |
inventory_holds_screen |
inventory.view |
— |
GET /screens/hotels |
hotels_screen |
hotels.view |
b2bHotelOffers: agents.view |
GET /screens/tickets/:id |
ticket_screen |
tickets.view |
— |
The airline blocks and hotels screens are refused without inventory.view and
hotels.view. Their pages already required them; the separate list routes did
not check.
GET /screens/visa
Query: q, status (database status, comma-separated), tripType, groupId,
cursor, limit (1–200, default 50), lookups=1.
Returns { data, nextCursor, total, totalIsEstimate, stageCounts, lookups? }:
data rows and nextCursor as GET /visa?paged=1; total is exact;
stageCounts counts every stage over the whole filtered queue, the stage filter
left out. With lookups=1: lookups.visaGroups (as GET /visa/groups) and
lookups.travelGroups (the active departures, as GET /groups). Sorted newest
first; the route takes no sort.
GET /screens/visa/:id, GET /screens/visa-groups/:id, GET /screens/tickets/:id
The same object as GET /visa/:id, GET /visa/groups/:id and GET /tickets/:id.
404 when the person cannot read the row. A ticket's docs is always empty:
TicketDocument was dropped in 20260401190000, and the old read always failed.
GET /screens/approvals
Query: the bookings list filters (q, status, financeStatus, opsStatus,
groupId, agentId, type, from, to), sort, dir (default asc),
cursor, limit, withTotal (default on), withStats (default on).
Returns a page as GET /sales/bookings?paged=1 plus stats, the tiles of
GET /sales/bookings/stats over the same filters.
GET /screens/airline-blocks
Query: blockId (optional — adds that block's manifest).
Returns { airlines, blocks, suppliers, offers, groups, bookings, blockPayments,
accounts, glAccounts, paidFromAccounts, currencies, drafts, manifest }:
| Key | As |
|---|---|
airlines |
GET /inventory/airlines |
blocks |
GET /inventory/quota-blocks |
suppliers |
GET /suppliers (active) — without the creditor-ledger check that route runs for each supplier |
offers |
GET /inventory/b2b-flights |
groups |
GET /groups?scope=all |
bookings |
every booking on a departure linked to a block, walked through booking_page (PLT-050): id, groupId, passengerCount, and each passenger's id, flightId, passengerCategory, needsTicket |
blockPayments |
credits and refunds against a block on active suppliers' accounts, as GET /suppliers/:id/transactions |
accounts |
GET /accounts |
glAccounts |
GET /finance/accounts |
paidFromAccounts |
GET /inventory/paid-from-accounts, only without finance.view |
currencies |
GET /admin/currencies |
drafts |
GET /inventory/quota-blocks/drafts |
manifest |
GET /inventory/quota-blocks/:id/manifest |
GET /screens/seat-releases, GET /screens/inventory-holds
Query: status (seat releases; all or comma-separated) / state (holds;
active by default, or all).
Return { releases, blocks } — the rows as GET /inventory/releases, and the
request form's live blocks — and { holds, pick } — the rows as
GET /inventory/holds, and the new-hold form's blocks, FITs, hotels, partners
and active departures.
GET /screens/hotels
Returns { hotels, groups, assignments, suppliers, accounts, exchangeRates,
b2bHotelOffers } as GET /hotels, GET /groups, GET /hotels/assignments,
GET /suppliers, GET /accounts, the exchange_rates in force today and
GET /b2b-hotel-offers. A hotel's allotment calendar is drawn from this answer.