Permissions — Canonical Matrix
This file is the single source of truth for roles, permissions, and who-can-do-what. A drift test (
src/test/permissions.matrix.test.ts) parses this file and fails the build if the code uses a permission that isn't declared here, or if this file declares a permission no code references. If you add a page/section/action, update this file — the test will fail otherwise.
Last audited: 2026-09-18 (post-upgrade documentation pass; role and permission facts re-verified against the migrations and the drift tests)
1. How to use this document
- Adding a new page, section, or action? Add a row to the relevant section in §6. If the action introduces a new permission name, add it to §4 (catalog) and §5 (role grants).
- Changing who can do what? Update §5. Update the seed migration (
supabase/migrations/*seed*permissions*.sql). Runnpm testto confirm the drift test still passes. - Removing a permission? Remove every reference from code first (grep
permission="x.y",requirePermission('x.y')), then remove from §4/§5, then add aDROPmigration.
2. Architecture
Permission enforcement happens at three layers:
- Route-level (client) —
<ProtectedRoute requiredPermissions={[...]}>insrc/App.tsx. Gates whole pages. - UI-level (client) —
<PermissionGate permission="...">wrapping individual buttons/actions in component trees. - API-level (server) —
requirePermission('...')insrc/lib/api.tshandler functions. The authoritative check. Also backed by Postgres RLS policies on sensitive tables (Booking,Customer,Agent,Supplier,Payment,JournalEntry,JournalLine,FinanceConfig) viaauth_user_has_permission(perm_name).
The SQL function order of precedence (post 20260416020000):
1. explicit user-level deny (UserPermission.allowed=false) → denied
2. explicit user-level allow (UserPermission.allowed=true) → granted
3. role grant (RolePermission) → granted
4. otherwise → denied
No role-name bypasses. SUPER_ADMIN is the only role that holds every permission, and it holds them because every permission row is granted to it explicitly — not by a hardcoded short-circuit. A trigger grants each newly inserted permission to it as well. Every other role, CEO, GM, IT_ADMIN and ADMIN_HR included, holds only its bundle (§5), and apply_role_bundles() deletes grants outside that bundle. Revoking a specific row in the PermissionsMatrix UI actually restricts the holder. (ACC-011 / ACC-012, decided 2026-09-17 — see docs/rules/decisions-log.md.)
3. Role catalog
23 roles total — 1 super admin, 4 admin-tier, 15 functional staff, 1 field role (tour leader), 2 portal-scoped (partner/customer).
| Role | Tier | Purpose | Auto-grant? |
|---|---|---|---|
SUPER_ADMIN |
super-admin | The owner's administrator. The only role with every permission, including the break-glass journal overrides and destructive/system rights. Everyone else gets rights the super admin grants. | Seeded every permission, and every newly inserted permission, by 20260919100200_finance_role_bundles.sql |
CEO |
leadership | Chief executive; ultimate business authority | Leadership bundle: every *.view / *.export, business approvals, finance.supplier_transactions.correct (20260919100200) |
GM |
leadership | General Manager; operational head | Leadership bundle (20260919100200) |
IT_ADMIN |
admin | System administrator; tech owner — system settings only | Users, MFA reset, integrations, audit, currencies/cities, config (20260919100200) |
ADMIN_HR |
admin | HR/admin operations — no finance, approvals, invoices or permission editing | Seeded most permissions (20260115000000 …) minus the F1 revokes (20260919100200) |
SALES_MANAGER |
functional | Sales team lead | Sales bundle |
SALES_EXEC |
functional | Sales representative | Sales-minimal bundle |
B2B_MANAGER |
functional | B2B/partner account manager | Partner + sales bundle |
B2B_EXEC |
functional | B2B sales rep | B2B-minimal bundle |
OPS_MANAGER |
functional | Operations lead (groups, inventory) | Operations bundle |
OPS_EXEC |
functional | Operations staff | Operations-minimal bundle |
FINANCE_MANAGER |
functional | Finance lead | Finance + approvals bundle (no break-glass, ledger rebuild, year/period close) |
ACCOUNTANT |
functional | Bookkeeper — prepares, verifies other people's receipts, reconciles | Finance bundle without approvals, year close, configuration or numbering |
CHARTERED_ACCOUNTANT |
functional | External CA — reads finance, reports and the audit trail; prepares journals and supplier-transaction corrections; no cash handling | CA bundle (20260919100200) |
CASHIER |
functional | Records receipts only | finance.view, finance.payments.record, plus the general read bundle (bookings.view, customers.view, … ) — read-only everywhere else |
TICKET_MANAGER |
functional | Ticketing lead — owns the airline blocks (AIR §30) | Tickets + reports |
TICKET_EXEC |
functional | Ticketing executive — enters passengers, allocates seats, updates PNRs, edits blocks, sells seats on, records the airline's payment (AIR §30, FIN-044) | Ticketing-minimal bundle (20260929150000); no approvals |
VISA_OFFICER |
functional | Visa processing | Visa bundle |
AUDITOR |
functional | Read-only oversight | bookings.view, reports.export |
TOUR_LEADER |
field | The group leader / mutawwif who travels with a departure; sees only the groups assigned to them (TravelGroup.leadUserId), on a phone (FLD-001) |
field.view, field.checkin only (20260928170100) |
AGENT |
portal | B2B partner user (external) | Portal-only; no staff permissions |
CUSTOMER |
portal | End customer (external) | Portal-only; no staff permissions |
Custom roles can be created via the Admin → Permissions UI. They start with zero permissions and must be granted explicitly.
4. Permission catalog
Permission name format: <module>.<action> or <module>.<sub-resource>.<action>. Every permission here MUST be inserted into the Permission table via a seed migration, and MUST be referenced in code (otherwise the drift test fails).
4.1 Core staff modules
| Module | Permission | Meaning | Enforced at |
|---|---|---|---|
| agents (B2B partners) | agents.view |
List/read B2B partners | Route /agents, Sidebar |
agents.create |
Create new partner | <PermissionGate> on "Add Business Partner" |
|
agents.delete |
Permanently delete a partner (and their ledger) | <PermissionGate> on row Delete button, API DELETE — SUPER_ADMIN only since 20260919100200 listed it in fin_super_admin_only_permissions(); the earlier "IT_ADMIN only" of 20260430140000 no longer holds |
|
| communications | communications.send |
Send customer WhatsApp / email messages (single, bulk, scheduled); record contact consent / opt-out; read the outbound queue, which carries every recipient's contact details. Server-side in the whatsapp-send edge function and /operations/communications |
requirePermission + <PermissionGate> on send buttons; whatsapp-send guard; RLS on CommunicationQueue per command since 20260926230000 (no DELETE for anyone) |
| approvals | approvals.view |
See the approvals queue | Route /approvals, Sidebar |
approvals.approve |
Approve/reject/send-back bookings | <PermissionGate> in Approvals.tsx + server-side |
|
| bookings | bookings.view |
List/read bookings | Route /sales/bookings, /sales/bookings/:id |
bookings.create |
Create new booking (wizard, import) | Route /sales/bookings/new, API POST |
|
bookings.edit |
Modify booking fields, add passengers | <PermissionGate>, API PATCH |
|
bookings.delete |
Soft/hard-delete booking | <PermissionGate>, API DELETE |
|
bookings.cancel |
Submit a cancellation request for a booking or passenger (executes immediately for users who also hold bookings.cancel.approve) |
<PermissionGate>, API POST |
|
bookings.cancel.approve |
Approve or reject a pending cancellation request (the refund override is set here). Paying the refund out needs finance.payments.refund since F1 |
<PermissionGate>, API POST /cancel/approve, /cancel/reject |
|
booking.transfer |
Transfer a passenger to a different group/booking. Granted to CEO, GM, IT_ADMIN, ADMIN_HR, SALES_MANAGER, SALES_EXEC, B2B_MANAGER, B2B_EXEC, OPS_MANAGER. | <PermissionGate>, API POST |
|
| customers | customers.view |
List/read customers | Route /sales/customers, /people |
customers.create |
Create new customer | <PermissionGate>, wizard customer creation |
|
customers.edit |
Edit customer profile, documents | <PermissionGate>, API PATCH |
|
customers.delete |
Delete customer record | <PermissionGate>, API DELETE |
|
| finance | finance.view |
See Finance module / reports / vouchers | Route /finance |
finance.create |
Post journals and vouchers (payment, contra, notes, settlement, supplier refund and business partner receipts) | <PermissionGate> on the journal composer and Finance → Vouchers |
|
finance.edit |
Edit finance config, reconcile | RLS on FinanceConfig, Payment/Journal updates |
|
finance.payments.record |
Record a receipt against a booking or invoice (pending until verified), allocate an agent receipt | <PermissionGate> on "Add Payment" (booking page, Bookings list), Finance → Transactions "Record Payment" / "Pay", Finance → Vouchers receipt against a booking; API POST /finance/payments, /finance/payments/batch, /finance/vouchers/agent-receipt-allocate |
|
| accounts | accounts.delete |
Hard-delete a Chart-of-Accounts row (only when it has no transactions, children, or linked financial accounts) | API DELETE /finance/accounts/:id, RLS on Account |
| groups | groups.view |
List/read travel groups | Route /groups |
groups.create |
Create new group | <PermissionGate> on "Create Group" |
|
groups.edit |
Edit group, reassign passengers, link/unlink flights, misc expenses | <PermissionGate> + API PATCH/POST |
|
groups.delete |
Soft-delete group | API DELETE /groups/:id | |
| group_pricing | group_pricing.edit |
Edit the rate sheet on the group's Pricing tab (4 line items + tax lines) | API PATCH /groups/:id/pricing, <PermissionGate> on Pricing tab save |
| cancellation_policies | cancellation_policies.manage |
Create, edit and delete cancellation policies — bands, flat charges, percentages and kept items (PRC-020 / PRC-023) | <PermissionGate> on /admin/cancellation-policies, API POST/PATCH/DELETE /cancellation-policies, RLS on CancellationPolicy / CancellationPolicySlab / CancellationPolicyComponent, DB save_cancellation_policy |
| group_invoices | group_invoices.view |
See group invoices tab + individual invoices | Route (Invoices tab on group), API GET |
group_invoices.create |
Create a draft invoice from a group payer | <PermissionGate> on Create draft invoice (also Internal invoice and Supplementary), API POST |
|
group_invoices.edit |
Edit a DRAFT invoice (line items, taxes, due date) | <PermissionGate> + API PATCH (server rejects on ISSUED) |
|
group_invoices.issue |
Transition DRAFT → ISSUED (numbers and locks it; posts nothing); and Post to Finance — the revenue + tax voucher, pending a finance approver | <PermissionGate> + API POST /group-invoices/:id/issue and POST /group-invoices/:id/post |
|
group_invoices.cancel |
Cancel an ISSUED invoice (posts reversing credit-note journal) | <PermissionGate> + API POST /group-invoices/:id/cancel |
|
| hotels | hotels.view |
List/read hotels | Route /hotels, Sidebar |
hotels.create |
Create hotel/facility | <PermissionGate> |
|
hotels.edit |
Update hotel fields, contracts | <PermissionGate> |
|
hotels.delete |
Remove hotel | <PermissionGate> |
|
hotels.export |
Export/print hotel reports | <PermissionGate> on export buttons |
|
| food | food.view |
List/read food inventory | Route /food, Sidebar |
food.create |
Create food item, import CSV | <PermissionGate> |
|
food.edit |
Update food item, assign meals to a group | <PermissionGate> |
|
food.delete |
Remove food item | <PermissionGate> |
|
food.export |
Export/print food reports | <PermissionGate> on export buttons |
|
| incidents (duty of care) | incidents.view |
Read incidents, checklists, communication log, documents and the health details on them (INC-002, C360-003) | Route /operations/incidents, requirePermission; RLS on Incident, IncidentTraveller, IncidentChecklistItem, IncidentUpdate, IncidentDocument, TravellerStateEvent; storage bucket incident-documents |
incidents.manage |
Report an incident, assign the owner and family liaison, work the checklist, log calls and updates, upload documents, record costs, set hospitalised / missing / found / discharged | <PermissionGate>; DB open_incident, assign_incident_owner, update_incident_details, update_checklist_item, add_incident_checklist_item, add_incident_update, add_incident_document, set_traveller_state |
|
incidents.close |
Close an incident (every step done or not applicable) and reopen a closed one; also the management list recorded as notified for a Critical incident | <PermissionGate>; DB change_incident_status |
|
incidents.confirm_death |
Confirm a traveller deceased, or correct that record — management only, with the incident number typed back (INC-012) | requirePermission; DB set_traveller_state |
|
| inventory | inventory.view |
Access inventory module (airline blocks, FIT, ground, B2B) | Route /inventory/*, /suppliers |
inventory.create |
Create airline block / FIT / ground transfer / B2B offer | <PermissionGate> |
|
inventory.edit |
Edit inventory record; also archive/restore an airline block or FIT inventory (AIR §26) | <PermissionGate>, inline forms; DB archive_airline_block, restore_airline_block, archive_fit_inventory, restore_fit_inventory |
|
inventory.counters.repair |
Repair a drifted inventory counter to the value derived from the allocation records. Reading the drift report needs only inventory.view; applying a correction needs this, a written reason, and every counter changed is recorded in InventoryDriftRepair (AUD-010, INV-013) |
requirePermission on POST /inventory/drift/repair; DB inventory_repair_drift |
|
inventory.holds.manage |
Create, extend, convert and release a hold on airline-block, FIT, hotel-room or ground capacity (AIR §14, INV-011) | <PermissionGate>; DB create_inventory_hold, extend_inventory_hold, convert_inventory_hold, release_inventory_hold, expire_inventory_holds |
|
inventory.release.request |
Request a seat release, or withdraw your own request (AIR §16) | <PermissionGate>; DB request_seat_release, cancel_seat_release |
|
inventory.release.approve |
Approve or reject a seat release up to the seat limit in FinanceConfig.seatReleaseApprovalSeatLimit (default 10, ACC-030) — never your own request |
<PermissionGate>; DB approve_seat_release, reject_seat_release |
|
inventory.release.approve_large |
Approve a seat release above that limit (ACC-030: CEO/GM) | DB approve_seat_release (the seat count decides which permission applies) |
|
inventory.release.approve_own |
Approve or reject your own seat release request (owner, 26 Sep 2026: TICKET_MANAGER). The decision is marked selfDecided on the release and written to the audit trail; the seat limit still applies (ACC-030) |
DB approve_seat_release, reject_seat_release (20260930000000) |
|
inventory.release.file |
Record an approved release's airline filing (airline reference, actual penalty and refund). Opens the airline cancellation filing in pending_refund; finance settles the refund (CXL-030). SUPER_ADMIN, TICKET_MANAGER, TICKET_EXEC and every finance.create role |
DB complete_seat_release (20260930030000); POST /inventory/releases/:id/complete |
|
inventory.block_payments.record |
Record a payment to an airline for a quota block or FIT purchase — posted pending finance approval (FIN-044, FIN-032). The database applies the overpayment guard (INV-025), converts a foreign contract at its rate (FIN-034) and refuses a TDS supplier unless the caller also holds finance.tds.deduct (FIN-021) |
AirlineBlocks.tsx / FITInventory.tsx Post Payment (hasPermission); POST /inventory/quota-blocks/:id/finance-events and /inventory/fit/:id/finance-events (supplier_payment) and POST /suppliers/:id/transactions (a credit against quota_block / fit) send a holder without the route's own gate through DB record_airline_block_payment; GET /inventory/paid-from-accounts (DB airline_block_paid_from_accounts); the native app's Record payment. Money back from the airline: the web Record refund from the airline (AirlineBlocks.tsx, <PermissionGate>) → POST /inventory/quota-blocks/:id/airline-refund → DB record_airline_block_refund; the native app's Record refund |
|
inventory.b2b_cancellations.file |
File the cancellation of a third-party seat sale — seats, the refund to the buyer, the buyer's charge, optionally the supplier's side — for finance to decide (INV-014, FIN-032). Filing only: the decision is finance.cancellations.approve_b2b (decide_b2b_cancellation), never the filer's |
AirlineBlocks.tsx Cancel Sale / File Cancellation (hasPermission, either this or finance.create); POST /finance/b2b-cancellations sends a holder without finance.create through DB file_b2b_cancellation; the native app's Cancel this sale |
|
inventory.writeoff.approve |
Write seats off that were bought and never sold, so their cost leaves Stock-in-Hand and becomes a real expense (FIN-037). Needs a written reason; the approver is recorded and the row is immutable | requirePermission on POST /inventory/quota-blocks/:id/write-off-unsold; RLS on UnsoldSeatWriteOff |
|
| leads | leads.view |
List/read leads | Route /sales/leads |
leads.edit |
Update lead status, edit fields | <PermissionGate> in detail dialog |
|
| partners | partners.create |
Create partner (alt to agents) | Legacy; kept for RLS policies |
partners.edit |
Edit partner | Legacy alias | |
partners.approve |
Approve or reject a new business partner, or make active one never approved (PTR-095) | <PermissionGate>, POST /agents/:id/status, partner_set_status, trigger Agent_approval_guard |
|
partners.delete |
Delete partner | Legacy alias | |
partners.credit.override |
Take a partner over its credit limit (PTR-030) — only with a written reason of at least 3 characters, sent with the booking write; the database checks it, records it in the audit trail (partner_credit_override) and does not store it on the booking. Held by FINANCE_MANAGER, CEO, GM, SUPER_ADMIN (20261006120000) |
DB trigger "Booking_credit_limit" (bkg_credit_limit_guard) on every booking write; booking wizard (WizardShell.tsx, hasPermission) offers Override and save when the save is refused |
|
| quotations | quotations.view |
List/read quotations | Route /sales/quotations |
quotations.create |
Create new quotation; also used for edit/send/delete until split | <PermissionGate> |
|
| reports | reports.view |
Access analytics / reports pages | Route /reports |
reports.export |
Download/print any report | <PermissionGate> on export buttons |
|
| requests | requests.view |
See service requests queue | Route /requests |
requests.create |
Create a service request (ops-facing; customer/agent portals are self-service) | requirePermission in API |
|
requests.edit |
Update or add notes to a service request | requirePermission in API |
|
| suppliers | suppliers.create |
Add supplier | <PermissionGate> |
suppliers.edit |
Edit supplier, record transactions | <PermissionGate> |
|
suppliers.approve |
Approve or reject a new supplier (PTY-009) | <PermissionGate>, POST /suppliers/:id/decision, supplier_decide |
|
suppliers.delete |
Remove supplier | <PermissionGate> |
|
| tickets | tickets.view |
List/read tickets | Route /tickets |
tickets.edit |
Update passenger names (before the name deadline), request a ticketing exception, upload docs | <PermissionGate>; DB update_ticket_names, request_ticket_exception |
|
tickets.approve |
Issue a ticket; approve/reject an exception requested by someone else (maker-checker) | <PermissionGate>; DB issue_ticket, decide_ticket_exception |
|
| visa | visa.view |
List/read visa cases | Route /visa |
visa.create |
Create new visa case | <PermissionGate> |
|
visa.edit |
Update case status (VISA-003 transitions only), upload / soft-delete docs | <PermissionGate>; DB change_visa_status, add_visa_document, delete_visa_document; drive-upload kind visa_doc |
|
visa.groups.view |
List/read visa groups + drill-in | Route /visa/groups/:id |
|
visa.groups.edit |
Edit visa group code, status override, close | <PermissionGate> |
|
visa.intake.view |
List/read public visa intake queue | Route /sales/visa |
|
visa.intake.convert |
Convert intake → VisaCase (carries all fields + attachments, matches customer by passport); change intake status / reject | <PermissionGate>; DB convert_visa_intake, update_visa_intake |
| work (work inbox) | work.view | See the work inbox and your own items (WRK-001) | Route /work, Sidebar, requirePermission, RLS on every Work* table |
| | work.view.queue | See the queues you lead, and another person's list (PERF-007) | work_inbox_page scope check; tab visibility |
| | work.view.all | See everybody's work | work_inbox_page scope check; tab visibility |
| perf (performance) | perf.view.own | See your own performance numbers (PERF-007) | Route /performance, Sidebar, requirePermission; DB perf_require_view |
| | perf.view.all | See anyone's performance numbers; each look is audited (PERF-007, PERF-011) | person picker; DB perf_require_view, perf_people |
| | work.create | Log a job or type a task (WRK-002) | <PermissionGate> on "Log a job"; DB work_item_create |
| | work.claim | Claim a pool item, park it, answer it, put it back (WRK-006) | <PermissionGate>; DB work_item_claim / _release / _wait / _resume / _record_response |
| | work.close | Finish an item | <PermissionGate>; DB work_item_close |
| | work.assign | Hand work to a named person, see suggestions, cancel an item (WRK-007) | <PermissionGate>; DB work_item_assign, suggest_work_assignee, work_item_close(status=cancelled) |
| | work.reopen | Reopen a closed item | <PermissionGate>; DB work_item_reopen |
| | work.queues.manage | Name the members and leads of each work queue, set their weekly capacity points, take them off (WRK-013) | <PermissionGate> in Work → Queues; requirePermission; DB work_queue_member_save / work_queue_member_remove |
4.2 Admin module
| Permission | Meaning | Enforced at |
|---|---|---|
admin.view |
Access /admin and /people/employees |
Route guards |
Granted to every role that holds another admin.* right: IT_ADMIN, ADMIN_HR, CHARTERED_ACCOUNTANT (it needs /admin to reach the audit log), OPS_MANAGER, CEO, GM, AUDITOR, SUPER_ADMIN.
Legacy admin.edit kept for backwards-compat with existing RLS. New code should use the specific admin.* permissions defined in §4.4 below.
4.3 Cross-cutting (dashboard, chat)
| Permission | Meaning | Enforced at |
|---|---|---|
dashboard.view |
Show Dashboard nav item and KPIs | Sidebar nav filter |
chat.view |
Access internal chat | Route /internal/chat |
4.4 Granular sectional permissions (phase 1)
The coarse finance.view / admin.view gate the ROUTE. These sectional permissions gate individual sections inside those pages so an admin can e.g. hide Profit & Loss from an Accountant while still letting them see Trial Balance. A user needs BOTH the parent route permission AND the sectional permission.
Finance reports — each has independent view + export:
| Permission | Meaning |
|---|---|
finance.reports.trial_balance.view |
Trial Balance report |
finance.reports.trial_balance.export |
Export Trial Balance |
finance.reports.day_book.view |
Day Book report |
finance.reports.day_book.export |
Export Day Book |
finance.reports.balance_sheet.view |
Balance Sheet |
finance.reports.balance_sheet.export |
Export Balance Sheet |
finance.reports.profit_loss.view |
Profit & Loss |
finance.reports.profit_loss.export |
Export P&L |
finance.reports.group_profit_loss.view |
Group Profitability |
finance.reports.group_profit_loss.export |
Export Group P&L |
finance.reports.aging.view |
Aging Report |
finance.reports.aging.export |
Export Aging |
finance.reports.receivables_payables.view |
Receivables & Payables summary |
finance.reports.receivables_payables.export |
Export R&P |
finance.reports.gst_summary.view |
GST Summary |
finance.reports.gst_summary.export |
Export GST |
finance.reports.stock_status.view |
Stock Status (inventory valuation) |
finance.reports.stock_status.export |
Export Stock Status |
finance.reports.group_status.view |
Group Status |
finance.reports.group_status.export |
Export Group Status |
finance.reports.settlements.view |
Settlement Report |
finance.reports.settlements.export |
Export Settlements |
Finance — TDS (Tax Deducted at Source):
| Permission | Meaning |
|---|---|
finance.tds.view |
View TDS rate master and deduction ledger |
finance.tds.deduct |
Apply TDS on a supplier payment |
finance.tds.export |
Export the quarterly 26Q CSV |
Finance workflow — split from the coarse finance.create / approvals.approve:
| Permission | Meaning |
|---|---|
finance.payments.verify |
Verify a pending payment (never one you recorded — DB verify_payment) |
finance.payments.reject |
Reject a pending payment. finance.payments.verify also allows rejecting (route and DB verify_payment), so Reject shows for either |
finance.payments.refund |
Request a refund (capped at paid minus prior refunds — DB refund_payment / request_booking_refund) |
finance.refunds.approve |
Approve or reject a refund request someone else filed; approval posts the refund voucher (DB approve_refund) |
finance.journals.approve |
Approve (post) a pending voucher created by someone else (DB approve_journal_entries; direct UPDATE to approved is refused without it) |
finance.journals.approve_own |
Override: bypass maker-checker to approve/reject a journal YOU created. Super-admin only; every use is written to the audit trail |
finance.journals.reject |
Reject a pending voucher |
finance.journals.bulk_approve |
Bulk approve journals (also needs finance.journals.approve) |
finance.journals.reverse |
Post a reversal voucher. It is approved only by someone who made neither the original nor the reversal (ACC-020; DB approve_journal_entries since 20261006120000) |
finance.journals.reverse_own |
Override: bypass maker-checker to reverse a journal YOU created from the website's Reverse action (a screen-level check). Super-admin only |
finance.bookings.approve_finance |
Finance-tier booking approval |
finance.bookings.reject_finance |
Finance-tier booking rejection |
finance.cancellations.approve_airline |
Approve airline cancellation refund |
finance.cancellations.approve_b2b |
Approve / reject a B2B buyer cancellation filed by someone else (DB decide_b2b_cancellation) |
finance.allocations.agent_receipt |
Allocate on-account agent receipt |
finance.allocations.supplier_advance |
Allocate supplier advance |
finance.stock_adjustment.post |
Post opening/closing stock adjustment |
finance.ledger.rebuild |
Rebuild missing postings (DB rebuild_payment_postings), airline-block postings and manual ledger entries |
finance.ledger.reset |
Retired 2026-09-18: the "Clear All Ledger Data" wipe was removed (FIN-031). The route answers 410; posted vouchers can only be changed with service-role break-glass |
finance.config.edit |
Edit finance config: document prefixes (the numbering counters themselves are only moved by the database) and GL defaults |
finance.supplier_transactions.correct |
Correct or remove a supplier transaction whose voucher has been reversed (DB correct_supplier_transaction, reason required, audited). Held by ACCOUNTANT, CHARTERED_ACCOUNTANT, CEO, GM, SUPER_ADMIN |
finance.approvals.high_value |
Approve money above the tier-1 limit in ApprovalLimit (ACC-030 — customer refunds above ₹50,000, manual journals above ₹25,000 today). Held by CEO, GM, SUPER_ADMIN |
finance.rates.manage |
Add, change or delete an exchange rate, and refresh rates from the live feed (FIN-046). The only way to write exchange_rates: DB set_exchange_rate / delete_exchange_rate. Held by FINANCE_MANAGER, ACCOUNTANT, CEO, GM, SUPER_ADMIN (20261006100000) |
finance.approvals.high_value |
Approve money above the tier-1 limit in ApprovalLimit (ACC-030 — customer refunds above ₹50,000; above ₹25,000, every voucher a person writes: journals, contras, on-account receipts, reversals on custom lines). Held by CEO, GM, SUPER_ADMIN |
Finance period/year management:
| Permission | Meaning |
|---|---|
finance.periods.create |
Create accounting period |
finance.periods.lock |
Lock accounting period |
finance.periods.unlock |
Unlock period |
finance.periods.close |
Permanently close period (exec-level) |
finance.years.create |
Create financial year |
finance.years.close |
Close financial year (exec-level) — DB close_financial_year closes every period in the year and refuses while vouchers dated in it are pending |
Admin — split from the monolithic admin.view:
| Permission | Meaning |
|---|---|
admin.dashboard.view |
Admin landing dashboard |
admin.audit.view |
Audit log access |
admin.audit.export |
Export audit log |
admin.currency.view |
View currencies |
admin.currency.create |
Create currency |
admin.currency.edit |
Edit currency |
admin.currency.delete |
Delete currency |
admin.cities.view |
View service cities |
admin.cities.create |
Create city |
admin.cities.edit |
Edit city |
admin.cities.delete |
Delete city |
admin.locations.view |
India locations hierarchy |
admin.integrations.view |
View email/WhatsApp/SMS config — non-secret settings and "configured" flags only (DB integration_settings_public) |
admin.integrations.edit |
Edit integrations; set or clear secrets write-only (DB set_integration_secret) |
admin.integrations.test |
Send test messages (edge function integration-test) |
admin.config.edit |
System config (branding, numbering) |
admin.permissions.view |
View permissions matrix |
admin.permissions.edit |
Edit permissions matrix (privilege escalation — super-admin only) |
admin.users.view |
View staff users |
admin.users.create |
Create staff user |
admin.users.edit |
Edit a staff login — profile, username, email, pause / resume, document review. Not its roles since 20261003165000: a role changes only by a role request (§4.7, ACC-077) |
admin.users.delete |
Delete staff user (super-admin only) |
admin.users.reset_password |
Reset staff password |
admin.mfa.reset |
Clear another user's authenticator factor + backup codes (super-admin only) |
admin.roles.view |
View custom roles |
admin.roles.create |
Create custom role |
admin.roles.edit |
Edit custom role |
admin.roles.delete |
Delete custom role (super-admin only) |
Agents — split from coarse agents.*:
| Permission | Meaning |
|---|---|
agents.deactivate |
Soft-disable partner |
agents.credit_limit.set |
Set a partner's credit limit, on a new partner or an existing one — finance and leadership only (PTR-030; checked in the database by Agent_terms_guard and partner_staff_create, 20261008230000) |
agents.commission.set |
Set a partner's commission rate, on a new partner or an existing one — finance and leadership only (PTR-030; same checks) |
agents.access_key.manage |
Create/reset partner portal login |
agents.ledger.view |
Partner ledger access |
agents.ledger.export |
Export partner ledger |
agents.allocate_receipt |
Allocate on-account receipt to bookings |
4.4a Field (tour-leader app)
| Module | Permission | Meaning | Enforced at |
|---|---|---|---|
| field | field.view |
The groups I lead: manifest, rooming, itinerary, check-ins — no money. Route /field, fld_my_groups(), fld_group_manifest(), fld_group_checkins() (each also admits groups.view) |
<ProtectedRoute> + SECURITY DEFINER functions |
field.checkin |
Record a check-in for a group I lead (fld_record_checkin(); groups.edit may record one for any group) |
<ProtectedRoute> on /field/groups/:id/check-in, requirePermission, function |
4.4b Programme (the traveller's guide, native app)
| Module | Permission | Meaning | Enforced at |
|---|---|---|---|
| programme | programme.manage |
Write the step-by-step trip guide every traveller reads in the app (TRV-002): trv_upsert_guide_step(), trv_delete_guide_step(), and reading inactive steps. Since 20261008200000 also its parts and translations — trv_upsert_guide_part(), trv_delete_guide_part(), trv_save_guide_translation() — and approving plain text in another language (trv_guide_set_status(), TRV-018); reading the editor (trv_guide_editor()) and the preview (trv_guide_preview()). Native app only — there is no web screen for it, so the drift test lists it under ALLOWED_DOC_ONLY |
SECURITY DEFINER functions + RLS on TripGuideStep; the app's guide editor (apps/mobile/src/lib/permissions.ts) hides the screen |
| guide | guide.religious.approve |
Approve religious text in the guide — a verse or dua (its Arabic, transliteration and meaning in every language) and anything marked Sunni or Shia — so that it replaces the approved text travellers read (trv_guide_set_status(), TRV-019). A second person: a holder may not approve text they wrote or last changed (maker-checker). Opens the guide editor and preview read-only for a holder without programme.manage. The office gives it to named people it trusts for religious content. Native app only, so the drift test lists it under ALLOWED_DOC_ONLY |
trv_guide_set_status() (refuses religious text without it, and refuses its draftedBy); the app's More → Guide for travellers row and the Approve buttons hide |
The programme of a departure (activities, notices) needs no new permission: groups.edit writes it, the group's tour leader (field.checkin + TravelGroup.leadUserId) writes their own, and groups.view, the leader, the group's travellers and its partner read it (TRV-003, TRV-004). Programme templates (INV-009) need none either: groups.view reads them and groups.edit saves, applies, renames and deletes them. programme.manage is the traveller guide's permission, not the templates'.
4.5 Portal-scoped permissions (NON-STAFF)
Portal users (role AGENT or CUSTOMER) get zero staff permissions. Since 20260918100000_data_isolation_portals.sql (Wave 1A, ACC-001/002/010) their access is enforced in the database, not in the browser API layer (src/lib/api.ts):
- Reads — row-level security ownership policies. A customer reads only rows linked to their own
Customerrecord (Customer.userId = auth.uid(), never matched by email): their bookings (as customer or payer), passengers, payments, invoices, visa cases/documents, tickets, quotations, requests, group invoices. A partner reads only their agency (Agent.userId = auth.uid()): theirAgentrow, customers and bookings with theiragentId, seat sales, partner invoices and documents, offers they bought or reserved, and their statement ledger entries. Helpers:auth_is_staff(),auth_customer_ids(),auth_agent_id(),auth_agent_status();auth_reads_beyond_field()(staff with a permission outsidefield.*) guards the staff-wide reads a tour leader has no business on —User,UserRole,UserPermission, the ledger tables, chat, the communications log and WhatsApp (20260929170000). - Writes — portal users have no direct table writes. Every portal action is a
SECURITY DEFINERfunction that takes the actor from the session and checks ownership, status, amounts and prices (list below). Database refusals map to HTTP 403 (42501), 409 (23514) or 404. - Staff read business tables through
*.viewpermission sets (e.g.Bookingneeds any ofbookings.view,groups.view,finance.view,visa.view,tickets.view, …);is_staff_user()no longer includesAGENTor deactivated users.
Routes remain gated by allowedRoles:
- /customer/** → allowedRoles={['customer']}
- /partner/** → allowedRoles={['agent']}
Portal functions (execute: signed-in users; each checks its own actor):
| Function | Who | Checks |
|---|---|---|
customer_submit_payment |
Customer | Own booking, not cancelled/rejected; amount > 0 and ≤ outstanding (verified + pending counted); status always pending (FIN-032); receipt is a private storage key; double submit returns the first payment |
customer_respond_quotation |
Customer | Own quotation; status sent/pending; not expired; note appended |
customer_create_request / customer_respond_request |
Customer | Own booking / group is open or theirs / own request not closed; attachments are storage keys |
customer_update_profile |
Customer | Allow-listed fields; name, DOB and passport frozen once any visa case is past NOT_STARTED |
customer_agent_contact |
Customer | Their partner's name, company, phone, email only |
partner_register |
New login | PAN/GSTIN format, agreement accepted, AGENT role via self_assign_portal_role, Agent.status = 'pending', documents without public URLs (PTR-001) |
partner_update_profile |
Partner | Name and contact person only |
partner_create_booking |
Active partner | Price per passenger from the group rate sheet by category (or published B2B seat price); client prices ignored; credit limit; capacity; PENDING_OPS, on_account, createdBy = caller (PTR-020/021/030) |
partner_add_passengers |
Active partner | Own booking in DRAFT/PENDING_OPS/NEEDS_CORRECTION; rate sheet price; credit; capacity |
partner_reserve_beds |
Active partner | Atomic decrement of bedsAvailable; offer active and unreserved or theirs (PTR-040) |
partner_sell_seats |
Active partner | Offer bought by them; one seat counter (B2BFlightOffer.seatsAvailable) under row lock (PTR-041) |
partner_upsert_invoice |
Active partner | Own customer/booking; totals from line items; status DRAFT/ISSUED only; issued invoices immutable; atomic numbering (PTR-050) |
partner_create_request |
Active partner | Own customer or booking |
public_b2b_offers, partner_hotel_offers, partner_seat_inventory |
Partner | Offer lists without block cost, PNR, internal notes or other buyers (PTR-060) |
public_groups, portal_context |
Anyone | Open groups with published prices (no payer, lead staff or flights); caller kind for routing |
4.6 Leave, holidays and the employee agreement
Rules: 22 · Leave and the employee agreement (LV-001 … LV-062, EA-001 … EA-011). Seeded by 20261003094100. Every route is one SECURITY DEFINER function that takes the caller from the session, checks the permission and the routing, refuses a paused login and writes the audit row; the requirePermission() calls in src/lib/leave.ts only shape the screen (ACC-001).
| Module | Permission | Meaning | Enforced at |
|---|---|---|---|
| leave | leave.apply |
Apply for, report, withdraw and cancel one's own leave; read one's own balances, ledger and applications; attach one's own medical certificate. Every staff role; not TOUR_LEADER, AGENT or CUSTOMER (LV-001, LV-003) |
<ProtectedRoute> on /leave; leave_actor('leave.apply') in leave_apply, leave_preview, leave_my_dashboard, leave_my_ledger, leave_attach_certificate, leave_cancel; leave_employees() (accruals) |
| hr | hr.leave.approve |
HR's approval of every application (except an HR applicant's own, which their reporting manager decides — LV-031); decide a cancellation asked for on leave that has started; read anyone's leave and register; see everyone in the team view (LV-030, LV-033, LV-055) | leave_can_decide_hr() in leave_decide; leave_decide_cancellation; leave_may_read() in the row policies; leave_team, leave_register |
hr.leave.approve_peak |
Management's second approval for peak season, the notice period and leave without pay (LV-032, LV-040, LV-041); see everyone in the team view | leave_can_decide_mgmt() in leave_decide; leave_may_read(); leave_team |
|
hr.leave.admin |
Leave admin: settings, leave types, peak seasons, the employee's leave record (confirmation, notice, weekly off), comp-off credits, adjustments, the accruals by hand, year end, the register; cancel anyone's leave with a reason; record an absence older than the reporting window (LV-033, LV-042, LV-050 … LV-062) | <ProtectedRoute> on /leave/admin; leave_actor('hr.leave.admin') in leave_settings_save, leave_type_save, peak_season_save, peak_season_delete, hr_set_employee_leave_profile, leave_compoff_credit, leave_adjust, leave_run_accruals, leave_year_end_run, leave_hr_record_absence; leave_admin_screen, leave_year_end_preview, leave_register, leave_cancel |
|
hr.holidays.manage |
Add, change and remove holidays in the holiday calendar; confirm a tentative date (LV-020, LV-021). Reading the calendar needs only a staff login | leave_actor('hr.holidays.manage') in holiday_save, holiday_delete; holiday_list answers canManage |
|
hr.agreements.issue |
Prepare, issue and withdraw an employee agreement; read every agreement (EA-002) | agreement_prepare, agreement_issue, agreement_withdraw; row policy EmployeeAgreement select own or hr |
|
hr.agreements.countersign |
Countersign an agreement the employee has signed, never one's own; read the agreements the employee has signed (EA-005, EA-010) | agreement_countersign; the constraint EmployeeAgreement_not_self_check; row policy |
|
hr.agreements.approve |
Approve or reject an agreement HR prepared, before the employee sees it; never one's own or one they prepared (EA-017) | agreement_approve, agreement_reject; POST /agreements/:id/approve, /reject; <PermissionGate> on the agreement page; reads every agreement |
|
hr.agreements.view |
Read every employee agreement and where each employee's stands (EA-008) | <ProtectedRoute> on /hr/agreements; row policies on EmployeeAgreement and AgreementTemplate; agreement_get, agreement_hr_list |
|
hr.profiles.edit |
Edit the profile details of any staff login that is not an admin account (ACC-084): designation, department, reporting officer, joined on, father's / guardian's name, date of birth, gender, blood group, personal email, address, district, state, PIN, emergency contact, notes, Aadhaar / PAN last four. Not the login's full name or work phone, not the employee code, nothing about access (roles, email, username, password, MFA, pause, deactivate). An admin account holds roles.apply or admin.permissions.edit (IT_ADMIN, SUPER_ADMIN today); one's own row stays on My profile. Also lets HR's agreement issue save the appointment back (EA-013). Seeded by 20261003235500 |
profile_edit_rights() in admin_update_employee_profile, agreement_profile_block (→ agreement_issue); profileEdit on admin_user_profile and employee_record; PATCH /users/:id/profile |
4.7 Role requests
Rules: 08 · Access control (ACC-077 … ACC-080, ACC-082). Seeded by 20261003165000. A staff role — every role but CUSTOMER, AGENT and TOUR_LEADER — is added or removed only by a request that a CEO approves and an admin applies. Every step is one SECURITY DEFINER function that takes the caller from the session, checks the permission and the separation of duties, refuses a paused login and writes the audit row; the requirePermission() calls in src/lib/roleRequests.ts only shape the screen (ACC-001). The trigger UserRole_staff_role_lock refuses every other write of a staff role, whoever makes it (ACC-079).
| Module | Permission | Meaning | Enforced at |
|---|---|---|---|
| roles | roles.request |
Ask for a staff role to be added to or removed from a login, with a reason; withdraw one's own request; read every request. Also needed to choose a role when creating a login (ACC-082). An administrative role (one whose bundle controls users, access, settings or role changes — role_is_administrative()) is asked for, added or removed, only by someone who also holds roles.apply (ACC-083) |
role_change_request_create / role_change_request_create_as (admin-users create_user, assign_role, remove_role), role_change_withdraw; row policy RoleChangeRequest select involved or permitted |
roles.approve |
The CEO's approval: approve or reject (with a note) a request waiting for a CEO — never one's own request, never one about oneself (ACC-078) | role_change_ceo_decide; the constraint RoleChangeRequest_ceo_not_requester |
|
roles.apply |
The admin's decision: accept (the role changes, in the same transaction) or reject (with a note) an approved request — never one's own request, one about oneself, or one one approved. A role that carries admin.permissions.edit (SUPER_ADMIN) is applied only by someone who holds all of it. Held only by IT_ADMIN and SUPER_ADMIN (owner, ACC-083) — they alone may ask for an administrative role |
role_change_admin_decide — the only function the lock lets write a staff role; the constraint RoleChangeRequest_admin_separate |
5. Role → permission grants
Canonical grants (as seeded by migrations through 20260416020000). Legend: ✓ = granted, - = not granted.
5.1 Super admin, leadership & admin tiers
Since 20260919100200_finance_role_bundles.sql (owner decision 2026-09-17) the tiers are:
| Permission group | SUPER_ADMIN | CEO | GM | IT_ADMIN | ADMIN_HR |
|---|---|---|---|---|---|
every *.view and *.export |
✓ | ✓ | ✓ | admin.* only | most (no finance / invoices) |
business approvals — approvals.approve, bookings.cancel.approve, finance.refunds.approve, finance.journals.approve/reject/bulk_approve, finance.bookings.approve_finance/reject_finance, finance.cancellations.approve_airline/approve_b2b, group_invoices.issue/cancel, finance.periods.lock |
✓ | ✓ | ✓ | - | - |
finance.supplier_transactions.correct |
✓ | ✓ | ✓ | - | - |
finance.rates.manage — exchange rates (FIN-046, 20261006100000) |
✓ | ✓ | ✓ | - | - |
partners.credit.override (PTR-030, 20261006120000 — also FINANCE_MANAGER) |
✓ | ✓ | ✓ | - | - |
cancellation_policies.manage (20260923110000) |
✓ | ✓ | ✓ | - | - |
partners.approve, suppliers.approve — new partners and suppliers (PTR-095, PTY-009, 20261008120000) |
✓ | ✓ | ✓ | ✓ | - |
| finance write (create / edit / payments / TDS / periods / config) | ✓ | - | - | - | - |
| operational write (bookings, customers, groups, inventory, visa, tickets, communications…) | ✓ | - | - | - | HR's existing bundle minus finance / approvals / invoices |
system settings — users, MFA reset, integrations, audit, currencies, cities, admin.config.edit |
✓ | view only | view only | ✓ | users only (no integrations secrets, no MFA reset) |
duty of care — incidents.view / incidents.manage / incidents.close / incidents.confirm_death (file 13) |
✓ | ✓ | ✓ | - | - |
admin.permissions.edit, admin.roles.* (grant rights to others) |
✓ | - | - | - | - |
role requests (§4.7, 20261003165000) — roles.request / roles.approve / roles.apply |
all three | request, approve | request | request, apply | request |
break-glass finance.journals.approve_own / finance.journals.reverse_own |
✓ | - | - | - | - |
destructive / system — finance.ledger.rebuild, finance.ledger.reset, finance.years.close, finance.periods.close, accounts.delete, admin.users.delete, agents.delete |
✓ | - | - | - | - |
Only
SUPER_ADMINholds every permission (ACC-012), and a trigger grants it every permission added later. Everyone else — CEO and GM included — holds a bundle; anything more is granted by the super admin through the Permissions matrix and recorded in the audit trail. Nobody grants a role directly any more: a staff role is requested, approved by a CEO and applied by an admin (§4.7, ACC-077). The no-outranking rule (20260917150000) still governs permission overrides, and for roles it survives in one place — a role that carriesadmin.permissions.edit(todaySUPER_ADMIN) is applied only by an admin who holds every permission of it.Maker-checker overrides.
finance.journals.approve_ownandfinance.journals.reverse_ownlet the holder approve/reject or reverse a journal entry they created themselves, bypassing segregation of duties. They belong toSUPER_ADMINonly.FINANCE_MANAGER,ACCOUNTANTandCHARTERED_ACCOUNTANTmust route their own vouchers to a different approver.Duty of care (file 13). Incident records carry health data, so they are not part of the blanket super-admin bundle:
IT_ADMINandADMIN_HRare not grantedincidents.*by the seed (20260919130000_incident_permissions.sql). Management can grant them explicitly in Admin → Permissions if the duty roster needs it.Who is the super admin.
assign_super_admin()gives the role to the loginadmin@alhuda.co.in; if that user does not exist it falls back to everyone who currently holdsIT_ADMINso nobody is locked out, and says in a notice which rule applied. It never removes existing roles.Where the roles and grants come from.
20260921110000_dr_seed_roles_and_permission_grants.sqlinserts aRolerow for everyRoleTypevalue and re-applies this whole matrix as an explicit manifest. Before it, onlySUPER_ADMINandCHARTERED_ACCOUNTANThad a row created by a migration; the other 18 existed only because someone made them through the app. Every grant migration seeds withINSERT ... JOIN public."Role" r ON r.name::text = g.role_name, so on a database built from migrations alone — a fresh project, or a restore from backup — that join matched nothing and every grant for those roles was skipped in silence: 2 roles and 204 grants instead of 20 and 949.supabase/tests/access_model.sqlis the check; run it after every restore (seedocs/operations/backup-restore.md).Known inconsistency — IT_ADMIN and inventory.
20260920100200_inventory_drift_check.sqlgrantedinventory.counters.repairto CEO, GM, IT_ADMIN, ADMIN_HR and OPS_MANAGER, after20260919100200had already narrowed IT_ADMIN to system settings. IT_ADMIN therefore holds an inventory right it cannot reach, because it has noinventory.view. Left as it is deliberately: granting the view would widen the role past the owner's 2026-09-17 decision.access_model.sqlrecords this as its one documented exception to "whoever can act in a module can open it".
5.2 Functional staff (abbreviated — canonical bundles)
| Role | Grants |
|---|---|
SALES_MANAGER |
bookings. (cancellation requests* only — no bookings.cancel.approve, no refund payout), customers.create/edit/delete, quotations.create, leads.edit, groups.create, groups.edit, inventory.holds.manage, reports.export, group_pricing.edit, group_invoices.create, group_invoices.edit, visa.intake.convert, communications.send + bookings.view, customers.view, quotations.view, leads.view, group_invoices.view, visa.groups.view, visa.intake.view |
SALES_EXEC |
bookings.*, customers.create/edit, quotations.create, leads.edit, communications.send, inventory.holds.manage + bookings.view, customers.view, quotations.view, leads.view, group_invoices.view, visa.groups.view, visa.intake.view |
B2B_MANAGER |
bookings. (cancellation requests only — no bookings.cancel.approve, no refund payout), partners. (excl. delete), agents.create, customers.create, quotations.create, reports.export, inventory.holds.manage, group_pricing.edit, group_invoices.create, group_invoices.edit + agents.view, bookings.view, customers.view, quotations.view, group_invoices.view |
B2B_EXEC |
bookings.*, partners.edit, customers.create, quotations.create, inventory.holds.manage + agents.view, bookings.view, customers.view, group_invoices.view |
OPS_MANAGER |
bookings.view, bookings.edit, bookings.cancel, bookings.cancel.approve, suppliers., hotels., food., inventory., inventory.counters.repair, inventory.holds.manage, inventory.release.request, inventory.release.approve, inventory.writeoff.approve, groups.create, groups.edit, reports.export, approvals.approve, visa.groups.edit, visa.intake.convert, communications.send, incidents.view, incidents.manage, incidents.close + bookings.view, inventory.view, approvals.view, group_invoices.view, visa.groups.view, visa.intake.view. No journal approval and no refund payout (finance.payments.refund, finance.refunds.approve) |
OPS_EXEC |
bookings.view, bookings.edit, suppliers.edit, hotels.edit, hotels.export, food.edit, food.export, groups.edit, inventory.edit, inventory.holds.manage, inventory.release.request, visa.intake.convert, communications.send, incidents.view, incidents.manage + bookings.view, inventory.view, group_invoices.view, visa.groups.view, visa.intake.view |
FINANCE_MANAGER |
every finance.* except the super-admin-only set (finance.journals.approve_own, finance.journals.reverse_own, finance.ledger.rebuild, finance.ledger.reset, finance.years.close, finance.periods.close) and finance.supplier_transactions.correct; bookings.cancel, bookings.cancel.approve, approvals.approve, reports.export, group_pricing.edit, group_invoices.create/edit/issue/cancel, suppliers.edit, inventory.writeoff.approve + finance.view, bookings.view, approvals.view, group_invoices.view |
ACCOUNTANT |
finance.view, finance.create, finance.edit, finance.payments.record/verify/reject/refund, finance.journals.reverse, finance.supplier_transactions.correct, finance.rates.manage (20261006100000), finance.tds.view/deduct/export, finance.allocations., finance.stock_adjustment.post, finance.periods.create, finance report views+exports except P&L / balance sheet / group P&L, reports.export, group_invoices.create, group_invoices.edit, suppliers.edit + finance.view, bookings.view, group_invoices.view. No* approvals, refund approval, period lock/unlock/close, year close, config or numbering |
FINANCE_MANAGER |
every finance.* except the super-admin-only set (finance.journals.approve_own, finance.journals.reverse_own, finance.ledger.rebuild, finance.ledger.reset, finance.years.close, finance.periods.close) and finance.supplier_transactions.correct; bookings.cancel, bookings.cancel.approve, approvals.approve, reports.export, group_pricing.edit, group_invoices.create/edit/issue/cancel, suppliers.edit, inventory.writeoff.approve, partners.credit.override (20261006120000, PTR-030) + finance.view, bookings.view, approvals.view, group_invoices.view |
ACCOUNTANT |
finance.view, finance.create, finance.edit, finance.payments.record/verify/reject/refund, finance.journals.reverse, finance.supplier_transactions.correct, finance.tds.view/deduct/export, finance.allocations., finance.stock_adjustment.post, finance.periods.create, finance report views+exports except P&L / balance sheet / group P&L, reports.export, group_invoices.create, group_invoices.edit, suppliers.edit + finance.view, bookings.view, group_invoices.view. No* approvals, refund approval, period lock/unlock/close, year close, config or numbering |
CHARTERED_ACCOUNTANT |
finance.view, finance.create (prepare journals — always approved by someone else), finance.journals.reverse, finance.supplier_transactions.correct, finance.tds.view, finance.tds.export, every finance.reports.*, reports.view, reports.export, admin.audit.view, dashboard.view + bookings.view, customers.view, groups.view, group_invoices.view, agents.view, inventory.view. No payment recording or verification, refunds, bank/config edits, numbering, period unlock or year close |
CASHIER |
finance.payments.record + finance.view, and the general staff read bundle (bookings.view, customers.view, agents.view, groups.view, inventory.view, leads.view, quotations.view, requests.view, tickets.view, visa.view, approvals.view, dashboard.view, chat.view). No finance.create (journals, on-account receipts), TDS, opening balances or verification, and no write of any kind outside recording a receipt. The F1 bundles constrain finance.* exactly; the read bundle predates them and was left in place |
TICKET_MANAGER |
tickets.edit, tickets.approve, bookings.edit, groups.edit, reports.export, inventory.view, inventory.edit, inventory.holds.manage, inventory.release.request, inventory.release.approve (AIR §30: this role owns airline blocks; before 20260920110200 it held no inventory permission at all), inventory.block_payments.record (20260929150000: it pays for its blocks, finance approves — FIN-044), inventory.b2b_cancellations.file (20260929200000: it files a sale's cancellation, finance decides — INV-014) + tickets.view, bookings.view, groups.view |
TICKET_EXEC |
Exactly tickets.view, tickets.edit, bookings.view, bookings.edit, groups.view, inventory.view, inventory.create (20260930000000), inventory.edit, inventory.release.file (20260930030000), inventory.block_payments.record, inventory.b2b_cancellations.file, dashboard.view, work.view, work.create, work.claim, work.close (20260929150000, 20260929200000; asserted by supabase/tests/ticketing_pays_for_its_blocks.sql). AIR §30: the executive enters passenger info, allocates seats and updates PNRs (bookings.edit), edits blocks and sells seats on (inventory.edit), and records the airline's payment. No tickets.approve, no inventory.release.request / .approve, no inventory.create, no inventory.holds.manage — those stay with the manager |
VISA_OFFICER |
visa.create, visa.edit, visa.groups.edit, visa.intake.convert, communications.send + visa.view, visa.groups.view, visa.intake.view, bookings.view |
AUDITOR |
reports.export + all *.view (including incidents.view) |
Module view permissions (20260921110000).
reports.view,hotels.viewandadmin.viewexisted in no migration at all, andgroups.viewhad reached the leadership tier only, so the bundles above described rights their holders could not actually use — a role withhotels.editcould not open/hotels, and nobody outside leadership could open/groups. All four now follow one rule: whoever holds a right in a module also holds that module's view permission. On top of the bundles above that means
reports.view→ ACCOUNTANT, ADMIN_HR, B2B_MANAGER, CHARTERED_ACCOUNTANT, FINANCE_MANAGER, OPS_MANAGER, SALES_MANAGER, TICKET_MANAGER (everyone who holdsreports.export)hotels.view→ OPS_MANAGER, OPS_EXEC, ADMIN_HRgroups.view→ SALES_MANAGER, SALES_EXEC, B2B_MANAGER, B2B_EXEC, OPS_MANAGER, OPS_EXEC, FINANCE_MANAGER, ACCOUNTANT, VISA_OFFICERadmin.view→ IT_ADMIN, ADMIN_HR, CHARTERED_ACCOUNTANT, OPS_MANAGERplus CEO, GM, AUDITOR and SUPER_ADMIN, which hold every
*.viewalready.agents.deleteis the exception: it is destructive, so it stays with SUPER_ADMIN alone. Asserted byaccess_model.sql(ACC-049).Write permissions no operational role held (20260922100100). An end-to-end replay of a real 27-pilgrim Umrah departure found three permissions whose routes exist and whose RLS names them, but which no role that does the work held. Each grant below is justified by a route in
src/lib/api.ts:
groups.edit→ OPS_MANAGER, OPS_EXEC, SALES_MANAGER, TICKET_MANAGER (it was ADMIN_HR + leadership only). Nine routes need it:PATCH /groups/:id;POST,PATCHandDELETEon/groups/:id/flights[/:flightId];POSTandDELETEon/groups/:id/misc-expenses[/:id];POSTandDELETEon/groups/:id/documents[/:docId]; andPOST /sales/bookings/:id/move-group. Linking a flight to a departure is the one that mattered most — without it a departure's air cost never reached the books. TICKET_MANAGER owns airline blocks and PNRs (AIR §30), so the flight-link and PNR routes are its own; it also gainsgroups.view, which it lacked, under the module-view rule above.bookings.edit→ OPS_MANAGER, OPS_EXEC, TICKET_MANAGER. Operations could not assign a seat, a room, a meal plan or an airport transfer, and could not set a passenger's PNR (PATCH /sales/bookings/:id/passengers/:passengerId).suppliers.edit→ ACCOUNTANT, FINANCE_MANAGER. No finance role held it, so nobody in finance could record a supplier bill (POST /suppliers/:id/transactions), allocate against one, or correct a supplier record.Nothing the F1 finance-controls work revoked comes back. None of the three is a
finance.*permission, so none sits inside the exact finance familiesapply_role_bundles()rewrites, and none appears infin_role_revokes()for a role granted here.suppliers.editis deliberately not granted to CHARTERED_ACCOUNTANT (its bundle is an exact set — the grant would be reverted on the nextapply_role_bundles()) or to CASHIER (no write beyond recording a receipt, ACC-020/021). A CA who needs to record supplier bills is a segregation-of-duties decision for the owner, not a quiet widening here. Asserted bysupabase/tests/journal_write_paths.sql(FIN-076, FIN-077).
5.3 Portal roles
| Role | Staff-matrix grants |
|---|---|
AGENT |
none (portal-scoped via route allowedRoles + ownership) |
CUSTOMER |
none (portal-scoped via route allowedRoles + ownership) |
5.4 Work inbox (20260926220000)
work.* is granted by permission, never by role name; SUPER_ADMIN receives each new row through the Permission_grant_super_admin trigger.
| Permission | Granted to |
|---|---|
work.view, work.create, work.claim, work.close |
CEO, GM, SALES_MANAGER, SALES_EXEC, B2B_MANAGER, B2B_EXEC, OPS_MANAGER, OPS_EXEC, TICKET_MANAGER, TICKET_EXEC (20260929150000), VISA_OFFICER, FINANCE_MANAGER, ACCOUNTANT |
work.view only (read the list, touch nothing) |
CASHIER (ACC-021/ACC-044: receipts only), ADMIN_HR, IT_ADMIN (ACC-046: system settings only), AUDITOR |
work.view.queue, work.assign, work.reopen |
CEO, GM, SALES_MANAGER, B2B_MANAGER, OPS_MANAGER, TICKET_MANAGER, VISA_OFFICER, FINANCE_MANAGER |
work.view.all |
CEO, GM, AUDITOR |
work.queues.manage |
CEO, GM (20261008170000; named in their exact bundles in fin_role_bundle()) |
perf.view.own |
CEO, GM, SALES_MANAGER, SALES_EXEC, B2B_MANAGER, B2B_EXEC, OPS_MANAGER, OPS_EXEC, TICKET_MANAGER, VISA_OFFICER, FINANCE_MANAGER, ACCOUNTANT |
perf.view.all |
CEO, GM (PERF-007: leadership only; queue-lead team views wait for queue members) |
5.5 Field (20260928170100)
| Permission | Granted to |
|---|---|
field.view, field.checkin |
TOUR_LEADER (and SUPER_ADMIN through the trigger) |
Staff read check-ins through groups.view and may record one through groups.edit; they do not need field.*. Consent at booking (set_field_consent) needs bookings.edit or bookings.create, or being the booking's partner or customer.
5.6 Programme (20260928220000)
| Permission | Granted to |
|---|---|
programme.manage |
SUPER_ADMIN, and every role that holds groups.edit at the time the migration runs: CEO, GM, SALES_MANAGER, OPS_MANAGER, OPS_EXEC, TICKET_MANAGER, ADMIN_HR |
guide.religious.approve (20261008200000) |
SUPER_ADMIN only (the Permission_grant_super_admin trigger, and a backstop insert in the migration). No other role: the office grants it to named people as a user permission (Admin → Users → permissions) — TRV-019 |
The migration computes the list from groups.edit rather than naming roles, so a database where groups.edit was granted to another role by hand gives that role the guide too. Verified on a database built from migrations on 2026-09-25: the eight roles above. Live location (FLD-006) and passport submissions (TRV-005) add no permission: the traveller's own login, groups.view and bookings.edit decide.
5.7 Leave and the employee agreement (20261003094100)
| Permission | Granted to |
|---|---|
leave.apply |
SUPER_ADMIN, CEO, GM, IT_ADMIN, ADMIN_HR, SALES_MANAGER, SALES_EXEC, B2B_MANAGER, B2B_EXEC, OPS_MANAGER, OPS_EXEC, FINANCE_MANAGER, ACCOUNTANT, CHARTERED_ACCOUNTANT, CASHIER, TICKET_MANAGER, TICKET_EXEC, VISA_OFFICER, AUDITOR — not TOUR_LEADER, AGENT or CUSTOMER (LV-003 is open) |
hr.leave.approve, hr.leave.admin, hr.holidays.manage, hr.agreements.issue |
ADMIN_HR, SUPER_ADMIN |
hr.leave.approve_peak, hr.agreements.countersign |
CEO, GM, SUPER_ADMIN |
hr.agreements.view |
ADMIN_HR, CEO, GM, AUDITOR, SUPER_ADMIN |
hr.agreements.approve (20261008125500, EA-017) |
CEO, SUPER_ADMIN — the owner: "CEO only"; named in the CEO's exact bundle in fin_role_bundle() |
hr.profiles.edit (20261003235500, ACC-084) |
ADMIN_HR, SUPER_ADMIN |
fin_role_bundle() holds exact bundles for CEO, GM, IT_ADMIN, CHARTERED_ACCOUNTANT and AUDITOR, and apply_role_bundles() deletes anything outside them. The migration re-creates fin_role_bundle() with the new permissions in those bundles (CEO and GM: leave.apply, hr.leave.approve_peak, hr.agreements.countersign; IT_ADMIN, CHARTERED_ACCOUNTANT and AUDITOR: leave.apply; hr.agreements.view reaches CEO, GM and AUDITOR through their share of every *.view), so a later run of apply_role_bundles() keeps them. The exact-bundle tests for CASHIER and TICKET_EXEC now include leave.apply: it is self-service on one's own leave, not a business write, so it does not widen what those roles can do to money or tickets.
5.8 Role requests (20261003165000)
| Permission | Granted to |
|---|---|
roles.request |
ADMIN_HR, GM, CEO, IT_ADMIN, SUPER_ADMIN |
roles.approve |
CEO, SUPER_ADMIN |
roles.apply |
IT_ADMIN, SUPER_ADMIN |
fin_role_bundle() is re-created with them: CEO and GM no longer share one exact bundle (the CEO's adds roles.approve), and IT_ADMIN's adds roles.request and roles.apply, so a later run of apply_role_bundles() keeps them. ADMIN_HR is not an exact bundle and fin_role_revokes('ADMIN_HR') does not name them. Before this migration nobody held these; a staff role was changed by whoever held admin.permissions.edit (SUPER_ADMIN) through the UserRole write policies, which are dropped.
5.9 New partners and suppliers (20261008120000)
| Permission | Granted to |
|---|---|
partners.approve |
GM, CEO, IT_ADMIN, SUPER_ADMIN |
suppliers.approve |
GM, CEO, IT_ADMIN, SUPER_ADMIN |
The owner asked that new partners and suppliers be approved by "general managers, CEOs, or Admin only" (02/10/2026). Admin means IT_ADMIN and SUPER_ADMIN. fin_role_bundle() names both permissions in the leadership bundle (CEO, GM) and IT_ADMIN's, so apply_role_bundles() keeps them. ADMIN_HR, B2B_MANAGER and B2B_EXEC keep partners.edit (edit, suspend, deactivate, lift a suspension) but no longer approve a registration.
5.10 A partner's terms (20261008230000)
| Permission | Granted to |
|---|---|
agents.credit_limit.set |
FINANCE_MANAGER, CEO, GM, SUPER_ADMIN |
agents.commission.set |
FINANCE_MANAGER, CEO, GM, SUPER_ADMIN |
The owner decided on 03/10/2026 that a partner's credit limit and commission are set by finance and leadership only (PTR-030). Until then ADMIN_HR and B2B_MANAGER held both; the migration revokes them from every role outside the four above. fin_role_bundle() names both in the leadership bundle (CEO, GM), and fin_role_revokes() names them for ADMIN_HR, SALES_MANAGER and B2B_MANAGER, so apply_role_bundles() keeps it so. A permission given to one person (UserPermission) is not touched. Without the permission, a member of staff can still add and edit a partner; its commission and credit limit stay 0 (new) or unchanged (existing).
6. Page-by-page action matrix
This is the inventory of every user-triggerable action, the permission it requires, and where the enforcement lives. The drift test confirms that every permission string here actually appears in code, and vice versa.
6.1 Sales → Customers (/sales/customers)
| Section | Action | Permission | Enforcement |
|---|---|---|---|
| Route | View page | customers.view |
<ProtectedRoute> |
| Header | Sync customers | customers.edit |
(should gate — currently none) |
| Header | Export CSV | customers.view |
client-only |
| Header | Import CSV | customers.create |
<PermissionGate> recommended |
| Header | Create customer | customers.create |
<PermissionGate> |
| Row | Edit customer | customers.edit |
<PermissionGate> |
| Row | Delete customer | customers.delete |
<PermissionGate> |
| Profile | Upload doc to Drive | customers.edit |
server-side |
| Profile | Delete Drive doc | customers.edit |
server-side |
| Profile | View ledger | customers.view |
server-side scoping |
| Profile → Activity tab | View customer journey timeline | customers.view (or admin.audit.view) |
get_audit_timeline RPC re-checks in DB |
| Row / Profile | Open Customer 360 (/customers/:id) |
customers.view |
<ProtectedRoute> |
6.1a Customer 360 (/customers/:id) — C360-001/003
One page per customer, all facts computed by get_customer_360
(20260919120000). The database returns only what the caller may see (staff by
permission, a customer their own records — ACC-002/010) and lists what it left
out in restricted; the page hides the same sections.
| Section | Action | Permission | Enforcement |
|---|---|---|---|
| Route | View page | customers.view |
<ProtectedRoute> + requirePermission on GET /journey/customers/:id |
| Emergency banner | See that an incident affects a traveller (state only, no detail) | customers.view |
DB: traveller state only; incident detail needs incidents.view (C360-003) |
| Now / Journey / Readiness | Current trip stage, readiness checklist, next best action | bookings.view |
DB + canSeeSection |
| Where they are today | City, hotel, room, roommates, next movement | bookings.view |
DB + canSeeSection |
| Itinerary | Day-by-day flights, hotels, transfers, ziyarat | bookings.view |
DB + canSeeSection |
| Trips | All bookings with journey dots | bookings.view |
DB + canSeeSection |
| Travellers & family | Linked passengers, guardians | bookings.view |
DB + canSeeSection |
| Documents / Visa & tickets | Masked passport, expiry, visa and ticket status | bookings.view |
DB + canSeeSection |
| Files | The customer's documents, their visa documents and the live issued e-tickets/vouchers (GET /journey/customers/:id/files); open one in the viewer (drive-file) — ACC-074 |
customers.view (list); open: customers.view / visa.view / bookings.view by kind |
requirePermission + each part under its row security; drive-file per kind |
| Files | Upload a customer document (upload-customer-doc) |
customers.edit |
edge function |
| Files | A file the customer sent on WhatsApp shows From WhatsApp, its traveller and Awaiting review; OK / Reject (a reason) (POST /sales/customers/:id/documents/:docId/review → customer_document_review(), a paused login refused; COMM-035) |
customers.edit |
DB function + <PermissionGate>; a trigger refuses any other write of a document's source or review |
| Money | Paid / due from verified payments and group invoices (never Booking.balanceAmount) |
finance.view or bookings.view |
DB re-checks; section null + restricted otherwise |
| Requests & messages | Customer requests, message log, consent | requests.view (messages: customers.view) |
DB re-checks |
| Health & special needs | Restriction notice (no data recorded yet — HLT-003) | customers.view |
canSeeSection |
| Activity | Customer journey timeline | customers.view (or admin.audit.view) |
get_audit_timeline RPC |
6.2 Sales → Leads (/sales/leads)
| Section | Action | Permission |
|---|---|---|
| Route | View page | leads.view |
| Header | Create lead | leads.edit |
| Detail | Update status | leads.edit |
| Detail | Convert to quotation | quotations.create |
| Detail | Convert to booking | bookings.create |
6.3 Sales → Quotations (/sales/quotations)
| Section | Action | Permission |
|---|---|---|
| Route | View page | quotations.view |
| Header | Create quotation | quotations.create |
| Row | Edit | quotations.create (treat as edit, currently same perm) |
| Row | Send to customer | quotations.create |
| Row | Delete | quotations.create (destructive — consider quotations.delete future) |
6.4 Sales → Bookings (/sales/bookings) + Booking Detail (/sales/bookings/:id) + Wizard (/sales/bookings/new)
| Section | Action | Permission |
|---|---|---|
| Route (list) | View page | bookings.view |
| Route (detail) | View page | bookings.view |
| Route (wizard) | Create new | bookings.create |
| List header | Create booking | bookings.create |
| List header | Import bookings (CSV/XLSX) | bookings.create |
| List row | Print invoice | bookings.view |
| Detail / List | Edit booking — price, rates, GST, currency, customer, partner/source and travellers only while DRAFT, NEEDS_CORRECTION or REJECTED; contact details, notes and room choices any time (DB triggers "Booking_write_guard", "BookingPassenger_write_guard") — PRC-003 |
bookings.edit |
| Detail / List | Add passengers (DB: adding or removing a traveller needs bookings.create or bookings.edit, restrictive policies on BookingPassenger, 20261007100000) |
bookings.edit |
| Wizard (create / edit) | Override and save a partner booking that would take the partner over its credit limit — appears only after the save is refused; a reason is required and recorded (DB trigger "Booking_credit_limit", partner_credit_override in the audit trail) — PTR-030 |
partners.credit.override |
| Detail / List | Send message / WhatsApp | bookings.view |
| Detail / List | Add payment (POST /finance/payments, recorded pending — ACC-021, FIN-032) |
finance.payments.record |
| Detail / List | Request visa case | visa.create |
| Detail / List | Submit draft for approval (POST /sales/bookings/:id/submit → submit_booking) — LC-002 |
bookings.edit (DB: bookings.create or bookings.edit) |
| Detail / List | Readiness check (GET /sales/bookings/:id/readiness) — PAX-004/011/020 |
bookings.view |
| Detail / List | Send back (to ops) | approvals.approve |
| Detail / List | Resubmit after correction (returns to the stage that sent it back) | bookings.edit |
| Detail / List | Delete booking — DRAFT only, with no payments, carried money, transferred-in traveller, visa case or issued ticket; type booking number + reason (delete_booking, which also rejects or reverses the draft's vouchers; a browser hard delete is refused) — LC-021 |
bookings.delete |
| Detail / Wizard | Move booking to another group (POST /sales/bookings/:id/move-group → move_booking_to_group) — INV-002 |
groups.edit (DB: groups.edit, booking.transfer or bookings.edit) |
| Detail | Request cancellation (always a request; ACC-020) — request_booking_cancellation / request_passenger_cancellation record the requester; the browser cannot write a request |
bookings.cancel |
| Detail | Approve cancellation request — not the requester; refund override set here | bookings.cancel.approve |
| Detail | Pay a refund on a cancelled booking (POST /sales/bookings/:id/refund → request_booking_refund) — requested here, paid when finance.refunds.approve approves it |
finance.payments.refund (was bookings.cancel.approve) |
| Detail | Transfer passenger to another group, optionally at a new price with a reason (POST /sales/bookings/:id/passengers/:pid/transfer → transfer_passenger_to_booking) — LC-030, PRC-005. No discount limit yet (PRC-002 is open) |
booking.transfer (DB: booking.transfer) |
| Detail → Activity tab | View booking journey timeline (passengers, seats, rooms, visa, payments, vouchers) | bookings.view (or admin.audit.view) |
| Detail | Journey header — stage, branch, readiness checklist, next best action (GET /journey/bookings/:id → get_booking_journey) — JRN-001/003, INT-103 |
bookings.view (DB re-checks; a customer/partner sees only their own) |
| Detail | Customer ledger | customers.view |
| Detail → Flights | Assign / update / unassign a passenger's flight leg (seat, fare class, ticket no, PNR) | bookings.edit (DB: BookingPassengerFlight RLS) |
| Detail → Hotels | Assign / update / unassign a passenger's stay (room number, share group, dates, meal plan) | bookings.edit (DB: BookingPassengerHotel RLS) |
| Detail → Meals | Assign / unassign a passenger's meal plan | bookings.edit (DB: BookingPassengerMeal RLS) |
| Detail → Ground | Assign / unassign a passenger's airport transfer | bookings.edit (DB: BookingPassengerGroundService RLS) |
| Detail → Family / Wizard (after the booking is made) | See who is family on this booking (GET /families/booking/:id → booking_families) — PAX-036 |
bookings.view (DB) |
| Detail → Family / Wizard | "Who is family here?" — All one family (pre-fill from GET /families/booking/:id/prefill, then POST /families → family_save), Split into families, None are family (POST /families/status → travellers_set_family_status), Decide later. Optional; never blocks submission or approval — PAX-036 |
bookings.edit (DB: bookings.edit, or the partner who owns the travellers) |
| Detail → Family | Set a child's or infant's guardian — an adult on the same departure, any booking (POST /families/guardian → traveller_set_guardian) — PAX-021 |
bookings.edit (DB) |
6.5 Groups (/groups)
| Section | Action | Permission |
|---|---|---|
| Route | View page | groups.view |
| Header | Create group | groups.create |
| Header | Create from template (list page) | groups.create |
| Header → More | Clone group (detail page); the programme comes too (trv_clone_programme, which checks groups.create itself — INV-003, INV-008) |
groups.create |
| Header → More | Save as template (detail page) | groups.create |
| Card / Header → More | Delete group | groups.delete |
| Header → More | Print group report; Rooming list / Flight manifest / Meal count exports | groups.view |
| Print group report | Include money — each booking's total, paid and due, the group's totals, filter all / fully paid / balance due; the printout is marked internal (INV-007). Screen gate only: the figures are the ones the page already read | bookings.view or finance.view (<PermissionGate>) |
| Passenger | Mark group leader | groups.edit |
| Header | Edit group — name, codes, dates, capacity, status override, service charge per traveller (FIN-039; reason when the status or the charge changes) | groups.edit |
| Overview → Sales status | Stop selling / Reopen sales / Mark departed / Mark completed / Back to automatic (PATCH /groups/:id statusOverride, reason — INV-006) |
groups.edit |
| Overview → Group Leader / Passengers → a traveller | Name or remove the group leader — one of the group's own travellers (POST /groups/:id/leader → set_group_leader, audited — PAX-035) |
groups.edit (simple Yes/No) |
| Passengers → a traveller | Transfer to another group (the booking page's transfer dialog, POST /sales/bookings/:b/passengers/:p/transfer) — PAX-035, LC-030 |
booking.transfer |
| Passengers → a traveller | Remove from group → Cancelling their trip (the booking page's passenger cancellation request; approved by someone else with bookings.cancel.approve) or → Moving to another group (the transfer) — PAX-035, LC-020 |
bookings.cancel / booking.transfer |
| Passengers → bulk bar | Assign hotel / meal / ground service to the travellers picked (POST /groups/:id/travellers/place → place_travellers) — PAX-035 |
bookings.edit |
| Passengers → bulk bar | Link flight to the travellers picked (one seat request per traveller) | bookings.edit |
| Passengers → bulk bar | Request visa for the travellers picked (one case per traveller, POST /visa with bookingPassengerId) |
visa.create |
| Passengers → By family | See the departure's families, "Not family" and "Family not recorded"; readiness counts (not recorded, no guardian, family needs a head) — PAX-036, PAX-021 | groups.view (families read with the rights that read BookingPassenger) |
| Passengers → By family | New family / Edit family / Add to family / Treat this booking as one family (POST /families → family_save) — PAX-036 |
bookings.edit (DB) |
| Passengers → By family | Remove from family, optionally marking them not family (POST /families/:id/remove-member); Dissolve a family with a reason (POST /families/:id/dissolve) — PAX-036 |
bookings.edit (DB) |
| Passengers → a traveller | Mark not family / Undo (POST /families/status) — PAX-036; Set guardian for a child or infant (POST /families/guardian) — PAX-021 |
bookings.edit (DB) |
| Exports | Rooming list and flight manifest show each traveller's family and relationship — PAX-036 | groups.view |
| Overview → Tour Leader | Search who can be appointed (GET /groups/tour-leader-candidates?q= → tour_leader_candidates; employees, partner logins, traveller logins — FLD-007) |
groups.edit |
| Overview → Tour Leader | Appoint (POST /groups/:id/tour-leader → appoint_tour_leader: grants TOUR_LEADER and nothing else, sets leadUserId, audited, notified) |
groups.edit (simple Yes/No) |
| Overview → Tour Leader | Dismiss (DELETE /groups/:id/tour-leader → dismiss_tour_leader: clears leadUserId, removes TOUR_LEADER when no other group is led) |
groups.edit (simple Yes/No) |
| Booking dialog | Move whole booking to another group (move_booking_to_group, reason, capacity checked) |
groups.edit |
| Booking dialog | Drop whole booking from group (move_booking_to_group with no target, reason) |
groups.edit |
| Header | Assign existing booking to this group (move_booking_to_group) |
groups.edit |
| Itinerary tab | See the day-by-day planner, the Timeline and the traveller preview (GET /groups/:id/programme → trv_trip_programme) — INV-008 |
groups.view |
| Itinerary tab | Add, edit, duplicate, move up/down, move to another day and delete an activity (POST /groups/:id/activities, POST /groups/:id/activities/reorder, DELETE /groups/:id/activities/:activityId → trv_upsert_activity, trv_reorder_activities, trv_delete_activity, which check trv_can_write_group) — INV-008, TRV-003 |
groups.edit (<PermissionGate> + route + DB; simple Yes/No on delete) |
| Itinerary tab | Save programme as template; start from or add from a template; rename and delete a template (POST /programme-templates, POST /programme-templates/:id/apply, PATCH/DELETE /programme-templates/:id → programme_template_save, programme_template_apply, programme_template_update, programme_template_delete, which check groups.edit themselves) — INV-009 |
groups.edit (<PermissionGate> + route + DB; simple Yes/No on add-to-existing and delete) |
| Itinerary tab | See the templates and their items (GET /programme-templates → programme_templates(); RLS on ProgrammeTemplate, ProgrammeTemplateItem) — INV-009 |
groups.view |
| Itinerary tab | Edit planned stays — opens Edit group on the planned hotel stops and transfers | groups.edit |
| Flights tab | Link flight | groups.edit |
| Flights tab | Unlink flight | groups.edit |
| Hotels tab | Assign hotel | groups.edit |
| Ground tab | Assign transfer | groups.edit |
| Ledger tab | View this departure's own vouchers (GET /groups/:id/ledger, FIN-035) |
groups.view and finance.view — seeing a departure is not the same right as seeing its money |
| Pricing tab | View rate sheet | groups.view |
| Pricing tab | Edit line items + tax lines (save) | group_pricing.edit |
| Pricing tab | Refresh rates from inventory | group_pricing.edit |
| Pricing tab | Copy from another group — list the departures with a sheet and copy one onto this departure (GET /groups/:id/pricing/copy-sources, POST /groups/:id/pricing/copy; copy_group_pricing also needs groups.view, PRC-006). The phone's Copy pricing from another group on the group screen is the same right |
group_pricing.edit |
| Pricing tab | Compare with inventory cost — cost and margin beside each price, nothing changed (PRC-007) | group_pricing.edit |
| Pricing tab | Use cost as price (confirmed; saved only by Save) | group_pricing.edit |
| Invoices tab | View payers + their invoices | group_invoices.view |
| Invoices tab | Create draft invoice for a payer | group_invoices.create |
| Invoices tab | Edit draft invoice | group_invoices.edit |
| Invoices tab | Issue draft → ISSUED (numbered, locked; posts nothing) | group_invoices.issue |
| Invoice dialog | Post to finance — revenue + tax journal; says Nothing to post when the bookings already posted their revenue (FIN-036) | group_invoices.issue |
| Invoices tab | Create supplementary for issued invoice | group_invoices.create |
| Invoices tab | Cancel issued invoice (credit note) | group_invoices.cancel |
| Templates list | View templates | groups.view |
| Templates list | Instantiate (create group from template) | groups.create |
| Templates list | Save / delete template (GroupTemplate) |
groups.create (DB: GroupTemplate RLS) |
| Activity tab | View group timeline (bookings, passengers, inventory, visa, finance) | groups.view (or admin.audit.view) |
| Passengers tab | Traveller journeys + group readiness (GET /journey/groups/:id → get_group_journey) — JRN-001/003, INT-100 |
bookings.view (<PermissionGate> + DB) |
6.5a Journey board (/operations/journey) — JRN-003, INT-100/103
| Section | Action | Permission | Enforcement |
|---|---|---|---|
| Route | View board | bookings.view |
<ProtectedRoute> + requirePermission on GET /journey/board |
| Board | Groups in travel or departing within N days, readiness %, blockers, next best action | bookings.view |
get_journey_board returns [] without it |
| Dashboard widgets | Readiness blockers · departures next 30 days · travellers in travel by city | bookings.view |
journeyWidgets[].anyPermission + DB |
6.5a Operations → Duty of care (/operations/incidents)
Incidents (file 13). Reads need incidents.view — the same permission gates the health details on an incident (C360-003). Every action is a SECURITY DEFINER function that re-checks the permission, and every one is confirmed Yes/No (UX-001).
| Section | Action | Permission | Confirmation |
|---|---|---|---|
| Route | View the incident board (INC-020) and an incident's detail | incidents.view |
— |
| Header | Report incident (open_incident; Critical records the management escalation) |
incidents.manage |
simple |
| Drawer | Add update / log call / record a visit (add_incident_update) |
incidents.manage |
simple |
| Drawer | Upload a document to the company Shared Drive (drive-upload, kind incident_doc) and record it (add_incident_document); open one in the viewer (drive-file) |
incidents.manage (upload), incidents.view (open) |
simple |
| Drawer | Checklist step → in progress / done (update_checklist_item) |
incidents.manage |
simple |
| Drawer | Checklist step → blocked / not applicable, or undoing a finished step (INC-010 step 12) | incidents.manage |
reason |
| Drawer | Add a suggested task from the impact assistant (INT-141) (add_incident_checklist_item) |
incidents.manage |
simple |
| Drawer | Assign owner / family liaison / escalation contact (assign_incident_owner) |
incidents.manage |
reason |
| Drawer | Edit severity, place, restricted details, costs and who bears them (update_incident_details; recorded only, never posted to the ledger) |
incidents.manage |
reason |
| Drawer | Status → In progress / Resolved (change_incident_status) |
incidents.manage |
reason |
| Drawer | Close, or reopen a closed incident (change_incident_status; every step done or not applicable) |
incidents.close |
typed (incident number) |
| Drawer | Traveller state → hospitalised / missing / found / discharged (set_traveller_state) |
incidents.manage |
reason |
| Drawer | Traveller state → deceased, or a correction of it (set_traveller_state, INC-012) |
incidents.confirm_death |
typed (incident number) |
| Booking / group / Customer 360 | Open-emergency banner and traveller flags (incident_banner, group_traveller_states) |
incidents.view for the detail; bookings.view / groups.view / customers.view see only that an incident exists (C360-003) |
— |
| Pickers | Traveller and group search, assignable staff (incident_lookup, incident_assignable_staff) |
incidents.manage |
— |
| Dashboard | Incident widgets (open by severity, needing an owner or update, open critical) | incidents.view / incidents.manage |
— |
6.5b Field app (/field) — FLD-001 … FLD-005
| Section | Action | Permission | Confirmation |
|---|---|---|---|
| Route | My groups, one group's manifest / rooms / plan / log | field.view (own groups only; groups.view opens any group's check-ins on the Groups page) |
— |
| Group | Record a check-in (fld_record_checkin) |
field.checkin for a group I lead; groups.edit for staff |
— (a check-in is a record of fact; saved on the phone first, sent once) |
| Booking wizard / partner wizard / booking page | Record the travellers' answer on location sharing (set_field_consent) |
bookings.create / bookings.edit, or the booking's partner or customer |
simple (booking page switch) |
| Booking wizard / partner wizard / booking page | Record the booking's emergency contact (set_booking_emergency_contact, INC-005) |
bookings.create / bookings.edit, or the booking's partner or customer |
— (a fact about the booking) |
| Group → SOS tab | Read the emergency contact of every traveller on a group I lead, and call it | field.view for the group's leader; bookings.view / incidents.view for staff |
— |
| Groups → Overview | Last check-in and missing travellers (fld_group_checkins) |
groups.view |
— |
| Customer 360 | Latest check-in in "where they are today" | customers.view |
— |
6.5c Phone app (/m/**) — PLT-060
The same actions as the desktop screens, on the same routes and functions; nothing is gated more loosely on a phone.
| Section | Action | Permission | Confirmation |
|---|---|---|---|
| Route | /m, /m/more, /m/notifications, /m/approvals |
staff sign-in; each count and queue appears only for the permission it needs | — |
| Route | /m/bookings, /m/bookings/:id |
bookings.view |
— |
| Route | /m/customers |
customers.view |
— |
| Route | /m/groups (opens the field screen) |
groups.view |
— |
| Booking | Receive payment (POST /finance/payments, recorded pending — FIN-032) |
finance.payments.record |
reason (the receipt note) |
| Booking / Approvals | Approve the sales check (ops_decide_booking) or finance (finance_decide_booking) |
approvals.approve |
simple |
| Booking / Approvals | Send back (ops_decide_booking / finance_decide_booking with send_back) |
approvals.approve |
reason |
| Bookings | New booking (opens the wizard) | bookings.create |
— |
| Notifications | Switch alerts on or off for this phone or browser (push-subscribe) |
any signed-in user, own device only | — |
| Notifications | The inbox of kept notifications: read, mark read, mark all read, the unread count (AppNotification RLS, ntf_mark_read, ntf_mark_all_read, ntf_unread_count — TRV-010) |
any signed-in user, own rows only; no staff permission | — |
6.5d Native app (apps/mobile) — TRV-001 … TRV-014, FLD-006
One Expo app, three stacks chosen from the login (20 · The traveller's app). Every screen calls the database functions below; the app hides what the person may not do and the function refuses the rest. Nothing is gated more loosely than on the web.
| Stack | Screen | Action | Permission / function | Confirmation |
|---|---|---|---|---|
| Staff | Home | Counts and departures (badge_counts, dash_*) |
each only for the permission it needs, as on /m (§6.5c) |
— |
| Staff | Home → My dashboard | The website's role dashboards on the phone (UX-010, UX-011), opened from Home — never a button in the tab bar (UX-024): one tab per matched role profile, main first, with the website's suppression and priority (src/lib/dashboardProfiles.ts, copied byte for byte to apps/mobile/src/lib/dashboardProfiles.ts); each tab is one dashboard_screen call (PRF-002), as on /app |
no permission of its own: the Home row shows when the login's permissions match at least one profile detector (§6.18); each widget needs one of its own permissions (§6.18) and each tile its dash_* function's own check — a tile the database refuses is not drawn |
— |
| Staff | Dashboard → a widget's title or row | Opens the phone screen behind it (Approvals, Groups, Bookings, Visa, Tickets, Airline blocks, Seat releases, Leads, Partners, Finance, My receipts, Users; a departure opens its readiness, a row its booking, group, block, visa case, ticket sheet or task); no link where the phone has no screen | that screen's own permission, checked before the link is offered (e.g. Finance needs the finance jobs of financeAccess, Users admin.users.view); the screen checks again |
— |
| Staff | Home / Dashboard → Readiness | One departure: who is missing finance clearance, payment, visa, ticket or room (app_departure_readiness, INT-100); a row opens the booking; Call / WhatsApp the traveller or customer |
any of groups.view, approvals.approve, hotels.edit, inventory.edit, visa.edit, tickets.approve; amounts need finance.view or bookings.view, phones bookings.view or customers.view (null otherwise) |
— |
| Staff | Bookings, Booking | List and open a booking; receive a payment (record_payment, pending — FIN-032); approve or send back (ops_decide_booking, finance_decide_booking); record the emergency contact (set_booking_emergency_contact) |
bookings.view; finance.payments.record; approvals.approve (or finance.bookings.approve_finance); bookings.edit / bookings.create |
reason on send back and on the receipt note |
| Staff | Booking → Actions → Edit room and payment | The room choice and, on a direct booking, part or full payment (update_booking with only those fields and no travellers — PRC-003); not on a cancelled or transferred booking |
bookings.edit (checked in the function) |
— |
| Staff | Booking → Actions → Ask to cancel the booking; a traveller → Ask to cancel this traveller | A request only (request_booking_cancellation / request_passenger_cancellation stamp the requester — LC-020, ACC-020); not on a draft, nor while one waits |
bookings.cancel (checked in the function) |
reason |
| Staff | Booking → cancellation waiting → Reject | app_reject_cancellation (whole booking) — LC-020 |
bookings.cancel.approve (checked in the function) |
reason |
| Staff | Booking → Actions → Ask for a refund | When the cancelled travellers' refunds owe money: refund_preview then request_booking_refund (paid from the account the money came into; capped — PRC-030; someone else approves — CXL-003) |
finance.payments.refund (checked in the function; the preview needs finance view) |
reason |
| Staff | Booking → a traveller → Open visa case | The traveller's case (VisaCase RLS, then the case screen) — VISA-001. Opening a new case stays on the website |
visa.view |
— |
| Staff | Booking → Documents (app, and the desktop booking page's Documents card) | The issued e-ticket sheet and hotel voucher(s) of the booking (IssuedDocument RLS), open one (drive-file, kind issued) |
bookings.view |
— |
| Staff | Booking → Documents → Issue e-ticket | Render, store and record the company's e-ticket sheet for the booking (edge function issue-document, kind eticket → issue_document_record; TRV-013). Refused by name while any traveller ticketed by Alhuda lacks an issued ticket number |
tickets.view and bookings.view (both checked in the function) |
Yes/No; a re-issue says it replaces the travellers' sheet |
| Staff | Booking → Documents → Issue hotel voucher | One voucher per hotel stay of the booking (issue-document, kind hotel_voucher) |
hotels.view and bookings.view (both checked in the function) |
Yes/No |
| Staff | Find (Home box, More → Find anyone) | Search bookings (booking_page), customers (customers_list_page), partners (Agent) and groups (TravelGroup) at once; open the result's own screen (PTY-007) |
each section only with its view permission: bookings.view, customers.view, agents.view, groups.view; the reads' own checks and row security decide |
— |
| Staff | Customers, Customer | The customers list (customers_list_page) and one customer: code, contact, trips, money, the current trip's passport / visa / ticket, travellers, files (customer_360_screen + CustomerDocument RLS; a file opens through drive-file, kind customer_file) (PTY-007) |
customers.view (checked in customer_360_screen and drive-file); money needs finance.view or bookings.view and trips bookings.view (decided in get_customer_360) |
— |
| Staff | Customer, Booking travellers, Partner record, Find rows | Call and WhatsApp: open the phone's dialer and WhatsApp with the number shown (+91 for a ten-digit number); nothing is sent or recorded (PTY-008) | none beyond reading the record | — |
| Staff | Booking → Payments → Receipt (app, and the desktop booking page's Payments tab) | The receipt of a verified payment: the live one, or rendered, stored and recorded now (issue-document, kind receipt → issue_document_record; FIN-045); opened through drive-file (kind issued; IssuedDocument RLS lets finance.view read receipts). Refused for a payment that is not verified |
finance.view or bookings.view (checked in the function); a re-issue (reissue) is staff only |
— (a receipt does not change; nothing is replaced unless reissue is asked) |
| Staff, Partner, Traveller | A document's sheet → Share / WhatsApp | Share the PDF of an issued e-ticket sheet, hotel voucher or receipt from the phone; WhatsApp the customer a note (TRV-015). The file is fetched through drive-file first, so its checks decide |
whoever may open the document; nothing new on the server | — |
| Staff | Approvals | The tab — or, when the bar already has Bookings, Groups and Operations, a row on Home with the number waiting (UX-024): everything dash_approvals_inbox gives the person (ACC-020); shown to anyone holding one of the function's nine rights |
any of approvals.approve, finance.bookings.approve_finance, finance.payments.verify, finance.journals.approve, finance.refunds.approve, bookings.cancel.approve, finance.cancellations.approve_b2b, finance.cancellations.approve_airline, tickets.approve |
— |
| Staff | Approvals → Sales check / Finance check | Open the booking; approve and send back there | approvals.approve (or finance.bookings.approve_finance) |
simple / reason |
| Staff | Approvals → Receipts to verify | Verify / reject a receipt (verify_payment; never the recorder — FIN-032) |
finance.payments.verify (reject also finance.payments.reject) |
note optional / reason |
| Staff | Approvals → Vouchers | Open the voucher's lines; approve / reject (approve_journal_entries, approver ≠ maker, period lock). A voucher with pendingMeta follow-up steps is approved on the desktop only |
finance.journals.approve / finance.journals.reject |
note optional / reason |
| Staff | Approvals → Ticket exceptions | Approve / refuse (decide_ticket_exception, never the requester) |
tickets.approve |
reason |
| Staff | Approvals → Refunds | Approve / reject a refund someone else asked for (approve_refund: never the requester, capped at what can still be refunded — PRC-030, ACC-030 limit; approving posts the refund voucher and the negative payment); on approval the customer's refund_notice e-mail through mailer, best effort — CXL-003, #559 step 4 |
finance.refunds.approve (checked in the function) |
note optional / reason |
| Staff | Approvals → Cancellations | Open the booking; Reject the request (app_reject_cancellation, audited, the requester told — LC-020). Approving stays on the website |
bookings.cancel.approve (checked in the function) |
reason |
| Staff | Approvals → Airline and seat-sale refunds | Listed only; decided on the desktop | as the inbox | — |
| Staff | Home → My work; More → Tasks | The work inbox (work_inbox_page: Mine, To pick up; Team = queues / everything); Given by me (work_given_by_me) — WRK-001 … WRK-018 |
work.view; Given by me work.create; Team work.assign with work.view.queue / work.view.all |
— |
| Staff | Tasks → a task | Details and ledger (work_item_get); take it (work_item_claim); first answer / note (work_item_record_response); park / restart (work_item_wait / _resume); hand over (work_item_assign; list work_people, suggestions suggest_work_assignee); put back (work_item_release); finish (work_item_close done); cancel (work_item_close cancelled); reopen (work_item_reopen) |
take work.claim; answer / park / restart / hand over: the holder with work.claim, or work.assign; put back: the holder with work.claim; finish work.close (holder or work.assign); cancel work.assign; reopen work.reopen; suggestions work.assign |
reason on park, hand over, put back, finish, cancel, reopen |
| Staff | Tasks → New task | Type a task (work_item_create, WRK-018): title, details, due time, priority, a linked booking / customer / group / partner; hold it myself or leave it in the pool; give it to a colleague; the holder is told (AppNotification, a push from the server and an e-mail — WRK-018, COMM-041) |
work.create; hold it myself work.claim; give it to someone else work.assign; searching a record needs that record's view permission |
— |
| Staff | More → My performance; Tasks → chart | The scorecard (perf_scorecard), the jobs behind a figure (perf_scorecard_rows), choose a colleague (perf_people) — PERF-001 … PERF-012 |
perf.view.own; another person perf.view.all (each look audited, PERF-011) |
— |
| Staff | Home / More → Leads | The leads (Lead RLS), call / WhatsApp; change status (lead_set_status: lost / closed need a reason, converted stays — SAL-001) |
leads.view; leads.edit (checked in the function) |
reason on lost / closed |
| Staff | Home / More → Visa cases | The cases, by stage and departure, with a search (visa_phone_cases, VISA-032); the next forward step with a reason (change_visa_status, VISA-003 — the customer is e-mailed on issued / refused, VISA-005) |
visa.view; visa.edit |
reason |
| Staff | Visa cases → Case | The case, its documents (open: drive-file kind visa_file) and history (visa_case_screen); Add visa copy or a paper (drive-upload kind visa_doc, then add_visa_document — VISA-031) |
visa.view; visa.edit to add |
— |
| Staff | Home / More → Tickets → Departure | The departures and one sheet (ticketing_departures, ticketing_flight_sheet); enter one traveller's ticket number (issue_tickets); ask for an exception (request_ticket_exception) — AIR §24 |
tickets.view; tickets.approve; tickets.edit |
reason |
| Staff | Tickets → Departure → Paste from the airline | Paste the airline's list; it is read with the website's reader (src/lib/ticketPaste.ts, copied to the app and held equal by a test) — numbers in the order of the Ready travellers, or name and number pairs; the preview shows each number against its traveller, wrong ones in red, lines matched to nobody; Save sends every number in one issue_tickets call, all or nothing — the website's POST /tickets/flights/:id/issue (AIR §24, #559 step 5) |
tickets.approve |
reason (where the numbers came from) |
| Staff | Home / More → Receipts I recorded | The receipts this login recorded and their status (Payment RLS, createdBy = me) |
finance.payments.record |
— |
| Staff | Home / More → Finance | The finance area: my vouchers waiting (dash_my_journals), the queue's size (badge_counts). Shown to finance staff who write, approve or reverse vouchers — not to everyone who reads finance |
finance.view and one of finance.create, finance.journals.approve, finance.journals.reverse |
— |
| Staff | Finance → New entry | Write a journal / payment / receipt / contra voucher: type, date, narration, lines from the account picker (active leaf accounts in rupees; party ledgers by party code) → create_manual_journal: always pending, maker = caller, balanced to the paisa, no future date, open period, rupees only, idempotent for ten minutes, audited (FIN-032, ACC-030) |
finance.create (checked in the function — the same right as POST /finance/journals) |
Yes/No showing every line |
| Staff | Finance → New entry / voucher → Attach a bill or photo | Store the file on the company drive (drive-upload, kind voucher_attachment) and link it (JournalAttachment RLS) |
finance.edit (the edge function); the link finance.create or finance.edit |
— |
| Staff | Finance → Vouchers, voucher | The recent vouchers, mine or everyone's, by status (JournalEntry RLS); one voucher with lines, makers and files (finance_journal_screen); open a file (drive-file, kind file) |
finance.view |
— |
| Staff | Finance → voucher → Approve / Reject | approve_journal_entries; offered only on a pending voucher someone else wrote (the function refuses the maker unless finance.journals.approve_own); one with website follow-up steps is approved on the website |
finance.journals.approve / finance.journals.reject |
note optional / reason |
| Staff | Finance → voucher → Reverse | A posted hand-made voucher, not a reversal, not reversed yet, not by its maker (unless finance.journals.reverse_own, as on the website) → post_journal_reversal, pending, with the reason in the memo |
finance.journals.reverse |
reason |
| Staff | Finance → Account ledger | One account's statement between two days, opening and closing balance (finance_account_ledger) |
finance.view (checked in the function) |
— |
| Staff | Groups, Group | The departures; a group's people, rooms, plan, log, SOS (fld_group_manifest, fld_group_checkins); record a check-in (fld_record_checkin); edit the programme and post a notice (trv_upsert_activity, trv_delete_activity, trv_post_notice); where the sharing travellers are (fld_group_locations) |
groups.view to open; groups.edit to record, edit and post; groups.view for positions |
— |
| Staff | Group → Tour leader | See who leads, call them; Appoint / Change through the candidates search (tour_leader_candidates, chips Employees / Partners / Travellers); Dismiss (dismiss_tour_leader) — FLD-007 |
groups.edit (the functions check it) |
Yes/No |
| Staff | Group → Tour leader → Invite a new person | Create a login for someone who is not an employee, partner or traveller yet (edge function admin-users create_user with allowNonStaffEmail), then appoint them (appoint_tour_leader); the temporary password is shown once; a password-reset email is requested for them (auth-login reset); a WhatsApp note (never the password) goes through whatsapp-send when the inviter may send and the number is inside WhatsApp's 24-hour window |
groups.edit and one of admin.users.create, admin.users.edit, customers.create, customers.edit, partners.create, partners.edit (the edge function's own rule); communications.send for the WhatsApp note |
Yes/No |
| Staff | Group → Manifest | ⋯ → Make group leader (one traveller per group; not the tour leader) — PAX-035 | groups.edit · set_group_leader |
Yes/No |
| Staff | Group → Manifest | ⋯ → Transfer to another group (always a new booking on the target group, sent for approval; optional new price; reason) — LC-030, PRC-004/005, INV-020 | booking.transfer · transfer_traveller_to_group |
reason |
| Staff | Group → Manifest | ⋯ → Remove from group → Cancelling their trip (a request only; approved by someone else on the desktop) — LC-020 | bookings.cancel · request_passenger_cancellation |
reason |
| Staff | Group → Manifest | ⋯ → Remove from group → Moving to another group (opens the transfer) | booking.transfer · transfer_traveller_to_group |
reason |
| Staff | Group → Services | Place travellers into a hotel / meal plan / transfer (tick by booking; optional room-share label or meal plan) — PAX-035 | bookings.edit · place_travellers (the screen itself opens with groups.edit) |
Yes/No |
| Staff | Group → Manifest → By family | Families, "Not family", "Family not recorded" and the readiness line (fld_group_manifest carries them); New family / Edit family, Mark not family, Set guardian — PAX-036, PAX-021 |
groups.view to see; bookings.edit · family_save, travellers_set_family_status, traveller_set_guardian to change |
Yes/No |
| Staff | New booking → after it is made | "Who is family here?" — All one family / None are family / Decide later (optional) — PAX-036 | bookings.edit · family_prefill_from_booking, family_save, travellers_set_family_status |
— |
| Partner | Booking → Family | The booking's families and guardians; the same question; set a guardian — own travellers only — PAX-036, PAX-021 | the functions check the partner owns the travellers | — |
| Leader | Group → Manifest | No per-traveller actions | field.checkin only |
— |
| Leader | Group → Manifest → By family | The families, read only — PAX-036 | field.view for a group I lead |
— |
| Staff | More → Groups I lead | A member of staff appointed tour leader: the departures they lead (fld_my_groups), with or without groups.view; a group opens the tour leader's screen — FLD-007 |
TOUR_LEADER on the login; hidden for every other login |
— |
| Traveller | My trip → Groups I lead; Me → Groups I lead | The departures this traveller has been appointed tour leader of (fld_my_groups), the outbox of unsent check-ins; a group opens the tour leader's screen (§6.5b) — FLD-007 |
TOUR_LEADER on the login (field.view, field.checkin for a group I lead); hidden for every other traveller |
— |
| Staff | More → Scan a passport | Read a passport with the phone (extract-passport, or the MRZ on the phone) and attach it to a passenger as a pending submission (trv_submit_passport), or copy the fields |
bookings.edit (staff may submit for any passenger); the desktop reviews |
— (a submission changes nothing until applied) |
| Staff | Booking → passport submissions | Apply or reject a scan (trv_review_passport_submission) |
bookings.edit |
simple |
| Staff | More → Guide for travellers | Write, edit, deactivate and delete guide steps (trv_upsert_guide_step, trv_delete_guide_step); mark a step or part Everyone / Men / Women and Both / Sunni / Shia; add, change and delete parts — text, verse, dua (trv_upsert_guide_part, trv_delete_guide_part); write Urdu, Hindi, Kashmiri, Arabic by hand (trv_save_guide_translation); read everything with its status and approver (trv_guide_editor) — TRV-018 … TRV-021 |
programme.manage; the row also shows for guide.religious.approve (read-only editor) |
simple on delete |
| Staff | More → Guide for travellers → Approve / Discard the change | Approve the text waiting on a step or part in a language — it replaces the approved text travellers read (trv_guide_set_status approved): religious text (verse, dua, their meaning, anything Sunni or Shia) — TRV-019; plain text — TRV-018. Discard a waiting change (discard) |
religious: guide.religious.approve, and not the person who wrote or last changed it (maker-checker; Approve shown disabled with the reason); plain: programme.manage, one person; discard: programme.manage |
Yes/No on approve |
| Staff | More → Guide for travellers → Preview as | The guide as a traveller of a chosen language, tradition and gender reads it, with Show both (trv_guide_preview) — TRV-020, TRV-021 |
programme.manage or guide.religious.approve |
— |
| Staff / Partner | More → My profile | Own profile (profile_me); staff change their contact fields (profile_update_me) and file documents (upload-employee-doc → employee_add_document) — ACC-071; a partner changes its address and website (partner_update_my_profile) and adds, changes or removes its contacts (partner_contact_save / partner_contact_remove, own agency only) — PTR-086 |
any signed-in login; a paused one reads only (ACC-070) | simple on save |
| Staff | More → Users | Search logins with status, access and roles (admin_users_list); open the employee record (employee_record: profile, documents with review, access, activity — ACC-071); pause with a reason / resume (admin_pause_user / admin_resume_user) — ACC-070; review a document (employee_document_review) |
admin.users.view to see; admin.users.edit to pause, resume or review |
reason + Yes/No on pause; Yes/No on resume; note on a rejection |
| Staff | More → Role requests | The requests waiting for the CEO or an admin, mine, and history (role_change_requests); Approve / Reject (role_change_ceo_decide), Accept and apply / Reject (role_change_admin_decide) — offered only where the row says canApprove / canApply (ACC-077, ACC-078). Asking for a change and withdrawing one are on the website |
the row shows with any of roles.request, roles.approve, roles.apply; approve roles.approve; apply roles.apply |
Yes/No on approve and accept; reason on reject |
| Staff | More → Partners, Home → partners waiting | The partners the login may read (Agent rows, pending first; the pending count on Home); the partner record (partner_record — PTR-083) with the profile, contacts (Call / WhatsApp), performance and notes (PTR-084 … PTR-089); approve / reject a new partner (and approve a closed one never approved) with a reason — partners.approve (PTR-095); suspend / deactivate / reactivate with a reason (partner_set_status — the partner told); pause / resume the login; review a document (partner_document_review — PTR-080); Add note (partner_note_add — PTR-088). Editing the profile and contacts and filing a document for the partner are desktop-only |
agents.view to see; partners.approve to approve or reject a new partner; partners.edit for the rest |
reason on every status change; note on a rejection; Yes/No on a note |
| Staff | Partners → Add partner, Home → New partner, More → Add a business partner | Add a business partner with the desktop form's fields; pending until approved, sales@ told with the terms; a PAN or GSTIN already used needs "Add anyway" — named only to agents.view holders (partner_staff_create — PTR-097). Commission % and Credit limit show only with their permission, otherwise 0 (PTR-030). No portal login from the phone |
partners.create (the function checks it, and that the login is staff); agents.commission.set / agents.credit_limit.set for the terms |
Yes/No with the terms |
| Staff | More → Notifications | Alerts on this phone (push-subscribe, FCM); the inbox of kept notifications — read, mark read, mark all read, the unread count on the row (AppNotification RLS, ntf_mark_read, ntf_mark_all_read, ntf_unread_count — TRV-010) |
any signed-in user, own device; own rows only, no staff permission | — |
| Leader | More → Notifications | The same inbox and alert switch | own rows only | — |
| Leader | My groups, Group | The groups I lead (fld_my_groups), the manifest, check-ins (§6.5b) |
field.view, field.checkin for a group I lead |
— |
| Leader | Live | Where my group's sharing travellers are (fld_group_locations, realtime on TravellerLocation) |
field.view and TravelGroup.leadUserId = me |
— |
| Leader | More → The travellers' guide | The step-by-step guide the travellers read, for the trip type of the group I lead, in my own language and tradition (trv_guide); a trip type picker when no group is current |
the trip types of the groups I lead (TravelGroup.leadUserId) and of my own bookings, plus the steps for every trip — TRV-022; staff with programme.manage, groups.view or guide.religious.approve read every type |
— |
| Leader | Programme | Today's plan; add, edit and finish activities; post a notice (trv_upsert_activity, trv_post_notice, audience trv_group_audience → push-send) |
field.checkin and the group's lead |
— |
| Traveller | My trip | My bookings, group, leader, passengers, emergency contact, balance (trv_my_trips); SOS (calls only); the location switch (trv_set_location_sharing, then trv_report_location from the phone while on) |
CUSTOMER login linked to the customer (Customer.userId); own bookings only |
the switch says who sees it; off in one tap |
| Traveller | My trip (a passenger on someone else's booking) | The trip, programme, own passenger row and passport scan, the others by name, the emergency contact, own location switch, own documents (trv_my_trips role passenger, trv_trip_programme, trv_submit_passport, trv_set_location_sharing, trv_my_documents) — no money, no payment, no request naming the booking — TRV-001 |
CUSTOMER login whose customer record is a live passenger on the booking (auth_passenger_booking_ids()); no row policy widened |
— |
| Traveller | My trip → Money → Receipt | The receipt of a verified payment on my booking (issue-document, kind receipt, FIN-045); Open, Share |
my booking as customer or payer (checked in the function); a passenger on someone else's booking sees no payments and gets no receipt; no re-issue | — |
| Traveller | My trip → Flights and hotels, payments, the office | Every flight with its times and the hotel in each city (trv_trip_programme — no PNR); the payments recorded on the booking and their state (RLS Payment select customer); Call / Write to us (customer_create_request) — TRV-014 |
own bookings only; no staff permission | — |
| Traveller | My trip → Scan passport | Photograph a passport for one of my passengers → pending submission (trv_submit_passport) |
own booking's passenger | — (staff review it) |
| Traveller | Programme | The departure's flights, hotels, activities and notices without the PNR (trv_trip_programme, realtime on GroupActivity / GroupNotice) |
a live booking of mine on the group | — |
| Traveller | Guide | The step-by-step guide for my trip type in my language, for my tradition and gender, approved text only; Show both (trv_guide, realtime on TripGuideStep) — TRV-018 … TRV-021. Only the steps for every trip and my own live bookings' trip types; no picker; with no trip yet, the steps for every trip only — TRV-022 |
any signed-in user, for their own trips' types (trv_guide_readable_types()); approved, active steps |
— |
| Traveller | Me | Who is signed in, the guide's language and my tradition (trv_my_guide_preferences, trv_set_guide_preferences — own login only; no staff screen reads it — TRV-018, TRV-021), the location switch, notifications, what the company keeps (links to the privacy page), sign out |
own login | — |
| Traveller | Me → Notifications | The same inbox (a notice posted to my group is in it the moment it is posted — trv_post_notice keeps it) and the alert switch |
own rows only | — |
| Visitor | Welcome → Create an account | Create a customer account by email (customer-signup) or with a WhatsApp code (auth-login signup_verify) — a login only, no customer record until the first booking request (TRV-007, TRV-016, LC-006). A customer can also sign up with no app at all, by writing "sign up" to the business number on WhatsApp (TRV-017; the first password is set on the web page /customer/set-password, which the app opens) |
public sign-up; the new login is CUSTOMER and nothing more (written by the database: auth_customer_login_create / auth_signup_create_login); no staff permission |
privacy terms ticked |
| Visitor | Welcome → Browse tours without signing in; Tour | The departures on sale as posters (public_departures, public_groups) |
public — the anonymous key's allow-list (ACC-053); no sign-in | — |
| Traveller | Tours, Tour | The same departures, signed in | public functions | — |
| Traveller | Tour → Book this trip | A booking request naming the travellers, room preference and phone (customer_create_request, type group_interest, TRV-008) — never a booking |
the customer's own login (portal_require_customer); no staff permission |
the sheet says it is a request and that the office creates the booking |
| Traveller | Requests and quotes → Requests | My requests and the office's replies (RLS CustomerRequest select customer); a question, document, change or cancellation request (customer_create_request) |
own login; a booking named in it must be mine | — |
| Traveller | Requests and quotes → Quotes | The quotations sent to me (RLS Quotation select customer, QuotationLineItem select portal); accept or decline (customer_respond_quotation, TRV-009) |
own login; only my own, only sent / pending, only before validUntil |
Yes/No with the total on accept; a reason on decline |
| Traveller | Requests and quotes → Payments | The balance per booking (trv_my_trips); I have paid records a claim as pending (customer_submit_payment, FIN-032) |
own booking as customer or payer; no staff permission — finance verifies with finance.payments.verify |
the sheet says nothing moves money |
| Traveller | Requests and quotes → Payments → Pay online | A Razorpay order for one booking (edge function razorpay-order → online_payment_actor), Checkout on the phone, then the app watches OnlinePaymentOrder / Payment (RLS, own bookings) for the receipt razorpay-webhook records verified |
own booking as customer or payer; the amount ≤ Booking.balanceAmount; no staff permission |
the page says nothing is charged until confirmed inside Razorpay; closing it records nothing |
| Traveller | My trip → My documents; Me → My documents | The visa case and its files, the e-ticket record (no PNR), the hotel stays (no price) and the papers under my own record (trv_my_documents, TRV-011); open a file (edge function drive-file, a ten-minute signed link), including the issued e-ticket sheet on a ticket row and the issued hotel voucher on a hotel row (kinds ticket / hotel, TRV-013) |
own login — only what trv_my_documents() lists for me; staff with customers.view, visa.view or bookings.view may open any document through drive-file |
— |
| Traveller | Me → Remind me before each activity | Local programme reminders on this phone (expo-notifications; no server) | own phone; the phone's notification permission | — |
| Traveller | Me → My details | Read and change name, phone, date of birth, address (RLS Customer select own, customer_update_profile) |
own login; identity fields frozen once a visa case has started | — |
| Traveller | Me → Request account deletion | What is erased and what the law keeps; DELETE typed and the password; always a request to the office, never erased on the person's own action (account_deletion_status; edge function account-delete → account_delete_self, service role only) — AUD-025 … AUD-027 |
the own login only (a CUSTOMER or AGENT login; staff are refused); no staff permission | typed DELETE + password |
| Visitor | Welcome → I am a business partner → Register your agency | Create a pending partner account: the login unconfirmed, the User row, the AGENT role and a pending Agent row, and the confirmation email (partner-signup, PTR-082) |
public; the new login is AGENT and nothing more; no staff permission |
agreement ticked |
| Partner | Home | The agency, its status, outstanding against the credit limit, documents and what is missing, whether the login leads groups (partner_my_account) |
AGENT login linked by Agent.userId; own agency only |
— |
| Partner | Home / More → Documents | Upload a registration document to the company drive and record it (upload-partner-doc → partner_add_document, PTR-080) |
own agency; pending, active or suspended (a closed account is refused); never a link | the sheet says who sees it |
| Partner | Bookings, Booking | The agency's bookings, passengers, payments, emergency contact and consent (RLS Booking select agency and the agency policies) |
own agency only | — |
| Partner | Booking → Documents | The issued e-ticket sheet and hotel voucher(s) of the booking (IssuedDocument select: Booking.agentId = auth_agent_id()), open one (drive-file, kind issued; TRV-013). The partner cannot issue |
own agency's bookings only | — |
| Partner | Booking → Payments → Receipt | The receipt of a verified payment on the agency's booking (issue-document, kind receipt: made the first time it is asked for, then handed back — FIN-045); Open, Share, WhatsApp the traveller |
Booking.agentId = my agency (checked in the function); no re-issue |
— |
| Partner | Booking → emergency contact | Record who to call (set_booking_emergency_contact, INC-005) |
the booking's own side: Booking.agentId = my agency |
— |
| Partner | Booking → Send for approval | A booking the office sent back (Sent back for correction) goes back to the team that sent it back, with a note saying what was corrected (partner_resubmit_booking, PTR-023); the database's refusal (rejected, price changed, not ready, account paused) is shown in its words |
own agency's NEEDS_CORRECTION booking; portal_require_active_agent() — active, not paused |
the sheet asks for the note (3 characters or more) |
| Partner | New booking | Create a booking on account: rate-sheet price, credit limit, seats, PENDING_OPS (partner_create_booking, PTR-020 … PTR-031); then the emergency contact and set_field_consent (source partner_portal) |
portal_require_active_agent() — active partners only |
Yes/No with travellers and the estimated total (PTR-070) |
| Partner | Money | The statement (bookings, GroupInvoice select portal payer, Payment select agency, LedgerEntry select agency statement), the invoices, the claims waiting |
own agency only | — |
| Partner | Money / Booking → I have paid | Report a payment against one booking or one issued invoice, recorded pending (partner_submit_payment, PTR-081, FIN-032) |
own booking or invoice; not pending-only accounts' targets (they have none); finance verifies with finance.payments.verify |
the sheet says nothing moves money |
| Partner | Money → Pay online | Pick one of the agency's bookings with a balance, then the same Razorpay page (razorpay-order, online_payment_actor = partner; the receipt by razorpay-webhook) |
Booking.agentId = my agency; rupees only; no staff permission |
as above |
| Partner (web) | Payments / Booking → Pay online, I have paid | The same two actions on the web portal (/partner/payments; POST /portals/agent/payments/online-order, POST /portals/agent/payments) — PTR-093; Razorpay Checkout opens in the page |
as the two rows above | as above |
| Staff | — (deliberately none) | Staff never start an online payment (FIN-032, DECIDED 2026-09-30): online_payment_actor never answers staff and razorpay-order refuses a staff login with 403 "Staff don't pay online. Record the customer's payment instead." Staff record the customer's payment instead (Take payment / Record payment, record_payment, pending) |
no permission grants it — finance.payments.record records a receipt and starts nothing at Razorpay; a staff member who is the booking's own customer or payer pays as that customer |
— |
| Partner | More → Agency details | Change the trading name and contact person (partner_update_profile) |
own agency | Yes/No |
| Partner | More → Request account deletion | The same screen as the traveller's; always a request ("Business partner — the office decides"); once approved the agency's business record stays (AUD-026) — AUD-025 … AUD-027 | the own login only; no staff permission | typed DELETE + password |
| Partner | More → Requests | The agency's requests (RLS CustomerRequest select agency); a new one on one of its bookings (partner_create_request, PTR-051) |
active partner; the booking must be mine | Yes/No |
| Partner | Offers and seat sales → Hotels | The beds on offer to partners and the agency's own reservations, no contract cost (partner_hotel_offers, PTR-060) |
AGENT login; open offers for an active partner only, own reservations for any status |
— |
| Partner | Offers → Hotels → Reserve | Take beds off an offer for the agency in one statement (partner_reserve_beds, PTR-040); nothing posts — the office bills |
portal_require_active_agent(); the offer must be unreserved or the agency's own |
Yes/No with beds × price (PTR-070) |
| Partner | Offers → Seats | Flight seats on offer at the published price, no cost, PNR or buyers (public_b2b_offers); the blocks the agency bought with sold / remaining from the one counter (partner_seat_inventory, PTR-041) and its sales (RLS PartnerSeatSale select agency) |
portal_require_active_agent() for the offers; own agency for the blocks and sales |
— |
| Partner | Offers → Seats → Buy | The booking wizard with the offer: partner_create_booking with p_b2b_offer_id — seats off the offer, published seat price (infants 0), credit limit, PENDING_OPS, one transaction (PTR-020, PTR-030, PTR-031) |
portal_require_active_agent(); an offer with no buyer or bought by this agency |
Yes/No with travellers and the total |
| Partner | Offers → Seats → Sell seats | Record a resale from a block the agency bought against the one seat counter under a row lock (partner_sell_seats, PTR-041) |
portal_require_active_agent(); B2BFlightOffer.buyerAgentId = my agency |
Yes/No with buyer, price and total |
| Partner | Offers → My holds | The beds reserved and the seat blocks held, with a countdown to check-in / departure (the same two reads). No release and no expiry: no partner function releases a hold, and PTR-042 is open | own agency only | — |
| Partner | More → Groups I lead | When the same login holds TOUR_LEADER and leads a departure (PTR-004): the leader's screens (§3 rows above) |
field.view, field.checkin and TravelGroup.leadUserId = me; the login stays a partner (auth_is_staff() is false) |
— |
| Staff | Tab bar | At most five buttons: Home and More, and between them Bookings, Groups, Operations, Approvals in that order of priority; Approvals moves to a Home row when all four apply; Dashboard is opened from Home (UX-024) | each button its screen's permission: bookings.view; groups.view; Operations any inventory.*, hotels.*, food.* or suppliers.* permission, visa.view, tickets.view, or a seat-release right (inventory.release.*, finance.create); Approvals one of the nine inbox rights |
— |
| Staff | Operations (tab) | The week's deadlines on top; tiles for Airline blocks, Hotels, Food, Ground, Visa, Tickets with one line each; rows for Deadlines, Holds, Seat releases, Suppliers, Partner offers (UX-024). Numbers from app_operations_summary() and badge_counts |
the tab: as the Tab bar row; each tile and row its screen's permission (below) — with no tile only the rows; app_operations_summary answers each section only with its permission (inventory.view; releases also tickets.view / finance.view; deadlines also tickets.view; hotels.view; food.view) and refuses a login with none of inventory.view, hotels.view, food.view, tickets.view, finance.view; the deadline count is the unacknowledged rows only |
— |
| Staff | Operations → Deadlines | The radar in AIR §21's buckets, 7 / 14 / 30 days (dash_deadline_radar); the alerts raised (InventoryDeadlineAlert RLS) with who acknowledged — INT-120 |
inventory.view or tickets.view — what dash_deadline_radar and the InventoryDeadlineAlert row policy answer (the website's page asks for inventory.view) |
— |
| Staff | Operations → Deadlines → Acknowledge | acknowledge_inventory_deadline_alert — who and when recorded with the note; a second tap refused — AIR §22 |
inventory.view (the function takes inventory.view, tickets.view, inventory.release.request or inventory.holds.manage) |
the note: what is being done |
| Staff | Operations → Deadlines → Run the ladder | fire_inventory_deadline_alerts then escalate_inventory_deadline_alerts; each rung once — AIR §22 |
inventory.edit, as on the website (the functions also take inventory.release.request or tickets.edit) |
Yes/No |
| Staff | Operations → Holds | Active / Converted / Released / Expired / All (InventoryHold RLS, with the partner, customer, group, block, FIT, hotel and people's names) — INV-011 |
inventory.view, as /inventory/holds |
— |
| Staff | Operations → Holds → Hold seats | Airline block, FIT or hotel rooms; the position (inventory_resource_position); how many, 12–72 hours, for a name / partner / group, why → create_inventory_hold (refused above what is free — INV-012) |
inventory.holds.manage |
the form; the database's refusal shown |
| Staff | Operations → Holds → Extend / Convert / Release | extend_inventory_hold (48 hours more, capped; refused on a hold already past its end and for a new end not in the future — INV-012, 20261008235000; the phone hides Extend there), convert_inventory_hold (to the booking the hold names), release_inventory_hold; a second tap refused |
inventory.holds.manage |
reason |
| Staff | Operations → Hotels | The blocks by city, a block, the departures holding its rooms (HotelInventory, GroupHotelAssignment, service_cities RLS) |
hotels.view |
— |
| Staff | Operations → Hotels → New hotel block | The row and its purchase voucher in one call (create_hotel_lease → create_hotel_inventory + post_hotel_purchase_from_hotel; pending — FIN-030, FIN-032) |
hotels.create |
the card says what will be posted |
| Staff | Operations → Hotels → Block → Edit | Name, city, stars, distance, room type, beds per room, threshold, notes — never the cost fields (RLS hotel_perm_update) |
hotels.edit |
— |
| Staff | More → Suppliers, Operations → Suppliers | The list, a supplier, what they sell us, the ledger and balance (Supplier, SupplierTransaction, inventory RLS) |
inventory.view or any right Supplier select staff reads (no suppliers.view exists — §8 item 5); the ledger rows suppliers.edit, inventory.view or finance.view |
— |
| Staff | Suppliers → Add supplier (button and +) / Edit / Retire / Restore | The desktop form's fields; a duplicate is refused (RLS supplier_perm_insert, supplier_perm_update) |
suppliers.create; suppliers.edit |
— |
| Staff | Operations → Hotels → Block → Assign to a departure | Rooms of the block to an open departure inside its dates: rooms, beds, check-in and check-out (the shared nights offered), price per night → assign_hotel_rooms, the stay and its consumption voucher in one transaction, pending finance (FIN-030; nights checked by the database — INV-031, INV-032). The website's POST /hotels/assignments (#559 step 5) |
hotels.edit (checked in the function); the departure picker reads TravelGroup under RLS |
the sheet names rooms × nights × rate |
| Staff | Operations → Hotels → Block → a departure → Open / Place travellers / Take these rooms back | Opens the group; puts ticked travellers into this hotel (place_travellers, PAX-035); gives the rooms back and reverses the stay's voucher (unassign_hotel_rooms, the website's DELETE /hotels/assignments/:id). Changing a stay's rooms, rate or dates is not on the phone (it re-posts from the browser — FIN-030 "Not covered yet") |
groups.view; bookings.edit; hotels.edit |
Yes/No on take back, naming the rooms and travellers |
| Staff | Operations → Hotels → Block → Who is staying | Each traveller in the hotel by departure: name, booking, room or share label, dates — no passport, date of birth or phone (GroupHotelAssignment, BookingPassengerHotel, BookingPassenger, Booking RLS — the reads of GET /hotels/:id/passengers) |
hotels.view and bookings.view (the stays' row policy bph_select) |
— |
| Staff | Operations → Hotels → Block → Offered to partners; Operations → Partner offers → Hotel beds | The beds offered to partners: beds left, price per bed, dates, open / reserved / closed (B2BHotelOffer RLS). Read only — offering beds has no screen on the website either (INV-016 "Not covered yet") |
hotels.view and agents.view (what hotels_screen asks before it returns them) |
— |
| Staff | Suppliers → supplier → Seats to pay for | The supplier's live airline blocks and FITs; each opens its screen, where Payments to the airline records a payment through record_airline_block_payment (FIN-044) — the function the website's supplier screen uses for a payment against a block or FIT. Any other supplier payment, a bill and TDS stay on the website (FIN-030 "Not covered yet") |
inventory.view to list and open; recording inventory.block_payments.record |
as Record payment |
| Staff | Operations → Partner offers | The seat offers to partners (B2BFlightOffer with its block and buyer, RLS — the website's /inventory/b2b-flights, offers carved for a departure left out): Open / Closed / Cancelled / All, a search |
inventory.view (the row also shows for hotels.view with agents.view, for the beds) |
— |
| Staff | Operations → Partner offers → Offer seats | A live block, seats, price per seat, currency, notes → app_open_b2b_flight_offer: held seats and releases waiting are not offered (INV-011, INV-012), then the website's b2b_transfer_seats; audited; one offer per request id (INV-016) |
inventory.create (checked in the function) |
— |
| Staff | Operations → Partner offers → Close | app_close_b2b_flight_offer: partners can no longer buy from it; a sold, departure or cancelled offer refused (PTR-041, INV-014); audited with the reason (INV-016) |
inventory.edit (checked in the function) |
reason |
| Staff | Operations → Airline blocks | The quota blocks and the FIT seats as cards, Upcoming / All / Archived, search (AirlineQuotaBlock, FITInventory, Airline under RLS); a block: legs, the seat position with holds and pending releases (inventory_resource_position), the contract and what the airline is paid (AIR §15), deadlines (AIR §21), the departures using it (GroupFlight), its releases (SeatRelease, tap for Seat releases) and the airline's penalty bands (AirlineReleasePolicyBand) — read-only |
inventory.view |
— |
| Staff | Operations → Airline blocks → New block | Starts as a draft (AIR §36). Save draft keeps the form and posts nothing (create_quota_block_draft / update_quota_block_draft). Review and submit runs the desktop's checks (unique PNR, leg chronology, FOC ≤ seats, a rate on a foreign contract) and Yes calls submit_quota_block: the row and its purchase voucher in one transaction (create_quota_block + post_quota_block_purchase_from_block, FIN-030) — Dr Stock-in-Hand for the paid seats, Dr GST Input Credit, Cr the supplier's ledger, pending finance (FIN-032) — with a deposit paid now recorded through record_airline_block_payment (initial_payment, FIN-044); a refusal leaves the draft with the reason |
save a new draft and submit inventory.create; the deposit inventory.block_payments.record (the card shows only then) |
the review sheet shows the voucher and the deposit |
| Staff | Operations → Airline blocks → Drafts → Draft | What the submit would post and what is still missing (quota_block_draft_preview); Edit, Submit block, Discard draft (discard_quota_block_draft, a reason, audited) |
list, edit, discard inventory.create or inventory.edit; submit inventory.create |
Yes/No on submit; reason on discard |
| Staff | Operations → Airline blocks → Block → Edit | Flight, legs, PNR, supplier, payment due and free-release deadlines, notes — never the cost fields (a re-cost is the desktop's adjustment voucher); a table update under RLS airlinequotablock_perm_update |
inventory.edit |
— |
| Staff | Operations → Airline blocks → Block → Archive / Restore | archive_airline_block (a reason; refused while seats are allocated) and restore_airline_block (AIR §26) |
inventory.edit |
reason on archive |
| Staff | Operations → Airline blocks → Block → Payments to the airline | Paid so far, still owed and the payments with their voucher status (airline_block_payment_summary); the ledgers money may leave from (airline_block_paid_from_accounts) |
inventory.view or finance.view to read; the list inventory.block_payments.record or finance.view |
— |
| Staff | Operations → Airline blocks → Block → Record payment | Amount in the contract currency (outstanding prefilled), paid from, method, reference, date, note → record_airline_block_payment: Dr the supplier's SUP- ledger / Cr the account, the ledger mirror and the supplier transaction, pending finance (FIN-044, FIN-032); over the outstanding, a future date, an archived or cancelled block refused; a TDS supplier refused unless the caller holds finance.tds.deduct (FIN-021) |
inventory.block_payments.record |
Yes/No with the two lines |
| Staff | Operations → Airline blocks → Block → Record refund | Amount received (≤ paid so far), received into, method, reference, date, note → record_airline_block_refund: Dr the account / Cr the supplier's SUP- ledger, the supplier_refund mirror and the supplier's refund row, pending finance (FIN-044, FIN-032); above what was paid, a future date, an archived block refused; no TDS |
inventory.block_payments.record |
Yes/No with the two lines |
| Staff | Operations → Airline blocks → FIT seats → FIT | Read-only: flight, seats, contract, deadlines, notes (FITInventory RLS) — plus Payments to the airline with Record payment / Record refund as for a block (p_kind: 'fit') |
inventory.view; recording inventory.block_payments.record |
as above |
| Staff | Operations → Airline blocks → Block → Third-party sales | The seats sold on to other agencies — buyer, seats, price, status, invoice, names given, the latest cancellation filed / approved / rejected (block_third_party_sales) |
inventory.view or finance.view |
— |
| Staff | Operations → Airline blocks → Block → Sell seats to another agency | Buyer from the party ledgers (inv_party_accounts) or typed, seats ≤ open, margin per seat (GST-inclusive), currency, GST, a reason below cost, notes → sell_block_seats_to_third_party: the offer, the revenue and COGS vouchers (pending — FIN-032) and the buyer's invoice in one transaction (INV-014) |
inventory.edit |
Yes/No with the buyer's total, commission and GST |
| Staff | Operations → Airline blocks → Block → sale → Names | The buyer's traveller names, one per seat, replaced in one call (b2b_set_manifest, INV-015) |
inventory.edit |
— |
| Staff | Operations → Airline blocks → Block → sale → Cancel this sale | Seats (default all), refund to the buyer (default the sale value less the charge), the buyer's charge, a reason, optionally the airline's side (charge, refund, approval reference, notes) → file_b2b_cancellation: the seats move and one pending_approval row is written in one transaction; "Filed — finance decides" (INV-014). The decision is finance's (decide_b2b_cancellation, finance.cancellations.approve_b2b), never the filer's (FIN-032); a filing already pending is refused |
inventory.b2b_cancellations.file or finance.create |
Yes/No with the split and where the seats go |
| Staff | Operations → Seat releases | The releases (SeatRelease under RLS, with the block labelled "code · PNR · route"), Waiting approval / Approved / Completed / All; each shows seats, type, requested by, penalty and expected refund, the status, and "Decided by self" when selfDecided (AIR §16–§18, §30). The row shows with inventory.view or any release right |
read inventory.view, tickets.view or finance.view (RLS); the Operations row inventory.view, inventory.release.request, .approve, .approve_large, .file or finance.create |
— |
| Staff | Seat releases → Request a release (also Airline blocks → Block → Release seats, preselected) | A live block picked by "code · PNR · route", seats, type and deadline (both optional — auto from the preview), a reason; the live preview (preview_seat_release: seats free, penalty, expected refund, FOC impact, approval level) → request_seat_release |
inventory.release.request |
Yes/No naming who decides (manager, CEO/GM above the seat limit, or own under approve_own) |
| Staff | Seat releases → Approve / Reject | approve_seat_release (optional remarks) / reject_seat_release (a reason). The requester's own only with inventory.release.approve_own — the confirm says it will be marked "decided by self"; above the ACC-030 seat limit only with inventory.release.approve_large; otherwise not shown and refused in the database's words (ACC-020) |
inventory.release.approve or inventory.release.approve_large |
Yes/No |
| Staff | Seat releases → Withdraw | cancel_seat_release (a reason): a waiting request by its requester or an approver; an approved one before filing |
inventory.release.request or inventory.release.approve |
Yes/No |
| Staff | Seat releases → File | An approved release: the airline reference, the actual penalty and refund (prefilled from the expected; the variance is kept, AIR §20) → complete_seat_release; when money moves an airline cancellation filing opens pending_refund and finance settles the refund (CXL-030) |
inventory.release.file or finance.create |
Yes/No |
| Staff | Operations → Food | The meal contracts (FoodInventory RLS); one contract and the departures on it (GroupFoodAssignment RLS) |
food.view |
— |
| Staff | Operations → Food → New contract | Write the contract and post its purchase voucher and the caterer's bill from the database (create_food_inventory → inv_post_food_purchase_as) — FIN-030, FIN-034 |
food.create |
the sheet says what the caterer will be owed |
| Staff | Operations → Food → Edit | Change the contract (update_food_inventory); quantity refused, a total under the allocation refused — INV-042, INV-012 |
food.edit |
— |
| Staff | Operations → Ground transfers | The transfers (GroundTransferInventory RLS); one transfer and the departures on it |
inventory.view |
— |
| Staff | Operations → Ground transfers → New / Edit | Direct table writes under the row policies (groundtransferinventory_perm_insert / _update); nothing posts on creation |
inventory.create / inventory.edit |
— |
| Staff | Group → Services | The rooms, meal plans and transfers on a departure with the capacity each uses (assignment tables' RLS) | groups.edit to open |
— |
| Staff | Group → Services → Assign / Remove rooms | assign_hotel_rooms (derives the consumption voucher, calls create_hotel_assignment), unassign_hotel_rooms |
hotels.edit |
the sheet names rooms × nights × rate; Remove confirms |
| Staff | Group → Services → Assign / Remove a meal plan | assign_food_plan (fed / billed from the manifest or a typed head-count, INV-040, INV-042), unassign_food_plan |
food.edit |
the sheet names fed, billed and meal-days drawn; Remove confirms |
| Staff | Group → Services → Assign / Remove seats | assign_ground_transfer (seats + the expense voucher in one transaction), unassign_ground_transfer |
inventory.edit |
the sheet names seats × price; Remove confirms |
| Staff | Group (header and Operations) | The departure's phase (Closed — INV-006), dates and seats booked (TravelGroup RLS); Operations rows for Flights, Rooming, Readiness, a Ticket sheet per flight, Services and Notify group, each only with its own right (#559 step 3) |
groups.view for the header and Operations; each row its screen's permission (rows below) |
— |
| Staff | Group → Flights | The linked blocks and FIT tickets with PNR, date, seats free and how many travellers are on each (GroupFlight, AirlineQuotaBlock, FITInventory RLS; the count BookingPassengerFlight RLS) — INV-033 |
groups.view (a block's details need inventory.view; the count bookings.view — left out without it) |
— |
| Staff | Group → Flights → Link a flight / Add or Change PNR / Unlink | app_link_group_flight (a block or FIT within a day of the departure — INV-032; one departure per block or FIT; a block with fewer free seats than the departure needs refused — INV-012), app_set_group_flight_pnr, app_unlink_group_flight (refused while a traveller is on the flight); each audited — INV-033. The website's Flights tab writes the table from the browser instead (INV-033 "Not covered yet") |
groups.edit (checked in each function) |
Yes/No on link and unlink |
| Staff | Group → Flights → a flight → Travellers and seats | Who is on the flight, with PNR, ticket and seat (BookingPassengerFlight, BookingPassenger RLS); set or clear a traveller's seat number (a table update under bpf_update — the website's PATCH /sales/bookings/:b/passengers/:p/flights/:a). Putting a traveller on a flight stays on the website (it posts the seat's cost, FIN-035) |
groups.view and bookings.view to see; bookings.edit to set |
— |
| Staff | Group → Flights → a flight → Ticket sheet; Group → Ticket sheet | Opens that flight's ticket sheet (ticketing_flight_sheet, row above) |
tickets.view |
— |
| Staff | Group → Rooming | Each hotel stay's rooms and who is in each, as the website's rooming-list export groups them (GroupHotelAssignment, BookingPassengerHotel, BookingPassenger RLS) — INV-030; set or clear a traveller's room number (a table update under bph_update — the website's PATCH /sales/bookings/:b/passengers/:p/hotels/:row) |
groups.view and bookings.view to see; bookings.edit to set |
— |
| Staff | Group → Flights → Share flight manifest; Group → Rooming → Share rooming list | Plain text to the phone's share sheet: names, rooms, seats, PNRs and ticket numbers — never a passport number or a date of birth. Read as the website's exports read | groups.view and bookings.view |
— |
| Staff | Group → Notify group (the send button in the header, or the Operations row) | A notice in every traveller's app and a push — the same sheet and functions as Programme → Post a notice (trv_post_notice, trv_group_audience → push-send) — TRV-004 |
groups.edit (trv_can_write_group) |
— |
| Staff | Group → Readiness | Opens Readiness (row above) | as Readiness | — |
6.6 Approvals (/approvals) + Corrections (/corrections)
| Section | Action | Permission |
|---|---|---|
Route /approvals |
View queue | approvals.view |
Route /corrections |
View queue | approvals.view (currently bookings.view — drift; see §8) |
| Row | Approve booking — ops (ops_decide_booking, approver ≠ creator) |
approvals.approve |
| Row | Reject booking — finance (finance_decide_booking, approver ≠ creator) |
approvals.approve or finance.bookings.reject_finance — the route checks the same pair as the DB (20261008140000) |
| Row | Send back for correction | approvals.approve (finance's send-back: or finance.bookings.reject_finance) |
| Row | Log communication | approvals.view |
| Row | Assign correction owner (assign_booking_correction, NEEDS_CORRECTION or REJECTED only) |
approvals.approve — checked in the DB function since 20261008140000; the browser's row write needed bookings.edit |
| Row | Resubmit corrected | bookings.edit (a partner resubmits its own booking from the portal, PTR-023) |
6.7 Visa (/visa, /visa/:id) + Tickets (/tickets, /tickets/:id)
| Section | Action | Permission |
|---|---|---|
Route /visa |
View pipeline | visa.view |
Route /visa/:id |
View case | visa.view |
Route /visa/groups/:id |
View visa group | visa.groups.view |
Route /sales/visa |
View public intake queue | visa.intake.view |
Route /visa/apply |
Public application form | (none — public) |
| Header | New visa case | visa.create |
| Header | Upload document | visa.edit |
| Header | Sync visa cases | visa.edit |
| Row | Bulk status change | visa.edit |
| Row | Generate invoice — bill the selected finished visas to the agent who processed them (FIN-043) | finance.create (DB bill_visa_cases) |
| Row | Per-row status | visa.edit |
| Docs dialog | Delete visa doc | visa.edit |
| Group detail | Edit visa group code | visa.groups.edit |
| Group detail | Set status override | visa.groups.edit |
| Group detail | Close visa group | visa.groups.edit |
| Intake queue | Convert to visa case | visa.intake.convert |
| Intake queue | Reject / change intake status (reason required) | visa.intake.convert |
| Row / detail | Status change — only VISA-003 transitions, reason required, changedBy recorded |
visa.edit (DB change_visa_status) |
| Docs dialog | Remove visa doc — soft delete with reason | visa.edit (DB delete_visa_document) |
Route /tickets |
View the departure list, open a departure's sheet | tickets.view (DB ticketing_departures, ticketing_flight_sheet) |
Route /tickets/:id |
View detail | tickets.view |
| Sheet | Enter / paste ticket numbers and save a departure — booking confirmed by finance or an approved exception, real e-ticket number, no duplicates, reason, all or nothing | tickets.approve (DB issue_tickets) |
| Row | Ask for an exception (reason) | tickets.edit (DB request_ticket_exception) |
| Row | Approve / refuse an exception — not the person who asked | tickets.approve (DB decide_ticket_exception) |
| Detail | Upload ticket doc | tickets.edit |
Retired 25 Sep 2026 (20260925100000): Sync tickets, Print report on the old queue, Bulk update names and Update passenger name — tickets are created from the seat and take the passenger's name.
| Visa groups | Create / rename / retire a visa group (VisaGroup) | visa.groups.edit; read with visa.groups.view or visa.view (DB: VisaGroup RLS) |
| Intake | Read an intake request and its attachments | visa.intake.view (DB: VisaIntakeRequest / VisaIntakeAttachment RLS) |
6.8 Finance (/finance)
| Section | Action | Permission |
|---|---|---|
| Route | View module | finance.view |
| Accounts | Create GL account | finance.edit |
| Accounts | Edit GL account | finance.edit |
| Accounts | Delete GL account | finance.edit |
| Accounts | View ledger drill-down | finance.view |
| Accounts | Read an account's balance (GET /accounts/:id/balance → finance_account_balance; the figure the insufficient-balance guard decides on, FIN-033) |
finance.view (DB finance_account_balance) |
| Payments | Record payment (always saved pending; createdBy from the session) |
finance.payments.record — only this permission since F1 (DB record_payment, Payment insert policy) |
| Payments | Record one receipt against several bookings (one atomic call — POST /finance/payments/batch → record_payments_batch) |
finance.payments.record |
| Accounts | Set or restate an opening balance (POST /finance/accounts/:id/opening-balance → set_account_opening_balance; posts a pending voucher and reverses the previous one) |
finance.create (DB: finance.create or finance.edit) |
| Settings | Move a document counter forward (set_document_counter, reason required) |
finance.config.edit |
| Payments | Verify payment — posts the receipt voucher approved in the same transaction; recorder cannot verify (FIN-032) | finance.payments.verify (DB verify_payment) |
| Payments | Reject payment (reason required; the Reject button shows for either permission) | finance.payments.reject or finance.payments.verify (DB verify_payment) |
| Payments | Request refund — cap and original account shown (PRC-030) | finance.payments.refund (DB refund_payment) |
| Approvals | Approve / reject refund request (requester cannot decide; posts refund voucher RFV-nnnnn) | finance.refunds.approve, plus finance.approvals.high_value above the ACC-030 limit (DB approve_refund + fin_check_approval_limit) |
| Settings | Approval limits by amount (ApprovalLimit) — read by any staff user, changed with |
finance.config.edit |
| Payments | Mark booking fully paid | Removed 2026-09-18 (route deleted) |
| Unposted entries | See the unposted-accounting-entries card (GET /finance/posting-failures) |
finance.view, bookings.view or approvals.view (DB PostingFailure select) |
| Unposted entries | Retry the posting (POST /finance/posting-failures/:id/retry → retry_posting_failure; useConfirm with a reason; re-posts the recorded lines and resolves the row on success) |
finance.create (DB retry_posting_failure) |
| Unposted entries | Mark one resolved after posting it by hand (useConfirm with a reason; the record itself can never be edited or deleted) |
finance.create (DB PostingFailure resolve) |
| Unposted entries | Record a posting failure | Any staff user (DB PostingFailure insert → is_staff_user()) — deliberately: the row is written on the failure path of someone else's action, so gating it would make the miss silent again (FIN-030) |
| Vouchers | Post a customer receipt against a booking or invoice (sent to /finance/payments; the composer offers only the voucher types the user may post) |
finance.payments.record |
| Vouchers | Post a customer receipt on account (/finance/vouchers/customer-on-account-receipt) |
finance.edit |
| Vouchers | Post a supplier refund or business partner receipt, payment, contra, debit / credit note, settlement | finance.create |
| Transactions | Record Payment, and Pay on a booking under Top Outstanding | finance.payments.record |
| Transactions | Print a verified receipt (shows Received from: the booking's partner with partner code, else payer or customer with customer code) | finance.view |
| Journal | Create manual journal (the phone: DB create_manual_journal, §6.5d) |
finance.create |
| Journal | Reverse journal — the Reverse action turns away the voucher's maker (screen-level); in the database the reversal waits until someone who made neither voucher approves it (ACC-020) | finance.journals.reverse (own voucher on the Reverse action: finance.journals.reverse_own) |
| Journal | Approve / reject voucher (creator cannot decide; nor can the maker of the voucher a reversal reverses — ACC-020). Above ₹25,000, every voucher a person writes — journals, contras, on-account receipts, reversals on custom lines — needs finance.approvals.high_value (ACC-030) |
finance.journals.approve / finance.journals.reject (DB approve_journal_entries, trigger "JournalEntry_limit_guard") |
| Journal | Approve all pending vouchers (confirmation; own vouchers skipped) | finance.journals.bulk_approve |
| Approvals | Approve booking (finance) — finance_decide_booking, approver ≠ creator |
approvals.approve or finance.bookings.approve_finance (route and DB alike since 20261008140000) |
| Approvals | Reject booking (finance) | approvals.approve or finance.bookings.reject_finance (route and DB alike) |
| Cancellations | List B2B cancellations | finance.view |
| Cancellations | Approve / reject B2B cancellation (filer cannot decide; reject restores seats in the same transaction) | finance.cancellations.approve_b2b (DB decide_b2b_cancellation) |
| Cancellations | Approve / reject airline cancellation refund (bank refund or supplier credit note; filer cannot approve — CXL-030/031) | finance.cancellations.approve_airline (DB-enforced in settle_airline_cancellation / reject_airline_cancellation) |
| Allocate | Allocate agent receipt | finance.payments.record |
| Reports | View trial balance, P&L, balance sheet, day book, aging | finance.view |
| Reports | Export any | reports.export |
| Settings | Create period | finance.periods.create or finance.edit (RLS) |
| Settings | Lock / unlock / close period (DB guard stamps who; closed periods cannot reopen) | finance.periods.lock / finance.periods.unlock / finance.periods.close |
| Settings | Close financial year | finance.years.close (DB close_financial_year) |
| Settings | Run FX period-end revaluation | finance.periods.close |
| Settings | Rebuild ledger / quota ledger / manual ledger entry | finance.ledger.rebuild |
| Settings | Nuclear ledger reset (wipe all) | Removed 2026-09-18 (finance.ledger.reset route answers 410) |
| Accounts | Force-delete a GL account with its vouchers | Removed 2026-09-18 (accounts with activity are deactivated) |
| Reports | Trial balance, P&L, balance sheet, cash flow, day book (aggregated in DB by entryDate) |
finance.view (DB finance_account_totals, finance_day_book_page) |
| Reports | GST summary | finance.reports.gst_summary.view |
| Settings | Save finance config | finance.edit |
| Settings | Stock adjustment | finance.edit |
| TDS | View TDS rate master / deduction ledger | finance.tds.view |
| TDS | Export 26Q CSV | finance.tds.export |
| Journal | Attach a document to a voucher (JournalAttachment) |
finance.create or finance.edit; read with finance.view (DB: JournalAttachment RLS) |
| Journal | Save / use a recurring journal template (JournalTemplate) |
finance.create or finance.edit; read with finance.view (DB: JournalTemplate RLS) |
| Settings | Store an FX rate snapshot (exchange_rate_snapshots) |
finance.edit or finance.create; read with finance.view |
| Settings → Exchange Rate Management | See the rates in force (GET /finance/fx-rates) |
finance.view |
| Settings → Exchange Rate Management | Add / update a rate — today in IST by default, never after today, a reason for an earlier date (DB set_exchange_rate, audited; FIN-046) |
finance.rates.manage (checked in the function; the table refuses direct writes from any browser session) |
| Settings → Exchange Rate Management | Refresh Rates from the live feed (POST /finance/fx-rates/refresh → set_exchange_rate per currency; never replaces a manual rate of the same day) |
finance.rates.manage (was finance.edit until 20261006100000) |
6.9 Partners (admin) (/agents)
| Section | Action | Permission |
|---|---|---|
| Route | View partners | agents.view |
| Header | Add partner | agents.create |
| Add / Edit dialog | Commission % and Credit limit (disabled, with a note, without the permission; the database refuses a change — PTR-030) | agents.commission.set; agents.credit_limit.set |
| Row | Edit partner | agents.edit |
| Row | View ledger | agents.view |
| Ledger | Allocate receipt | finance.payments.record |
| Ledger | Print statement | reports.export |
| Row | Set/reset access key | agents.edit |
| Row | Deactivate / reactivate | agents.edit |
| Row | Approve / reject a new partner (the ⋯ menu on a pending partner, or a closed one never approved) — POST /agents/:id/status — PTR-095 |
partners.approve |
| Row | Delete partner | agents.delete |
| Row | Open the partner profile (click the card or row; /partners/:id, GET /partners/:id/record → partner_record) — PTR-083, PTR-091. The row's other actions sit in one ⋯ menu with the gates above — PTR-092 |
agents.view |
| Record | Approve / reject a registration, or approve a closed partner never approved (action bar) — POST /agents/:id/status → partner_set_status with a reason — PTR-001, PTR-095 |
partners.approve |
| Record | Suspend, lift, deactivate, reactivate (More menu) — POST /agents/:id/status with a reason; Edit terms (commission, credit) and Edit profile (More menu, About tab) — PTR-001, PTR-083, PTR-091 |
partners.edit; changing the commission or credit limit also needs agents.commission.set / agents.credit_limit.set (refused by the database otherwise — PTR-030) |
| Record → action bar | New booking — opens the booking wizard with this partner chosen (/sales/bookings/new?agentId=<id>); shown for an active partner — PTR-091 |
bookings.create |
| Record → More | Access key (opens the list's access-key dialog, /agents?accessKey=<id>) — PTR-091 |
partners.edit |
| Record → More | Delete — typed confirmation, DELETE /agents/:id, back to the list — PTR-091 |
agents.delete |
| Record → More | Pause / resume the partner's login with a reason (PATCH /users/:userId → admin_pause_user / admin_resume_user) — ACC-070 |
partners.edit (or admin.users.edit) |
| Record → Documents | Review a document: OK, or reject with a note (POST /partners/:id/documents/:docId/review → partner_document_review) — PTR-080 |
partners.edit |
| Record → Timeline / Bookings / Payments & ledger / Travellers | Read the merged timeline (audit rows as action, field names and actor — AUD-004), the bookings and requests, the money against the credit limit, and the travellers — PTR-091 | agents.view (inside partner_record) |
| Record → About / Timeline sidebar | Read the profile (business, address, registrations, office relationship with internal notes), the contacts and the performance numbers — PTR-084, PTR-085, PTR-089 | agents.view (inside partner_record; PartnerProfile / PartnerContact row security) |
| Record → About / More | Edit profile: only the changed fields (PATCH /partners/:id/profile → partner_update_profile_admin; the relationship manager must be an active staff login) — PTR-084 |
partners.edit |
| Record → About → Contacts | Add, change, remove (with a reason, soft) a contact (POST /partners/:id/contacts → partner_contact_save; DELETE /partners/:id/contacts/:contactId → partner_contact_remove); Call / WhatsApp (PTY-008) |
partners.edit (Call / WhatsApp: agents.view) |
| Record → Documents | File a document on the partner's behalf, optional valid-until date (upload-partner-doc with agentId → partner_add_document_for); remove a document record with a reason, the file stays on the drive (DELETE /partners/:id/documents/:docId → partner_remove_document) — PTR-087 |
partners.edit (checked by the edge function and the database function) |
| Record → Notes / Timeline composer | Read the notes; Add note or Post from the Timeline (kind, text, optional follow-up date) — append-only, never edited or deleted (POST /partners/:id/notes → partner_note_add) — PTR-088 |
agents.view to read; partners.edit to add |
| Record → Access | The login: active / paused, authenticator, last sign-in, sign-ins in 30 days, failed this week; sessions (device, browser, IP); devices with push alerts; the last sign-ins | agents.view (inside partner_record) |
| List | City, relationship manager and tier columns and filters, and the person each partner is named by (partner_list_extras) — PTR-090, PTR-092 |
agents.view |
6.10 Customer portal (/customer/**) — role-scoped
Not enforced against staff matrix. Access gated by allowedRoles={['customer']}, RLS ownership policies and the portal functions in §4.5. Every state-changing action asks Yes/No first (UX-001).
| Action | Enforcement |
|---|---|
| View own bookings, payments, visa cases, invoices, statement | RLS ownership (auth_customer_ids()) |
Download own invoice (/sales/bookings/:id/invoice) |
RLS — 404 for other bookings; portal callers never create Invoice rows |
| Browse / apply for open groups | public_groups(); apply = customer_create_request (confirm) |
Pay Now (/portals/customer/payments) |
customer_submit_payment (confirm with amount summary) |
| Accept / decline quotation | customer_respond_quotation (confirm; decline asks for a reason) |
Create / respond to request (/portals/customer/requests/new, /portals/customer/requests/:id/respond) |
customer_create_request / customer_respond_request (confirm) |
| Edit profile | customer_update_profile (confirm) |
| Request account deletion (My Profile, the card at the foot) — AUD-025 … AUD-027 | edge function account-delete (password checked) → account_delete_self (service role only; files a request, never erases); account_deletion_status for the own view (typed DELETE + password) |
| Open own visa documents and request attachments | drive-file — own documents (trv_my_documents) or own uploads (ACC-074) |
| Manage own sessions | UserSession own rows policy |
Set the first password from a WhatsApp sign-up link (/customer/set-password?t=…, public page) — TRV-017 |
no session needed: auth-login set_password_with_token → auth_password_set_token_use (service role only; a hashed one-time link, 30 minutes, only for an active customer-only login); no staff permission |
Pay a booking's balance from a WhatsApp payment link (/pay/:token, public page) — COMM-034 |
no session needed: razorpay-order { payToken } → whatsapp_pay_link_use (service role only; a hashed link, 30 minutes, one use, bound to the booking, the amount and the customer or payer whose number asked for it); the order is the customer's (actorKind customer), never staff's; ten openings an hour per connection |
| The WhatsApp menu (writing to the business number) — COMM-030 … COMM-036 | no login: the number WhatsApp delivered the message from is the identity. whatsapp-webhook (signature-checked) calls service-role-only functions: whatsapp_menu_bookings shows only the live bookings where the number is the customer's, the payer's or a live traveller's own; a number on more than one customer's bookings is shown nothing; a partner's number is kept out |
6.11 Partner portal (/partner/**) — role-scoped
Not enforced against staff matrix. Access gated by allowedRoles={['agent']}, RLS agency policies and the portal functions in §4.5. Rules: docs/rules/11-partners.md. Every state-changing action asks Yes/No first (UX-001). Pending or suspended partners can sign in and read but cannot transact — except that a suspended partner may still pay what it owes (PTR-081).
| Action | Enforcement |
|---|---|
Sign up (/auth/partner-signup) |
partner_register — pending until approved |
| Dashboard, own bookings / customers / quotations / statement | RLS agency policies; customer lists mask passport numbers (AUD-020) |
Create booking (/portals/agent/bookings) |
partner_create_booking (confirm) — rate sheet price, credit limit, capacity, on_account, the submit readiness check (PTR-022) |
Add passengers (/portals/agent/bookings/:id/passengers) |
partner_add_passengers (confirm) |
Send a sent-back booking for approval again (/portals/agent/bookings/:id/resubmit) — PTR-023 |
partner_resubmit_booking (confirm with a note) — own NEEDS_CORRECTION booking only, active and not paused; resubmit_booking accepts the owning partner in place of bookings.edit |
| Who is family on my booking; mark not family; set a guardian (booking detail, and after the booking is made) — PAX-036, PAX-021 | booking_families, family_save, travellers_set_family_status, traveller_set_guardian — the partner's own travellers only (the function checks ownership); other partners' and the office's travellers are unnamed |
Flight offers (/partner/flights, /portals/agent/b2b-flights) and booking seats — flights only, no travel groups (PTR-094) |
public_b2b_offers; booking via partner_create_booking with the offer (confirm) |
Hotel offers / reserve beds (/portals/agent/hotel-offers/:id/reserve) |
partner_hotel_offers, partner_reserve_beds (confirm) |
Seat inventory / sell seats (/portals/agent/seat-sales) |
partner_seat_inventory, partner_sell_seats (confirm) |
Create / edit draft / issue invoice (/portals/agent/invoices) |
partner_upsert_invoice (confirm) |
Requests (/portals/agent/requests), optionally naming a departure (the enquiry that was on Flights — PTR-094) |
partner_create_request (confirm; the departure must be one public_groups() or the agency's bookings show) |
Payments page (/partner/payments, sidebar Payments; GET /portals/agent/payments) — PTR-093 |
allowedRoles={['agent']}; the reads are the agency's rows under RLS; partner_my_account |
I have paid (/partner/payments, and the booking detail on /partner/bookings; POST /portals/agent/payments) — PTR-081, PTR-093 |
partner_submit_payment (confirm) — own booking or issued invoice only, recorded pending; finance verifies with finance.payments.verify |
Pay online (/partner/payments, and the booking detail; POST /portals/agent/payments/online-order) — PTR-093 |
edge function razorpay-order → online_payment_actor = partner (confirm); rupees only, ≤ Booking.balanceAmount; the receipt is recorded by razorpay-webhook |
Edit name / contact person (/me → PATCH /portals/agent/profile) |
partner_update_profile (confirm) |
Request account deletion (/me, the card at the foot) — AUD-025 … AUD-027 |
edge function account-delete (password checked) → account_delete_self (service role only; files a request, never erases); once approved the agency's business record stays (typed DELETE + password) |
Edit own address and website (/me → PATCH /me/partner-profile) — PTR-086 |
partner_update_my_profile (confirm; allow-list; agency from the session) |
Add, change, remove own contacts (/me → POST /me/partner-contacts, DELETE /me/partner-contacts/:id) — PTR-085, PTR-086 |
partner_contact_save / partner_contact_remove (confirm; own agency only; removal needs a reason) |
Own profile and documents (/me: GET /me/profile → profile_me, GET /me/documents → AgentDocument select agency; upload through upload-partner-doc → partner_add_document) — ACC-071 |
own agency only; a paused login reads (ACC-070) |
| Manage own sessions | UserSession own rows policy |
6.12 Inventory (/inventory/**)
| Route | Action | Permission |
|---|---|---|
/inventory/quota |
View blocks | inventory.view |
| Create/edit/delete block | inventory.create / inventory.edit / inventory.delete |
|
Save draft / correct / discard a draft block (POST /inventory/quota-blocks draft: true, PATCH/DELETE /inventory/quota-blocks/drafts/:id; Drafts list GET /inventory/quota-blocks/drafts; the preview GET …/drafts/:id/preview) — AIR §36. A draft posts nothing and cannot be used |
new draft inventory.create; correct, discard, list, preview inventory.create or inventory.edit (DB create_quota_block_draft, update_quota_block_draft, discard_quota_block_draft, quota_block_draft_preview; RLS AirlineQuotaBlockDraft select ticketing) |
|
Submit block — creates it, posts its purchase voucher and records the deposit in one transaction (POST /inventory/quota-blocks/drafts/:id/submit) — AIR §36 |
inventory.create; the deposit also inventory.block_payments.record (DB submit_quota_block) |
|
| Write off seats bought and never sold (FIN-037) | inventory.writeoff.approve |
|
| Manage airlines | inventory.edit |
|
| Link/unlink block to group | inventory.allocate |
|
List services outside their departure's dates (GET /inventory/outside-trip-dates) — INV-003/032 |
groups.view or inventory.view (requireAnyPermission; DB inventory_outside_trip_dates) |
|
| Sell seats to B2B | inventory.edit |
|
File a B2B sale cancellation (POST /finance/b2b-cancellations, Cancel Sale) — INV-014 |
finance.create (the browser-built filing), or inventory.b2b_cancellations.file (DB file_b2b_cancellation, one transaction). The decision stays finance.cancellations.approve_b2b (§6.8) |
|
Buyer list for a seat sale (GET /inventory/party-accounts → inv_party_accounts(): party ledgers only) |
inventory.edit (or finance.view) |
|
Record supplier payment (POST /inventory/quota-blocks/:id/finance-events eventType: supplier_payment) — FIN-044 |
finance.create (the browser-built voucher), or inventory.block_payments.record (DB record_airline_block_payment: pending, guarded, TDS-aware). The Post Payment button shows for either |
|
Record refund from the airline (POST /inventory/quota-blocks/:id/airline-refund) — FIN-044. Pending finance (FIN-032); capped at what was paid so far; a fully cancelled block still takes it |
inventory.block_payments.record (<PermissionGate>; the route's requirePermission; DB record_airline_block_refund checks it again) |
|
Paid-from account list for a recorder without finance.view (GET /inventory/paid-from-accounts) |
inventory.block_payments.record or finance.view (DB airline_block_paid_from_accounts) |
|
Released-seat register (GET /inventory/released-seats?blockId=) — CXL-012 |
inventory.view |
|
Mark released seat re-used (POST /inventory/released-seats/:id/reuse) — CXL-021 |
inventory.edit (DB: inventory.edit or bookings.edit) |
|
| File airline cancellation against released / unallocated seats — CXL-020 | finance.create (DB-enforced in file_airline_cancellation) |
|
Airline release/cancellation penalty bands (GET/POST /inventory/penalty-bands, DELETE /inventory/penalty-bands/:id) — AIR §20 |
inventory.view to read, inventory.edit to maintain (DB upsert_release_policy_band, delete_release_policy_band) |
|
Block FOC summary and P&L (DB block_foc_summary, block_pnl) — AIR §15, §25 |
inventory.view or finance.view |
|
/inventory/quota, /inventory/fit |
Fetch flight details — the flight schedule from AirLabs (edge function flight-lookup; the key is its secret, never in the browser) — PLT-015 |
inventory.view, staff only (checked in the function with user_has_permission) |
/inventory/holds |
View holds board — AIR §14, INV-011 | inventory.view |
| Hold seats/rooms/places (reason) | inventory.holds.manage (DB create_inventory_hold) |
|
Extend a hold (reason, capped by FinanceConfig.maxHoldExtensions) |
inventory.holds.manage (DB extend_inventory_hold) |
|
| Convert a hold to its booking (reason) | inventory.holds.manage (DB convert_inventory_hold) |
|
| Release a hold (reason) | inventory.holds.manage (DB release_inventory_hold) |
|
| Run the hold-expiry sweep (preview, then commit) | inventory.holds.manage (DB expire_inventory_holds; the service role may run it unattended) |
|
Resource position (GET /inventory/holds/position) |
inventory.view (DB inventory_resource_position) |
|
/inventory/releases |
View seat releases — AIR §16–§18 | inventory.view |
Release preview (GET /inventory/releases/preview) — AIR §16 |
inventory.view (DB preview_seat_release) |
|
| Request a release (reason) | inventory.release.request (DB request_seat_release) |
|
| Withdraw your own request | inventory.release.request (DB cancel_seat_release) |
|
Approve / reject a release — not the requester (ACC-020), unless the requester holds inventory.release.approve_own |
inventory.release.approve up to the seat limit, inventory.release.approve_large above it (ACC-030; DB approve_seat_release, reject_seat_release) |
|
| Record the airline filing — posts through the cancellation chain (CXL-020) | finance.create (DB complete_seat_release → file_airline_cancellation) |
|
/inventory/deadlines |
Deadline radar — AIR §21, INT-120 | inventory.view (DB dash_deadline_radar) |
Alert list (GET /inventory/alerts) — AIR §22 |
inventory.view |
|
| Acknowledge an alert (reason) | inventory.view (DB acknowledge_inventory_deadline_alert) |
|
| Run the alert ladder / escalation now — AIR §22 | inventory.edit (DB fire_inventory_deadline_alerts, escalate_inventory_deadline_alerts; the service role may run them unattended) |
|
/inventory/fit |
View | inventory.view |
| CRUD | inventory.create / .edit / .delete |
|
Record FIT supplier payment (POST /inventory/fit/:id/finance-events eventType: supplier_payment) — FIN-044 |
finance.create, or inventory.block_payments.record (DB record_airline_block_payment, p_kind: 'fit') |
|
Record refund from the airline on a FIT (payment dialog; POST /inventory/fit/:id/airline-refund → DB record_airline_block_refund, p_kind: 'fit') — FIN-044. Pending finance (FIN-032); capped at what was paid so far; shown only when something is paid, the FIT has a supplier and is not archived (GET /inventory/fit/:id/payment-summary, inventory.view) |
inventory.block_payments.record (<PermissionGate>; the route's requirePermission; DB record_airline_block_refund checks it again) |
|
Released-seat register (GET /inventory/released-seats?fitId=) — CXL-012 |
inventory.view |
|
| Mark released seat re-used — CXL-021 | inventory.edit |
|
File FIT airline cancellation (POST /inventory/fit/:id/finance-events eventType: cancellation) — CXL-024 |
finance.create (DB-enforced) |
|
/inventory/b2b-flights |
View | inventory.view |
| Create offer | inventory.create |
|
| Close offer | inventory.edit |
|
/inventory/ground-transfers |
View | inventory.view |
| CRUD | inventory.create / .edit / .delete |
|
| Assign / remove | inventory.allocate |
6.13 Suppliers (/suppliers)
| Section | Action | Permission |
|---|---|---|
| Route | View page | inventory.view (semantically should be suppliers.view — tracked in §8) |
| Header | Add supplier | suppliers.create |
| Row / Dialog | Edit supplier | suppliers.edit |
| Dialog | Delete supplier | suppliers.delete |
| Ledger | Add transaction — off until the supplier is approved; the database refuses a hand-entered bill or payment for a pending or rejected supplier (PTY-009) | suppliers.edit (RLS: any of suppliers/finance/inventory/hotels/food write permissions) |
| Header | Approve / Reject a new supplier (shown while it is pending; Approve also on a rejected one) — POST /suppliers/:id/decision → supplier_decide; a rejection needs a reason — PTY-009 |
suppliers.approve |
| Ledger | Edit transaction — a posted voucher must be reversed first; the correction needs a reason | suppliers.edit + finance.supplier_transactions.correct when the row carries a voucher (DB correct_supplier_transaction) |
| Ledger | Delete transaction | suppliers.delete + finance.supplier_transactions.correct when the row carries a voucher |
| Payment | Apply TDS on supplier payment | finance.tds.deduct |
6.14 Hotels (/hotels)
| Section | Action | Permission |
|---|---|---|
| Route | View page | hotels.view |
| Header | Create hotel | hotels.create |
| Row | Edit hotel | hotels.edit |
| Row | Delete hotel | hotels.delete |
| Any | Export | hotels.export |
6.14a Food (/food)
Food inventory was split out of the Hotels page in 2026-04. Hotel and food now have independent permission families and live on separate routes; the legacy /hotels/food/* API paths are kept as aliases for one release.
| Section | Action | Permission |
|---|---|---|
| Route | View page | food.view |
| Header | Create food item | food.create |
| Header | Import food CSV | food.create |
| Row | Edit food item | food.edit |
| Row | Delete food item | food.delete |
| Any | Assign food to group | food.edit |
| Row | Correct a meal plan — days, rate, dates, exclusions, a typed head-count (INV-042; PATCH /food/assignments/:id) |
food.edit |
| Any | Export | food.export |
6.15 People (/people, /people/employees, /people/account-deletions)
| Section | Action | Permission |
|---|---|---|
Route /people |
View directory | customers.view |
Route /people/employees |
View staff | admin.view |
Route /people/account-deletions |
Account deletions: travellers' and partners' deletion requests (every deletion is a request since 2026-10-01; each shows an answer-by date 30 days on) and the deletions done (account_deletion_requests); linked from the Customers card (customers.edit) and the Agents card (partners.edit) — AUD-027 |
customers.view for the route; the list shows travellers to customers.edit and partners to partners.edit (the database decides) |
| Account deletions | Complete deletion — the office's approval — once nothing is in the way (money held for the person included) (edge function account-delete complete → account_deletion_complete, service role, permission checked on the verified staff id); Finish a follow-up (finish); Decline with a note the person reads (account_deletion_decline) — AUD-027 |
customers.edit (a traveller) / partners.edit (a partner) |
| User mgmt | Create user (Add employee, POST /users → admin-users create_user) |
admin.users.create |
| User mgmt | Create user → choose a role: the login is made with no staff role and the role becomes a request filed by the creator (admin-users create_user roles → role_change_request_create_as) — ACC-082 |
admin.users.create and roles.request (the field shows only with roles.request) |
| User mgmt | Create user → choose an administrative role (CEO, GM, HR Admin, IT Admin, Super Admin today — role_is_administrative()): offered and accepted only for a holder of roles.apply; anyone else is refused before the login is made — ACC-083 |
roles.apply (IT_ADMIN, SUPER_ADMIN) as well as the above; the list comes from GET /role-requests/roles (requestable) |
| User mgmt | Reset password (POST /users/:id/password → admin-users reset_password); Reset MFA (POST /users/:id/mfa-reset) |
admin.users.reset_password; admin.mfa.reset — each only on a login you can manage (can_manage_user) |
| User mgmt | Request a role change for a staff login — add or remove one role, with a reason (RoleRequestDialog → POST /role-requests → role_change_request_create). Not offered on a customer or partner login — ACC-077, ACC-080 |
roles.request |
| User mgmt | Request a role change that adds or removes an administrative role — hidden from anyone without roles.apply, and refused by the database for them (RoleChangeRequest_admin_roles_by_admins) — ACC-083 |
roles.request and roles.apply |
| User mgmt | Deactivate / reactivate (PATCH /users/:id { action: 'deactivate' \| 'reactivate' }), delete (DELETE /users/:id) |
admin.users.delete, only on a login you can manage (can_manage_user) |
| User mgmt | ⋯ menu and record header: the access actions (change email, change username, pause / resume, reset password, reset MFA, deactivate / reactivate) show only where the caller can manage the login — canManage = can_manage_user(caller, person), the check the server makes; read in the same one call (GET /users?view=directory → computed field employee_can_manage; the record's profileEdit.canManage). Elsewhere: "You can't manage this login's access" — ACC-084 |
the action's own permission and canManage |
| User mgmt | Open the employee record (/admin/users/:id, GET /users/:id/record → employee_record; also GET /users/:id/profile → admin_user_profile) — ACC-071 |
admin.users.view |
| Record → Documents | Review a document: OK, or reject with a note (POST /users/:id/documents/:docId/review → employee_document_review) — ACC-071 |
admin.users.edit |
| Record → Documents | Open a document in the viewer (drive-file, kind employee_file: EmployeeDocument row security) — ACC-074 |
admin.users.view |
| Record → Documents | Upload a document for the person (upload-employee-doc with userId) — ACC-071, ACC-074 |
admin.users.edit |
| Record → Access / Activity | Sessions, devices, sign-ins and the merged timeline (audit rows as action, field names and actor — AUD-004) | admin.users.view (inside employee_record) |
| User mgmt | Edit profile (PATCH /users/:id/profile → admin_update_employee_profile) — every field, over someone whose every right you hold (can_manage_user) or your own row — ACC-071; remove a document (DELETE /users/:id/documents/:docId) |
admin.users.edit |
| Record header, ⋯ menu | Edit profile for HR — the profile details only (name and work phone read-only) of a staff login that is not an IT Admin or Super Admin; shown on the record only where profileEdit.allowed is true (the ⋯ menu offers it to admin.users.edit or hr.profiles.edit, and the dialog says why when profileEdit refuses); a refused save says "HR can't edit an IT Admin or Super Admin profile." — ACC-084 |
hr.profiles.edit (the database decides through profile_edit_rights()) |
| User mgmt | Edit profile → Reports to: pick from the active staff, never the person or anyone below them; designation suggestions (GET /users/:id/reporting-options → staff_reporting_options; the trigger EmployeeProfile_reports_to_guard refuses a loop) — ACC-081 |
admin.users.view to list; admin.users.edit or hr.profiles.edit (ACC-084) to save |
| User mgmt | Pause / resume with a reason (PATCH /users/:id { action: 'pause' \| 'resume' } → admin_pause_user / admin_resume_user) — ACC-070 |
admin.users.edit (the function also refuses self, anyone above you, and a paused caller) |
| User mgmt | Change a staff login's email with a reason (PATCH /users/:id { action: 'change_email' } → admin-users change_email) — ACC-075 |
admin.users.edit (existing permission; the function also refuses anyone above you and any customer or partner login) |
| User mgmt | Move staff to @alhudatravels.in (page header → More): preview, then run with a reason (POST /users/workspace-domain → admin-users move_staff_to_workspace_domain, dryRun) — ACC-075 |
admin.users.edit (existing permission; a login above your station is listed and not moved) |
6.16 Admin (/admin)
| Section | Action | Permission |
|---|---|---|
| Route | View admin | admin.view (role-name gate removed in Wave 2 — ACC-001/012) |
| Permissions matrix | Edit role permissions | admin.edit |
| Permissions matrix | Edit user overrides | admin.edit |
| Permissions matrix | A user's roles — read only; PUT /permissions/users/:id answers 410 when roleIds is sent (ACC-077) |
— |
| Currencies | CRUD | admin.edit |
| Currencies → Exchange Rates | Add, edit or delete a rate; Fetch Auto Rates; Clear Manual Rates (Today) — through DB set_exchange_rate / delete_exchange_rate, audited; a rate dated before today needs a reason (FIN-046) |
finance.rates.manage (was the admin.currency.* gates over a table any staff login could write, until 20261006100000) |
| Cities | CRUD | admin.edit |
| Integrations | View settings + configured flags | admin.integrations.view |
| Integrations → WhatsApp | WhatsApp delivery problems: reset codes Meta refused, and booking notices that failed or found no approved template, last 30 days (GET /admin/whatsapp-delivery-failures → whatsapp_delivery_failures(), ACC-069, COMM-024) |
admin.integrations.view (checked in the function too) |
| Integrations | Edit settings / set or clear secrets (write-only) | admin.integrations.edit |
| Integrations | Send test email / WhatsApp (server-side) | admin.integrations.test |
| Integrations → WhatsApp → Message templates | List the WhatsApp templates copied from Meta, with status and last synced (listWhatsAppTemplates() in src/services/whatsappTemplateAdmin.ts; the table's staff-read RLS; AUD-024) |
admin.integrations.view |
| Reminders | Automatic e-mails by category: each category and whether its e-mails are on (notification_categories(), COMM-039); HR leave is listed, switched in Leave admin (hr.leave.admin, LV-039) |
admin.integrations.view (checked in the function too) |
| Reminders | Switch a category's e-mails on or off — Finance (notification_category_save(), COMM-039; refuses hr_leave; a paused login refused; audited notification_category_changed) |
admin.integrations.edit (checked in the function too) |
| Reminders | Booking reminder e-mail settings and the last 50 reminders (booking_reminder_settings(), COMM-014; the tab is hidden without it) |
admin.integrations.view (checked in the function too) |
| Reminders | Change the settings, switch reminders on or off (booking_reminder_settings_save(), COMM-014; a paused login refused; audited) |
admin.integrations.edit (checked in the function too) |
| Reminders | Automatic WhatsApp notices: settings, template names with their state in Meta, the last 50 notices (whatsapp_notice_settings(), COMM-025) |
admin.integrations.view (checked in the function too) |
| Reminders | Switch a WhatsApp notice on or off, change its template names (whatsapp_notice_settings_save(), COMM-025; a paused login refused; audited) |
admin.integrations.edit (checked in the function too) |
| Integrations → WhatsApp → Message templates | Sync from Meta (syncWhatsAppTemplates() → edge function whatsapp-templates-sync, which checks it again, staff only, not paused; AUD-024) |
admin.integrations.edit |
| Integrations → WhatsApp → Sign-up in the WhatsApp chat | See the sign-up form's status in Meta and the wa.me link (signupFlowStatus() in src/services/whatsappFlowAdmin.ts → edge function whatsapp-flow-setup {action:'status'}, which checks it again, staff only; TRV-017) |
admin.integrations.view |
| Integrations → WhatsApp → Sign-up in the WhatsApp chat | Set up WhatsApp sign-up form — create, upload and publish the WhatsApp Flow on the company's WhatsApp account and save its id (setUpSignupFlow() → whatsapp-flow-setup {action:'setup'}, which checks it again, staff only, not paused; audited; TRV-017) |
admin.integrations.edit |
| Integrations → WhatsApp → WhatsApp menu | See whether the menu is on, its office hours and bank details (loadMenuSettings() in src/services/whatsappMenuAdmin.ts → GET /admin/communication-settings → integration_settings_public(); COMM-030) |
admin.integrations.view (checked in the function too) |
| Integrations → WhatsApp → WhatsApp menu | Switch the menu on or off, change the office hours and the bank details, with a reason (saveMenuSettings() → PUT /admin/communication-settings; CommunicationSetting row policy; audited as integration_settings.update; COMM-030) |
admin.integrations.edit (row policy too) |
| Audit logs | View | admin.view |
| Audit logs | Read raw AuditLog rows (RLS) |
admin.audit.view |
| Cancellation Policies | Route /admin/cancellation-policies (list + read) |
groups.view (API GET also accepts admin.view) |
| Cancellation Policies | Create / edit / delete a policy — bands, flat charges, percentages, kept items | cancellation_policies.manage (API, RLS and save_cancellation_policy) |
| Cancellation Policies | Work out a charge (cancellation_quote) |
bookings.cancel, bookings.cancel.approve or cancellation_policies.manage |
| Cancellation Policies | Manual refund override on cancel (at approval) | bookings.cancel.approve |
| Passenger categories | Read age cut-offs (GET /config/passenger-categories) — PAX-002/003 |
any signed-in user |
| Passenger categories | Change age cut-offs (PATCH /config/passenger-categories, RLS on PassengerCategoryConfig) |
admin.config.edit |
| Family relationships | Read the list (GET /families/relationships, RLS on FamilyRelationship) — PAX-036 |
any signed-in user |
| Family relationships | Add, rename, reorder or switch off a relationship (POST /families/relationships → family_relationship_save; never deleted; head stays on) — PAX-036 |
admin.config.edit |
6.17 Reports (/reports) + Requests (/requests) + Communications (/communications, /whatsapp) + Chat (/internal/chat)
| Route | Permission |
|---|---|
/reports |
reports.view |
/requests |
requests.view |
/communications |
customers.view (drift — should be dedicated communications.view future) |
/whatsapp |
customers.view (drift — same) |
/communications, /whatsapp, booking "Send message" |
Send / queue / bulk send, record consent → communications.send |
/communications |
Staff push card → Send Notification → communications.send; the push-send edge function checks it (COMM-042) |
/requests |
Change status (fixed set, note required) / add note → requests.edit (DB update_customer_request) |
/internal/chat |
chat.view |
6.18 Role dashboards (/app) — UX-010 / UX-011
The staff home renders a dashboard per job, chosen from the permissions the user holds (src/lib/dashboardProfiles.ts, re-exported by src/components/dashboard/widgets/profiles.ts). The native app's Dashboard tab uses the same file (an identical copy) and the same widget permissions below (§6.5d). The route itself stays open to every staff user (allowedRoles={['admin','staff']}) so a user without a matching profile still lands somewhere and is told why; every widget on it is permission-gated twice — in the browser (anyPermission in the widget registry) and in the database (dash_* functions in 20260919140000_role_dashboards.sql raise 42501). Nothing here introduces a new permission.
| Widget (section) | Permission — any of | Enforcement |
|---|---|---|
| Waiting for your decision (work) | approvals.approve, finance.bookings.approve_finance, finance.payments.verify, finance.journals.approve, finance.refunds.approve, bookings.cancel.approve, finance.cancellations.approve_b2b, finance.cancellations.approve_airline, tickets.approve |
DB dash_approvals_inbox (each category included only for the permission that decides it; items you created are excluded — ACC-020) |
| Bookings to approve / Ticketing exceptions / Refunds requested | approvals.approve / tickets.approve / finance.refunds.approve |
DB dash_approvals_inbox with a category filter |
| Groups at risk, Departures next 30 days, Travelling today | groups.view, approvals.approve, hotels.edit, inventory.edit, visa.edit, tickets.approve |
DB dash_departures, dash_travel_today (money shown only with finance.view) |
| Exceptions (audit feed) | admin.audit.view (+ finance.view in the DB) |
DB dash_exceptions |
| Bookings this season / this month | bookings.view |
DB dash_bookings_season, dash_sales_numbers |
| Collections this month (and collected today under it) | finance.reports.day_book.view, finance.reports.profit_loss.view |
DB dash_collections_month (its today field) |
| Receivables overdue (and the customer / partner ledger split under it) | finance.reports.aging.view, finance.reports.receivables_payables.view |
DB dash_receivables_overdue (its ledger field) |
| Cash & bank | finance.reports.balance_sheet.view |
DB dash_cash_position |
| Supplier payments due | finance.reports.receivables_payables.view, finance.edit |
DB dash_payables_due |
| Receipts to verify | finance.payments.verify |
DB dash_receipts_to_verify (own receipts excluded) |
| Bank lines not matched | finance.edit |
DB dash_bank_unmatched |
| TDS to deduct and deposit | finance.tds.view, finance.tds.deduct |
DB dash_tds_pending |
| Vouchers you prepared | finance.create, finance.edit, finance.journals.reverse |
DB dash_my_journals |
| Period status & close blockers | finance.view (+ a reports/period/audit permission in the DB) |
DB dash_period_status |
| Collected today by mode / My receipts awaiting verification | finance.payments.record, finance.create |
DB dash_my_collections (own records only) |
| Leads to follow up | leads.view |
DB dash_leads_followup |
| Quotations expiring | quotations.view |
DB dash_quotations_expiring |
| Drafts not submitted / Sent back for correction / Dues before departure | bookings.view |
DB dash_draft_bookings, dash_corrections, dash_dues_near_departure (p_scope = 'mine' filters by createdBy / correctionOwnerId) |
| Customer requests / Partner requests | requests.view |
DB dash_customer_requests (p_scope = 'mine' filters by assignedToId) |
| Groups nearly full | groups.view, bookings.create |
DB dash_seats_running_out |
| Partner applications / Partner sales | agents.view |
DB dash_partner_applications, dash_partner_sales |
| Partner bookings pending | bookings.view |
DB dash_partner_bookings_pending |
| Partners near their credit limit | agents.ledger.view, finance.view (+ agents.view in the DB) |
DB dash_partner_credit |
| Visas not issued / Visa cut-offs | visa.view, groups.view |
DB dash_visa_deadline |
| Documents missing / Rejected cases / Cases by stage | visa.view |
DB dash_visa_documents_missing, dash_visa_rejected, dash_visa_stages |
| Intake queue | visa.intake.view |
DB dash_visa_intake |
| Rooming not assigned | hotels.edit, hotels.view, groups.view |
DB dash_rooming_unassigned |
| Deadlines next 7 days | tickets.view, inventory.view |
DB dash_ticket_deadlines |
| Tickets to issue | tickets.view |
DB dash_tickets_to_issue |
| Airline cancellations | inventory.view, tickets.view |
DB dash_airline_cancellations |
| Seats held, sold and released | inventory.view |
DB dash_block_utilisation |
| Deadline radar (T-7/3/1/0) | inventory.view, tickets.view |
DB dash_deadline_radar |
| Holds expiring | inventory.view, bookings.view |
DB dash_hold_expiry |
| Seat releases | inventory.view, tickets.view, finance.view |
DB dash_seat_releases |
| Users without two-factor / Dormant accounts / Active users by role | admin.users.view |
DB dash_users_without_mfa, dash_dormant_accounts, dash_users_by_role |
| Privileged access changes | admin.audit.view |
DB dash_privileged_grants |
| Frequent actions (shortcuts) | each link carries its own permission (bookings.create, quotations.create, agents.create, finance.payments.record, admin.permissions.view, …) |
hasPermission() per link |
6.19 My profile (/me) — ACC-070, ACC-071
One page for the signed-in person (staff, tour leader, partner; allowedRoles=['admin','staff','agent']). No staff permission: every read and write is the person's own row, decided by the database.
| Section | Action | Permission | Enforcement |
|---|---|---|---|
| Route | Open /me (Settings menu → My profile; a partner's sidebar My Profile; /partner/profile redirects) |
any staff or partner sign-in | GET /me/profile → profile_me() (own row) |
| Your login | Change phone (staff) | own row | PATCH /me/profile { patch } → profile_update_me (allow-list) |
| Personal details, address, emergency contact | Change personalEmail, address, district, state, pinCode, emergencyContact*, dateOfBirth, bloodGroup, gender |
own row; simple Yes/No | profile_update_me refuses any other field and a paused login |
| Employee details, roles | Designation, code, department, joined on, reports to, Aadhaar / PAN last four, roles | read-only | changed by the office with admin.users.edit, or by HR with hr.profiles.edit (not the code or roles; ACC-084) (§6.15) |
| Documents | List own files with drive links; upload a JPEG/PNG/WebP/PDF under 10 MB | own row | EmployeeDocument select own or admin; upload-employee-doc → employee_add_document (self); removal is the office's (§6.15) |
| Partner | The agency, credit limit, documents, the business and registrations the office keeps (read-only) and the relationship manager; change name / contact person, address, website and contacts; upload a document | own agency | §6.11 (PTR-086) |
| Request account deletion | A partner asks the office to delete their login (the card at the foot of /me; staff do not see it); always a request — AUD-025 … AUD-027 |
own login | edge function account-delete → account_delete_self |
| Paused | Everything reads; a save or an upload is refused and shown as a toast | — | auth_assert_can_write() in every function; assertCanWrite in the upload functions |
6.13 Work inbox (/work) - WRK-001 to WRK-018
| Section | Action | Permission | Enforcement |
|---|---|---|---|
| Route | View page | work.view |
<ProtectedRoute> |
| Tabs | My work, Waiting to be picked up | work.view |
always shown |
| Tabs | My queues | work.view.queue |
tab hidden without it; work_inbox_page refuses the scope |
| Tabs | Everything | work.view.all |
tab hidden without it; work_inbox_page refuses the scope |
| Header | Log a job (with due time and priority, WRK-018) | work.create |
<PermissionGate>; DB work_item_create |
| Log a job | Give it to someone else on create (phone only today) | work.assign; yourself needs work.claim |
DB work_item_create_as |
| Row | Claim | work.claim |
<PermissionGate>; DB work_item_claim (atomic) |
| Row | Open (projected row) | the owning page's permission | the linked screen gates it (WRK-001) |
| Drawer | Hand over | work.assign, or the job's holder with work.claim |
shown to a manager or the holder; DB work_item_assign checks which (WRK-007) |
| Drawer | See suggestions | work.assign |
DB suggest_work_assignee |
| Header | Routing: read the rules | work.view |
DB work_routing_list |
| Routing | Change a rule | work.assign |
shown when canEdit; DB work_routing_set (reason required) |
| Header | Queues: read who is in each queue and who leads it | work.view |
DB work_queue_members_list |
| Queues | Add a person, make them member or lead, set weekly capacity points, take them off, bring them back | work.queues.manage |
<PermissionGate>, shown when canEdit; DB work_queue_member_save / work_queue_member_remove (WRK-013), audited |
| Drawer | Park / restart | work.claim |
DB work_item_wait / work_item_resume (reason required) |
| Drawer | Record the first answer, add a note | work.claim |
DB work_item_record_response |
| Drawer | Put it back | work.claim |
DB work_item_release (reason required) |
| Drawer | Finish | work.close |
<PermissionGate>; DB work_item_close |
| Drawer | Cancel | work.assign |
<PermissionGate>; DB work_item_close(status=cancelled) |
| Drawer | Reopen | work.reopen |
<PermissionGate>; DB work_item_reopen |
6.13a Performance (/performance) - PERF-001 to PERF-012
| Where | Action | Permission | Enforced by |
|---|---|---|---|
| Route | View the page (your own numbers) | perf.view.own |
ProtectedRoute; DB perf_scorecard via perf_require_view |
| Figure | Open the jobs behind a number | perf.view.own (own) / perf.view.all (someone else) |
DB perf_scorecard_rows via perf_require_view |
| Header | Choose another person | perf.view.all |
DB perf_people; each look writes an AuditLog row perf.viewed (PERF-011) |
### 6.20 Leave (/leave, /leave/approvals, /leave/admin, /leave/holidays) — LV-001 … LV-062 |
Routes: handleLeave() in src/lib/leave.ts (Leave and agreements API). Screens: Leave.
| Section | Action | Permission | Enforcement |
|---|---|---|---|
| Route | Open /leave (sidebar Leave) |
leave.apply |
<ProtectedRoute>; GET /leave/dashboard → leave_my_dashboard |
| Dashboard | Apply for leave (preview as you type) | leave.apply |
POST /leave/preview → leave_preview; POST /leave/applications → leave_apply (every check again; clientKey makes a double click harmless) |
| Dashboard | Report an absence (LV-042) | leave.apply |
leave_apply with isUnplanned |
| My applications | Open one, with its timeline | own, routed to you, or HR | GET /leave/applications/:id → leave_application_detail (row checks in the function) |
| My applications | Withdraw a pending one; cancel approved leave not started; ask to cancel leave that has started | leave.apply (own) |
POST /leave/applications/:id/cancel → leave_cancel |
| My applications | Attach a medical certificate (LV-014) | leave.apply (own) |
upload-employee-doc (medical_certificate) → employee_add_document; POST /leave/applications/:id/certificate → leave_attach_certificate |
| Ledger | Read one's own ledger for a leave year | leave.apply |
GET /leave/ledger → leave_my_ledger |
| Route | Open /leave/approvals (sidebar Leave approvals needs hr.leave.approve) |
leave.apply; what is listed is decided by the routing |
<ProtectedRoute>; GET /leave/inbox → leave_inbox |
| Approvals | Approve or Refuse (a refusal needs a reason) | the routed approver: hr.leave.approve; an HR applicant's reporting manager; hr.leave.approve_peak for the management stage — never the applicant |
POST /leave/applications/:id/decide → leave_decide (leave_can_decide_hr, leave_can_decide_mgmt) |
| Approvals | Agree or refuse a cancellation of leave that has started | hr.leave.approve, or the routed reporting manager |
POST /leave/applications/:id/cancellation → leave_decide_cancellation |
| Team | Who is away (up to three months) | HR and management see everyone; anyone else their direct reports | GET /leave/team → leave_team |
| Route | Open /leave/admin (sidebar Leave admin) |
hr.leave.admin |
<ProtectedRoute>; GET /leave/admin → leave_admin_screen |
| Leave admin | Change settings (weekly off, sandwich rule, leave e-mails on or off (LV-039), windows, carry-forward cap, encashment, certificate days, company name and address, signatory) | hr.leave.admin |
PATCH /leave/admin/settings → leave_settings_save (allow-list, audited) |
| Leave admin | Change a leave type (days, on / off, half days, name, description) | hr.leave.admin |
PATCH /leave/admin/types/:code → leave_type_save (CL/SL, EL, comp-off and LWP cannot be switched off) |
| Leave admin | Declare or remove a peak season | hr.leave.admin |
POST/DELETE /leave/admin/peak-seasons → peak_season_save / peak_season_delete |
| Leave admin | Set an employee's confirmation date, notice period, weekly off | hr.leave.admin; never one's own |
PATCH /leave/admin/employees/:id → hr_set_employee_leave_profile |
| Leave admin | Record a comp-off | hr.leave.admin; never one's own |
POST /leave/admin/compoff → leave_compoff_credit |
| Leave admin | Opening balance, grant or adjustment (with a reason) | hr.leave.admin; never one's own |
POST /leave/admin/adjustments → leave_adjust |
| Leave admin | Run the accruals by hand (up to today) | hr.leave.admin |
POST /leave/admin/accruals → leave_run_accruals |
| Leave admin | Year end: preview, then close | hr.leave.admin |
GET/POST /leave/admin/year-end → leave_year_end_preview / leave_year_end_run (once a year, after 31 March) |
| Leave admin | Open an employee's register (Register on the Employees row); Download CSV | hr.leave.admin or hr.leave.approve |
GET /leave/register/:userId → leave_register |
| Leave approvals, Leave admin → Register | Cancel this leave on someone else's pending or approved application (with a reason; the days go back) | hr.leave.admin; offered when the detail answers canHrCancel |
POST /leave/applications/:id/cancel → leave_cancel (LV-033) |
| Leave admin | Record an absence for an employee, older than the reporting window, already approved | hr.leave.admin; never one's own |
POST /leave/admin/absences → leave_hr_record_absence (LV-042; audited) |
| Route | Open /leave/holidays (from the Leave page) |
any staff login | <ProtectedRoute allowedRoles={['admin','staff']}>; GET /holidays → holiday_list |
| Holidays | Add, change, confirm or remove a holiday | hr.holidays.manage |
POST/PATCH/DELETE /holidays → holiday_save / holiday_delete (audited) |
6.21 Employee agreement (/me/agreement, /hr/agreements) — EA-001 … EA-019
Screens: Employee agreement.
| Section | Action | Permission | Enforcement |
|---|---|---|---|
| Route | Open /me/agreement (header Settings menu → My agreement, shown with leave.apply) and /me/agreement/:id |
any staff login; one's own agreements only | <ProtectedRoute allowedRoles={['admin','staff']}>; GET /me/agreement → agreement_my; GET /agreements/:id → agreement_get |
| My agreement | Sign (read, tick, typed name, drawn signature, password) | the employee named in it; no permission | POST /agreements/:id/sign → agreement_sign (password checked in the database; five failures lock signing for fifteen minutes) |
| My agreement | Print or save as PDF | whoever may read it | the browser's Print; nothing on the server |
| Every page / app home | The bar "Your employee agreement is waiting for your signature" (and, for a countersigner, how many wait) — EA-016 | any staff login; one's own | badge_counts() (agreementToSign, agreementsToCountersign) and, on the app's home, agreement_waiting_for_me — counts only, no text, no pay |
| Route | Open /hr/agreements and /hr/agreements/:id (sidebar Employee agreements) |
hr.agreements.view |
<ProtectedRoute>; GET /hr/agreements → agreement_hr_list; GET /agreements/:id → agreement_get |
| Employee agreements | Prepare and preview; Send to the CEO for approval (EA-017) | hr.agreements.issue |
POST /hr/agreements/prepare → agreement_prepare; POST /hr/agreements → agreement_issue (status pending_approval) |
| Employee agreements / agreement page | Review and approve / Approve and send; Reject with a reason (EA-017) | hr.agreements.approve; never one's own or one they prepared |
POST /agreements/:id/approve → agreement_approve; POST /agreements/:id/reject → agreement_reject |
| Employee agreements | Pick the reporting officer from the staff; designation suggestions (EA-012, ACC-081) | hr.agreements.issue (or admin.users.view) |
GET /users/:id/reporting-options → staff_reporting_options |
| Employee agreements | Also save these to the employee's profile — the appointment used goes back to the profile on issue (EA-013) | hr.agreements.issue and the right to edit that person's profile: hr.profiles.edit over a staff login that is not an IT Admin or Super Admin (ACC-084), or admin.users.edit over that person (can_manage_user); otherwise the agreement is issued and the write is skipped with the reason |
agreement_issue(…, p_save_to_profile) → agreement_profile_block / admin_update_employee_profile (audited) |
| Employee agreements | Withdraw an unsigned issue (with a reason) | hr.agreements.issue |
POST /agreements/:id/withdraw → agreement_withdraw |
| Employee agreements | Countersign | hr.agreements.countersign; never one's own |
POST /agreements/:id/countersign → agreement_countersign |
| — | Change or delete an agreement | nobody | the triggers EmployeeAgreement_guard, EmployeeAgreement_no_truncate, AgreementTemplate_guard (EA-007) |
6.22 Role requests (/admin/role-requests) — ACC-077 … ACC-080, ACC-082, ACC-083
Routes: handleRoleRequests() in src/lib/roleRequests.ts (Access and roles API). Screens: Admin → Role requests.
| Section | Action | Permission | Enforcement |
|---|---|---|---|
| Route | Open the page (sidebar Role requests needs roles.request); a login without any of the three sees only the requests it made or that are about it |
any staff login | <ProtectedRoute allowedRoles={['admin','staff']}>; GET /role-requests?scope= → role_change_requests (row policy again inside) |
| Header | Request a role change — person, add or remove, role (staff roles only), reason | roles.request |
<PermissionGate>; POST /role-requests → role_change_request_create (one open request per person, role and direction; a double click returns the first) |
| Header | …for an administrative role (add or remove) — the dialog offers it only when GET /role-requests/roles says requestable (ACC-083) |
roles.request and roles.apply |
the trigger RoleChangeRequest_admin_roles_by_admins refuses anyone else: "Only IT Admin or Super Admin can ask for the CEO role." |
| Waiting for the CEO | Approve, or reject with a note — offered only when the row says canApprove (not the requester, not the subject) |
roles.approve |
<PermissionGate>; POST /role-requests/:id/ceo-decision → role_change_ceo_decide; on approval every other active holder of roles.apply gets a work-inbox job (WRK-018) |
| Waiting for an admin | Accept and apply, or reject with a note — offered only when the row says canApply (not the requester, the subject or the approver) |
roles.apply |
<PermissionGate>; POST /role-requests/:id/admin-decision → role_change_admin_decide (writes UserRole in the same transaction; closes the jobs) |
| Mine | Withdraw a request I made that is still open, with a reason | roles.request (the requester) |
POST /role-requests/:id/withdraw → role_change_withdraw |
| History | Applied, rejected and withdrawn requests with who decided what and when | read as above | role_change_requests('history') |
| Work inbox | The admin's job opens this page on the request | work.view |
workSubjectLink('RoleChangeRequest') |
| — | Change a staff role any other way | nobody | the trigger UserRole_staff_role_lock; no INSERT/UPDATE/DELETE grant on UserRole for anon or authenticated (ACC-079) |
7. Sidebar visibility
Staff navigation is one config array, STAFF_NAV_SECTIONS in src/components/layout/navConfig.ts, grouped by job (Wave 2). Each item is shown when hasPermission(item.permission) is true — never by role name — and a section with no visible item is hidden. A staff user who opens a page without its permission sees a 403 page naming the missing permission (ProtectedRoute → Forbidden). The footer shows the user's job title (from their role names — display only). Portal menus (customer, partner) are role-scoped (§6.10, §6.11).
| Section | Nav label | Path | Permission |
|---|---|---|---|
| Home | Home (role dashboard) | /app |
dashboard.view (route still uses the staff/portal split — §8 item 8) |
| Home | Internal chat | /internal/chat |
chat.view |
| Home | Leave (also highlights /leave/holidays) |
/leave |
leave.apply |
| Sales | Customers (also highlights Customer 360 /customers/:id) |
/sales/customers |
customers.view |
| Sales | Leads | /sales/leads |
leads.view |
| Sales | Quotations | /sales/quotations |
quotations.view |
| Sales | Bookings | /sales/bookings |
bookings.view |
| Sales | Requests | /requests |
requests.view |
| Sales | Communications | /communications |
customers.view (drift) |
| Sales | /whatsapp |
customers.view (drift) |
|
| Sales | People directory | /people |
customers.view |
| Operations | Work inbox | /work |
work.view |
| Operations | Journey board | /operations/journey |
bookings.view |
| Operations | Groups | /groups |
groups.view |
| Operations | Duty of care | /operations/incidents |
incidents.view (declared by the incidents workstream, INC) |
| Operations | Ops approvals | /approvals |
approvals.view |
| Operations | Corrections | /corrections |
bookings.view (drift — should be approvals.view) |
| Operations | Capacity | /operations/capacity |
groups.view |
| Operations | Cancellation policies | /admin/cancellation-policies |
groups.view |
| Operations | Hotels | /hotels |
hotels.view |
| Operations | Food | /food |
food.view |
| Operations | Ground services | /inventory/ground-transfers |
inventory.view |
| Ticketing | Tickets | /tickets |
tickets.view |
| Ticketing | Airline blocks | /inventory/quota |
inventory.view |
| Ticketing | FIT seats | /inventory/fit |
inventory.view |
| Ticketing | B2B flights | /inventory/b2b-flights |
inventory.view |
| Ticketing | Deadline radar | /inventory/deadlines |
inventory.view |
| Ticketing | Holds | /inventory/holds |
inventory.view |
| Ticketing | Seat releases | /inventory/releases |
inventory.view |
| Visa | Visa pipeline | /visa |
visa.view |
| Visa | Visa applications | /sales/visa |
visa.intake.view |
| Finance | Finance | /finance |
finance.view |
| Finance | Finance approvals | /finance?tab=approvals |
finance.view |
| Partners & Suppliers | Business partners | /agents |
agents.view |
| Partners & Suppliers | Suppliers | /suppliers |
inventory.view (drift — should be suppliers.view) |
| Reports | Reports | /reports |
reports.view |
| Admin | Admin | /admin |
admin.view |
| Admin | Employees & staff | /people/employees |
admin.view |
| Admin | Leave approvals | /leave/approvals |
hr.leave.approve (management and reporting managers reach the page from the Leave page and their notifications; there is no badge count) |
| Admin | Leave admin | /leave/admin |
hr.leave.admin |
| Admin | Employee agreements | /hr/agreements |
hr.agreements.view |
| Admin | Role requests | /admin/role-requests |
roles.request (the route opens for any staff login; a CEO or admin holds roles.request too) |
8. Known drift (as of this audit)
Items where code/matrix/seed don't fully agree. Each is tracked here; fixing them is incremental work.
chat.viewreferenced but unseeded. App.tsx:187 requires it, but no migration inserts it. Fixed in this PR bysupabase/migrations/20260416030000_seed_chat_and_dashboard_view.sql.dashboard.viewreferenced by Sidebar but no route guard uses it./approute relies onallowedRoles={['admin','staff']}instead. Seeded this PR; tightening the route guard is a follow-up./correctionsusesbookings.view. Semantically this is an approvals/ops action; should migrate to a dedicatedcorrections.view. Tracked for a follow-up PR — when wired, addcorrections.viewto §4./communicationsand/whatsappusecustomers.view. Should migrate to a dedicatedcommunications.view. Tracked; when wired, addcommunications.viewto §4./suppliersroute usesinventory.view. Should migrate tosuppliers.view. Tracked; when wired, add to §4.agents.edit/groups.delete/inventory.delete/inventory.allocate/quotations.deletenot yet wired. The API enforces these actions via RLS and role-level grants, but there are no<PermissionGate>wrappers in the UI so row-level buttons remain visible regardless of permission. Tightening UI gates is a follow-up per PR; when added to code, re-add to §4. (agents.deletewas wired and restricted to IT_ADMIN only — see migration20260430140000.)admin.edit/admin.users.*/admin.config.edit/admin.audit.viewnot yet split. User management actions in<UserManagement>aren't separately gated; all live behindadmin.view. Split when refactoring admin surface.- Route-level
allowedRoles=['admin','staff']on/appbypasses the permission matrix for dashboard access. Deliberate since the role dashboards landed (Wave 2D):ProtectedRoutesends users without a permission back to/app, so gating/appitself would loop. Every widget on the page is gated instead (§6.18), and a user who matches no profile is told so. - Partner & customer portals are role-gated only (
allowedRoles={['agent']}/['customer']). Actions inside portals are ownership-gated by the API, not matrix-gated. This is intentional (see §4.5) but means there is no single matrix row describing partner self-service capabilities. - Granular permissions (§4.4) are seeded but mostly not yet wired in code. Migration
20260416050000inserts all ~70 sectional permissions and grants the right role bundles (e.g.finance.reports.profit_loss.viewto FINANCE_MANAGER but not ACCOUNTANT). The PermissionsMatrix admin UI surfaces them so admins can already toggle per-role. Currently wired: finance report button filtering inFinance.tsx(the user's explicit Accountant-vs-P&L example works). Not yet wired: export-button gates, workflow-split checks (e.g.finance.payments.verifystill goes through coarsefinance.create), admin tab-level splits (user management still behindadmin.view), agent CRUD splits. TheALLOWED_DOC_ONLYlist insrc/test/permissions.matrix.test.tsenumerates exactly which permissions are seeded-but-unwired; each follow-up PR that wires a batch removes its entries from that list.
9. Governance (how this stays true)
When you add a page/section/action:
1. Add a row to §6 under the appropriate page.
2. If the permission is new, add it to §4 catalog and §5 grants matrix.
3. Add the corresponding row(s) to a new seed migration under supabase/migrations/.
4. Reference the permission in code:
- Frontend: <PermissionGate permission="x.y"> around the action
- Backend: await requirePermission('x.y') at the top of the handler
- Route: <ProtectedRoute requiredPermissions={['x.y']}> if gating a whole page
5. Run npm test — the drift test will catch missing seed rows, orphaned permissions, or code that references permissions not in this file.
When you rename a permission:
1. Update §4 and §5 in this file.
2. Update every code reference in the same PR (grep the old name).
3. Add a migration that renames in the DB (UPDATE "Permission" SET name = ...) AND updates RLS policies referencing the old name.
4. Run npm test.
When you delete a permission:
1. Remove every code reference first.
2. Remove from §4 and §5.
3. Add a migration that drops the RolePermission rows and then the Permission row.
4. Run npm test.
The drift test is in src/test/permissions.matrix.test.ts. It will fail the build if:
- Code uses a permission string not declared in this file
- This file declares a permission that no code references (after a grace list for migrations)
When you write an RLS policy, never write AS RESTRICTIVE alone. A restrictive policy is ANDed onto whatever the permissive policies allow; with no permissive policy for that command there is nothing to AND with, so the command is denied to every caller — the permission holder and the super admin included — and RLS reports it as "no rows", never as an error. Use a plain (permissive) policy for the permission gate, and add AS RESTRICTIVE only for a rule that must hold on top of an existing permissive policy (immutability, a period lock, a deliberate deny-all). public.rls_restrictive_only_commands() lists every table/command that denies outright; supabase/tests/journal_write_paths.sql (FIN-074) fails the build when it returns anything not on its allowlist.
10. Canonical changelog of this matrix
| Date | Migration | Change |
|---|---|---|
| 2026-10-01 | 20261006100000 | Exchange rates are managed by finance and dated (FIN-046, DECIDED 2026-10-01). Added finance.rates.manage — FINANCE_MANAGER, ACCOUNTANT, CEO, GM, SUPER_ADMIN; fin_role_bundle() (CEO, GM) and fin_role_finance_family() (ACCOUNTANT) name it so apply_role_bundles() keeps it. exchange_rates was writable by any staff login (policy "Staff can manage exchange rates", FOR ALL, is_staff_user()); that policy is dropped and INSERT / UPDATE / DELETE are revoked from anon and authenticated — reads are unchanged. New set_exchange_rate() and delete_exchange_rate() (SECURITY DEFINER, fin_require('finance.rates.manage'), audited). POST /finance/fx-rates/refresh moves from finance.edit to finance.rates.manage; the Exchange Rate Management buttons in Finance → Settings and the Exchange Rates tab in Admin → Currencies are gated on it (they were finance.edit and admin.currency.*). IT_ADMIN keeps admin.currency.* for currencies and no longer changes rates. Test: supabase/tests/exchange_rates_are_managed_and_dated.sql |
| 2026-10-01 | 20261006120000 | Finance guards in the database (PTR-030 DECIDED 2026-10-01, ACC-020, ACC-030). Added partners.credit.override (FINANCE_MANAGER, CEO, GM, SUPER_ADMIN); fin_role_bundle() patched in place so CEO and GM keep it (FINANCE_MANAGER is not an exact bundle). The trigger "Booking_credit_limit" refuses any booking write that takes a partner over its credit limit — staff paths included — unless a holder sends a reason (creditOverrideReason, audited as partner_credit_override, never stored). approve_journal_entries skips a reversal whose original the approver made unless finance.journals.approve_own (making a reversal is not refused; post_journal_reversal is unchanged). The ₹25,000 journal limit (finance.approvals.high_value above it) now covers contras, on-account receipts and reversals on custom lines. §4.1, §4.4, §5.1, §5.2, §6.4, §6.8. Test: supabase/tests/finance_guards_in_the_database.sql. |
| 2026-10-02 | 20261006130000 | Notifications share one queue (COMM-037 … COMM-039, DECIDED 2026-10-02). No new permission. Admin → Reminders → Automatic e-mails by category reads with admin.integrations.view (notification_categories()) and switches a category with admin.integrations.edit (notification_category_save(), not paused, audited); hr_leave is refused there — its switch stays LeaveSettings.emailNotifications under hr.leave.admin. New service-role-only functions notify_enqueue, notify_category_enabled, notify_email_ok, booking_notice_recipient, payment_receipt_email_queue, payment_claim_rejected_email_queue; new table NotificationCategory (no browser access). The partner's Payments page reads its own rejected payments under the existing agency row policy. |
| 2026-10-02 | — | Record refund from the airline on a FIT (web). No migration; no permission added, removed or regranted. The FIT payment dialog gains Record refund from the airline on inventory.block_payments.record. New routes: POST /inventory/fit/:id/airline-refund → DB record_airline_block_refund (p_kind: 'fit', unchanged since 20260929200000) and GET /inventory/fit/:id/payment-summary (inventory.view) → DB airline_block_payment_summary; §6 /inventory/fit. |
| 2026-10-02 | 20261006140000 | Invoices, airline refunds and account deletion (finance staff guide review). No permission added, removed or regranted. The web airline block screen gains Record refund from the airline on inventory.block_payments.record (new route POST /inventory/quota-blocks/:id/airline-refund → DB record_airline_block_refund, unchanged since 20260929200000); §4.1, §6.12. The group invoices tab button that makes a draft is now labelled Create draft invoice (group_invoices.create); §4.1 corrected: issuing posts nothing, Post to Finance posts. account_deletion_blockers re-created: a partner's pending payment claim on a group invoice is a blocker, and money reads ₹ (AUD-025). |
| 2026-10-01 | 20261005160000 | The WhatsApp menu (COMM-030 … COMM-036, DECIDED 2026-10-01). No new permission. Admin → Integrations → WhatsApp → WhatsApp menu reads with admin.integrations.view and saves with admin.integrations.edit through the existing /admin/communication-settings route (settings whatsapp_menu_enabled, whatsapp_menu_office_hours, whatsapp_menu_bank_details; default off). New POST /sales/customers/:id/documents/:docId/review → customer_document_review() (customers.edit, not paused). New service-role-only tables WhatsAppConversation, WhatsAppPayLink and functions whatsapp_menu_who, whatsapp_menu_identity, whatsapp_menu_booking_ids, whatsapp_menu_bookings, whatsapp_menu_missing_parts, whatsapp_menu_groups, whatsapp_menu_group, whatsapp_menu_enquiry, whatsapp_pay_link_issue / _use / _release / _order, whatsapp_menu_document_target, whatsapp_menu_document_add, whatsapp_menu_handoff, whatsapp_menu_last_staff_reply. razorpay-order takes { payToken } without a login (the public page /pay/:token). Test: supabase/tests/a_customer_uses_the_whatsapp_menu.sql. |
| 2026-10-01 | 20261005140000 | A customer signs up by chatting on WhatsApp (TRV-017, DECIDED 2026-10-01). No new permission. Admin → Integrations → WhatsApp → Sign-up in the WhatsApp chat: the status uses admin.integrations.view, Set up WhatsApp sign-up form uses admin.integrations.edit — both checked in src/services/whatsappFlowAdmin.ts and again in the new edge function whatsapp-flow-setup (staff only; setup refuses a paused login). New service-role-only tables SignupFlowToken, PasswordSetToken and functions auth_signup_flow_issue, auth_signup_flow_use, auth_password_set_token_issue, auth_password_set_token_use; auth_signup_create_login takes a proof channel (whatsapp_code default, whatsapp_flow). The public page /customer/set-password needs no permission (the link is the proof). |
| 2026-09-30 | 20261004100000 | The screens know whose access you manage (ACC-084, ACC-071, PRF-010). No permission added, removed or regranted. New computed field employee_can_manage(EmployeeProfile) = can_manage_user(auth.uid(), userId), read as canManage on GET /users?view=directory; profile_edit_rights() also returns canManage, so employee_record and admin_user_profile carry it in profileEdit. The Employees ⋯ menu and the record hide the access actions where it is false. §6.15. Test: supabase/tests/screens_know_whose_access_you_manage.sql |
| 2026-09-30 | 20261003235500 | HR edits the profile details of the staff, except an admin's (ACC-084, DECIDED 2026-09-30; EA-013 amended). Added hr.profiles.edit (ADMIN_HR, SUPER_ADMIN). fin_role_bundle() not re-created: ADMIN_HR is not an exact bundle and fin_role_revokes('ADMIN_HR') does not name it. New employee_profile_hr_fields(), admin_account_permissions() (roles.apply, admin.permissions.edit), user_is_admin_account(), user_is_staff_login(), profile_edit_rights(). admin_update_employee_profile accepts admin.users.edit + can_manage_user (every field, as before) or hr.profiles.edit (profile details of a non-admin staff login, not oneself); agreement_profile_block asks the same, so HR's agreement issue saves the appointment back for non-admin staff. admin_user_profile, employee_record and the update's answer carry profileEdit. PATCH /users/:id/profile accepts either permission. §4.6, §5.7, §6.15, §6.21. Test: supabase/tests/hr_edits_profile_details.sql |
| 2026-09-30 | 20261003190000 | Only an admin asks for an administrative role (ACC-083, DECIDED 2026-09-30). No permission added, removed or regranted; roles.apply stays with IT_ADMIN and SUPER_ADMIN only. New role_administrative_permissions() and role_is_administrative(role): a role is administrative when its bundle holds any of admin.users.create / edit / delete / reset_password, admin.mfa.reset, admin.permissions.edit, admin.roles.create / edit / delete, roles.request / approve / apply, admin.config.edit, admin.integrations.edit, admin.edit — today ADMIN_HR, CEO, GM, IT_ADMIN, SUPER_ADMIN. The trigger RoleChangeRequest_admin_roles_by_admins refuses a request to add or remove one unless the requester holds roles.apply; admin-users create_user refuses such a role before the login is made. role_change_roles() now returns [{ name, administrative, requestable }]. |
| 2026-09-30 | 20261003165000 | A staff role changes only with two approvals (ACC-077 … ACC-080, ACC-082). Added roles.request (ADMIN_HR, GM, CEO, IT_ADMIN), roles.approve (CEO) and roles.apply (IT_ADMIN); SUPER_ADMIN all three. fin_role_bundle() re-created (CEO and GM split; IT_ADMIN's bundle gains two). admin.users.edit no longer covers roles; the UserRole insert guarded / delete guarded policies (which let admin.permissions.edit change roles in one step) are dropped and INSERT/UPDATE/DELETE on UserRole revoked from anon and authenticated. The trigger UserRole_staff_role_lock refuses any write of a staff role outside role_change_admin_decide() — for browser sessions, the service role and other definer functions alike; CUSTOMER, AGENT and TOUR_LEADER keep their flows; a direct database session passes with app.role_change_maintenance = 'on'. admin-users assign_role / remove_role file a request for a staff role; create_user makes the login with no staff role and files requests for the roles chosen. POST /users/:id/roles and roleIds on PUT /permissions/users/:id answer 410. New table RoleChangeRequest, page /admin/role-requests, work type role_change. §4.7, §5.8, §6.15, §6.16, §6.22. Test: supabase/tests/a_role_changes_with_two_approvals.sql |
| 2026-10-02 | 20261008125500 | The CEO approves an employee agreement (EA-017). New permission hr.agreements.approve for CEO and SUPER_ADMIN (§5.7), named in the CEO's exact bundle. agreement_issue (hr.agreements.issue) now prepares (pending_approval); agreement_approve / agreement_reject (hr.agreements.approve) decide, never one's own or one the caller prepared; agreement_my / agreement_get hide a pending version from the employee; agreement_hr_list and GET /hr/agreements also accept hr.agreements.approve. Routes POST /agreements/:id/approve, POST /agreements/:id/reject. Tests: an_agreement_is_signed_and_kept.sql, every_agreement_step_is_emailed.sql. |
| 2026-10-02 | 20261008120000 | New partners and suppliers wait for approval (PTR-095, PTR-096, PTY-009, PTY-010). New permissions partners.approve and suppliers.approve for GM, CEO, IT_ADMIN and SUPER_ADMIN (§5.9), named in fin_role_bundle(). Every new partner is pending (the column default, and the trigger Agent_approval_guard forces a browser insert to it); deciding a pending partner, or making active one never approved, needs partners.approve — in the trigger for a browser write and in partner_set_status. A new supplier is pending (Supplier.approvalStatus); the browser cannot write the approval columns; supplier_decide (suppliers.approve) approves or rejects; a hand-entered SupplierTransaction for a supplier not approved is refused. New routes POST /agents/:id/status and POST /suppliers/:id/decision. A new partner or supplier e-mails sales@alhudatravels.in (category onboarding) and puts a notice in each approver's inbox; the decision e-mails sales@. Test: supabase/tests/new_partners_and_suppliers_wait_for_approval.sql. |
| 2026-10-02 | 20261008140000 | Booking screens: the browser checks what the database checks (ACC-020, LC-020, PTR-023). No permission added, removed or regranted. POST /finance/bookings/:id/finance-approve now accepts approvals.approve or finance.bookings.approve_finance to approve and approvals.approve or finance.bookings.reject_finance to reject or send back — what finance_decide_booking already accepted. Assigning a correction owner is assign_booking_correction (approvals.approve, in the function); the browser's row write needed bookings.edit. A booking-level cancellation approval is one call to approve_booking_cancellation (bookings.cancel.approve). A partner resubmits its own sent-back booking (partner_resubmit_booking; resubmit_booking accepts the owning partner in place of bookings.edit). Test: supabase/tests/booking_screens.sql. |
| 2026-10-02 | 20261008170000 | Work → Queues: members and leads are named on a screen (WRK-013, WRK-009). Added work.queues.manage — CEO, GM, SUPER_ADMIN (trigger); named in the CEO and GM bundles of fin_role_bundle(). New work_queue_members_list() (work.view), work_queue_member_save() and work_queue_member_remove() (work.queues.manage, checked in the function; audited by audit_workqueuemember). Routes GET /work/queues/members, PUT / DELETE /work/queues/:queueId/members/:userId. Test: supabase/tests/work_queue_members.sql. |
| 2026-10-03 | 20261003195000 | The appointment comes from the staff record; a reporting officer is one of the staff (EA-012, EA-013, ACC-081). No new permission. New column EmployeeProfile.parentName (father's / guardian's name), edited through admin_update_employee_profile (admin.users.edit) and read through profile_of. staff_reporting_options() (admin.users.view or hr.agreements.issue) lists who a person may report to. agreement_issue gains p_save_to_profile: the appointment used is written back through admin_update_employee_profile, only when the caller holds admin.users.edit and can_manage_user — issuing never widens who may edit a profile. The trigger EmployeeProfile_reports_to_guard refuses a non-staff officer, oneself and loops (two CEOs may report to each other). §6.21, §6 User mgmt. Test: supabase/tests/the_appointment_comes_from_the_staff.sql |
| 2026-10-03 | 20261003180000 | Staff do not pay online (FIN-032, TRV-009, PTR-093 — DECIDED 2026-09-30). No permission added, removed or regranted. online_payment_actor() no longer answers staff for a login with finance.payments.record: only customer (the booking's customer or payer) or partner (its agency). razorpay-order refuses a staff login with 403. The app's staff booking screen loses Pay online. §6.5d has the row. Test: supabase/tests/staff_do_not_pay_online.sql. |
| 2026-09-30 | 20261003234500 | Automatic WhatsApp notices: booking confirmed, payment received with the receipt PDF (COMM-020 … COMM-025, AUD-024 amended). No new permission. Admin → Reminders → Automatic WhatsApp notices reads with admin.integrations.view (whatsapp_notice_settings()) and changes with admin.integrations.edit (whatsapp_notice_settings_save(), a paused login refused); both check in the database. whatsapp_delivery_failures() also lists failed notices (still admin.integrations.view). WhatsAppNoticeSetting and BookingWhatsAppNotice have no browser access; booking_whatsapp_notice_queue/claim/finish() and whatsapp_notice_template() are service role only. The edge function whatsapp-booking-notice takes the service-role key only; issue-document answers a service-role call for a receipt only. Test: supabase/tests/whatsapp_booking_notices.sql. |
| 2026-09-30 | 20261003220000 + 20261003220100 | Every booking e-mail names the booking; booking reminder e-mails (COMM-001 … COMM-014). No new permission. New booking_email_summary() is service role only (the mailer checks a browser caller can read the booking first). Admin → Reminders reads with admin.integrations.view (booking_reminder_settings()) and changes with admin.integrations.edit (booking_reminder_settings_save(), a paused login refused); both check in the database. BookingReminderSetting and BookingReminderLog have no browser access. booking_reminders_run() and its helpers are service role only (pg_cron job alhuda-booking-reminders). Tests: supabase/tests/every_booking_email_names_the_booking.sql, supabase/tests/booking_reminder_emails.sql. |
| 2026-10-03 | 20261003110000 | WhatsApp templates are copied from Meta (AUD-024). No new permission. The template list uses admin.integrations.view; Sync from Meta uses admin.integrations.edit, checked in src/services/whatsappTemplateAdmin.ts and again in the new edge function whatsapp-templates-sync (staff only, a paused account refused; the nightly pg_cron job alhuda-whatsapp-templates-sync presents the dispatch key instead). New whatsapp_templates_sync_run() is service role only. WhatsAppTemplate RLS is unchanged. Test: supabase/tests/whatsapp_templates_come_from_meta.sql. |
| 2026-09-29 | 20261003094100 – 20261003094300 | Leave, the holiday calendar and the employee agreement (LV-001 … LV-062, EA-001 … EA-011). Added leave.apply (every staff role, not TOUR_LEADER, AGENT or CUSTOMER), hr.leave.approve, hr.leave.admin, hr.holidays.manage, hr.agreements.issue (ADMIN_HR), hr.leave.approve_peak, hr.agreements.countersign (CEO, GM), hr.agreements.view (ADMIN_HR, CEO, GM, AUDITOR); SUPER_ADMIN all. fin_role_bundle() re-created so apply_role_bundles() keeps them in the exact bundles. The leave and agreement tables grant SELECT only, under row security (own rows, the routed approver, the reporting manager, or the HR permission); every write is a SECURITY DEFINER function; the anonymous key reaches nothing (ACC-053). The ledger, the timeline and the year close are append-only; an application and an agreement are never deleted. §4.6, §5.7, §6.20, §6.21. Tests: supabase/tests/leave_follows_the_agreement.sql, supabase/tests/an_agreement_is_signed_and_kept.sql |
| 2026-10-02 | 20261002110000 | A WhatsApp reset code goes to the number typed; Meta's refusal is kept (ACC-069). No new permission. New whatsapp_delivery_failures() (SECURITY DEFINER, checks admin.integrations.view, anon revoked) and route GET /admin/whatsapp-delivery-failures; AuthLoginAttempt stays service-role only. auth_reset_contact(p_user_id, p_typed_phone) and auth_login_record(…, p_delivery_error_code, p_delivery_error) replace the shorter versions, service role only. Test: supabase/tests/a_reset_code_goes_to_the_number_typed.sql. |
| 2026-09-28 | 20261001220000 | A family is recorded per departure, never assumed (PAX-036); a minor names a guardian (PAX-021). No new permission: recording families, "not family" and guardians uses bookings.edit (it edits a booking's travellers; every role with groups.edit holds it); the relationship list uses admin.config.edit (the right that keeps the passenger-category settings). A partner records them for their own travellers only — the SECURITY DEFINER functions (family_save, family_remove_member, family_dissolve, traveller_set_family_status, travellers_set_family_status, traveller_set_guardian) check bookings.edit or the partner's ownership; none executable by anon. New tables FamilyRelationship, TravelFamily, TravelFamilyMember: read by staff with the rights that read BookingPassenger, by a partner or customer for their own travellers; no browser writes. BookingPassenger.familyStatus and guardianPassengerId cannot be written by a browser session. New routes under /families. Test: supabase/tests/families_per_departure.sql. |
| 2026-09-27 | 20261001190000 | A group is managed per traveller; the group leader is a person (PAX-035). No new permission. set_group_leader (groups.edit), place_travellers (bookings.edit), request_passenger_cancellation (bookings.cancel) and transfer_traveller_to_group (booking.transfer) — SECURITY DEFINER, each checks its right, none executable by anon. New routes POST /groups/:id/leader (groups.edit) and POST /groups/:id/travellers/place (bookings.edit). TravelGroup.groupLeaderPassengerId cannot be written by a browser session. The booking-level "Mark as Leader" is gone; the group page's leader picker no longer reads every customer, partner and user. Test: supabase/tests/a_group_works_per_traveller.sql. |
| 2026-01-15 | 20260115000000 | Initial 22-permission seed + admin-tier grants |
| 2026-04-02 | 20260402210000 | Added groups.view/edit/delete |
| 2026-04-15 | 20260415200000 | Permission-aware RLS on Booking/Customer/Agent/Supplier/Payment/Journal*/FinanceConfig |
| 2026-04-15 | 20260415210000 | Added customers.edit/delete, partners.*, suppliers.*, finance.edit, admin.edit + functional subsets |
| 2026-04-15 | 20260415220000 | Seeded all 13 functional role bundles |
| 2026-04-16 | 20260416010000 | Added *.view family + finance.payments.record; broadcast to staff roles |
| 2026-04-16 | 20260416020000 | Removed CEO/GM/IT_ADMIN role-name bypass from auth_user_has_permission(); grant every permission to those roles explicitly |
| 2026-04-16 | 20260416030000 | Added chat.view and dashboard.view to close drift with code |
| 2026-09-17 | 20260917120000 | AuditLog append-only (no UPDATE/DELETE/TRUNCATE for any role); raw SELECT restricted to admin.audit.view; scoped timelines via get_audit_timeline (bookings.view / customers.view / groups.view) |
| 2026-09-18 | 20260918110000 | Money integrity (Wave 1B): added finance.refunds.approve (CEO, GM, IT_ADMIN, ADMIN_HR, DIRECTOR, FINANCE_MANAGER). Payments recorded pending via record_payment and verified by another user via verify_payment; refunds via refund_payment / request_booking_refund + approve_refund; journal decisions via approve_journal_entries (finance.journals.approve / .reject / .bulk_approve); B2B cancellation decisions via decide_b2b_cancellation (finance.cancellations.approve_b2b); year close via close_financial_year (finance.years.close); rebuild gated on finance.ledger.rebuild. Posted vouchers, their lines, posted ledger entries and supplier transactions cannot be edited or deleted (service-role break-glass only); period lock on insert and update by entryDate; AccountingPeriod writes need the period permissions; B2BFlightOfferCancellation no longer open to every signed-in user; FinanceConfig readable again; ledger reset, GL force-delete and mark-paid removed |
| 2026-09-28 | 20261001234000 | PTR-084 … PTR-090 (partner profile). No new permission. PartnerProfile, PartnerContact, PartnerNote read with agents.view (contacts also by the partner for its own agency); written only by functions: partner_update_profile_admin, partner_contact_save / partner_contact_remove, partner_note_add, partner_add_document_for, partner_remove_document (partners.edit); partner_update_my_profile and the contact functions for a partner's own agency (auth_agent_id()); partner_list_extras (agents.view). Notes are append-only by trigger. |
| 2026-09-25 | 20260929230000 | PTR-083 / ACC-071 (records). No new permission. partner_record (agents.view), employee_record (admin.users.view), partner_document_review and partner_set_status (partners.edit), employee_document_review (admin.users.edit, and never above your station). A record's activity shows scoped audit rows without their values (AUD-004). |
| 2026-09-25 | 20260929140000 | ACC-070 / ACC-071. No new permission. user_has_permission() answers false for a paused account unless the permission's last segment is view, export or read; is_staff_user() now means "staff who may write". admin.users.view is wired (GET /users/:id/profile, admin_user_profile, admin_users_list, the app's Users screen). admin_pause_user / admin_resume_user take admin.users.edit, or partners.edit for a partner login, or customers.edit for a traveller login. |
| 2026-09-18 | 20260918130000 | Wave 1D: added communications.send (SALES_MANAGER, SALES_EXEC, OPS_MANAGER, OPS_EXEC, VISA_OFFICER, CEO, GM, IT_ADMIN); /operations/communications send moved from admin.users.edit to it. admin.integrations.view/edit/test wired (secrets write-only in IntegrationSecret; edge functions whatsapp-send, integration-test). Ticket issue/exception/name functions (tickets.edit/tickets.approve, maker-checker), visa status/intake/document functions (visa.edit, visa.intake.convert — intake PATCH no longer needs only visa.intake.view), update_customer_request (requests.edit); GET /requests needs requests.view |
| 2026-09-18 | 20260918100000 | Data isolation & portal functions (Wave 1A): staff read business tables by *.view permission sets; customers/partners read only their own/agency rows; is_staff_user() excludes AGENT and inactive users; open writes on BookingPassenger, GroupFlight, GroupExpense, B2B offers/passengers/cancellations, CancellationPolicy, FITInventory, CustomerDocument/Passenger become staff-only; portal actions via SECURITY DEFINER functions (§4.5); try_increment_group_booked_count / release_group_booked_count staff-only and no anon execute; b2b_transfer_seats requires inventory.create or inventory.edit; anon lost table grants and reads open groups via public_groups() |
| 2026-09-17 | 20260917150000 | Security lockdown part 1: no self-grant / no granting permissions or roles you don't hold (UserRole, RolePermission, UserPermission); Role/Permission catalogue migration-only; open UPDATE/DELETE removed from JournalEntry/JournalLine/LedgerEntry; UserSession own-rows; finance config tables permission-gated; WhatsApp + CommunicationQueue staff-only; User credential columns unreadable; deactivated users lose permissions; finance PIN verified in DB with lockout; portal self sign-up via self_assign_portal_role / self_register_customer. Edge functions admin-users, permissions-admin, auth-admin-mfa-reset, signed-url, mailer, extract-passport, finance-copilot, upload-*, secret-selftest now authorize server-side; whatsapp-webhook verifies Meta signatures |
| 2026-09-19 | 20260919100100 + 20260919100200 | F1 role bundles (owner decision 2026-09-17). New roles SUPER_ADMIN (the only role with every permission; a trigger grants it every permission added later) and CHARTERED_ACCOUNTANT. CEO/GM cut back to the leadership bundle (all *.view/*.export, business approvals, finance.supplier_transactions.correct); IT_ADMIN to system settings only; ADMIN_HR loses finance.*, approvals.*, group_invoices.*, bookings.cancel.approve, accounts.delete, group_pricing.edit, admin.integrations.edit, admin.mfa.reset; CASHIER keeps only finance.view + finance.payments.record; ACCOUNTANT loses approvals, period lock/unlock/close, year close and config; OPS_MANAGER loses journal approval and refund payout; SALES_MANAGER / B2B_MANAGER lose bookings.cancel.approve and refund payout. New permission finance.supplier_transactions.correct (ACCOUNTANT, CHARTERED_ACCOUNTANT, CEO, GM, SUPER_ADMIN). Break-glass finance.journals.approve_own / reverse_own, finance.ledger.rebuild/reset, finance.years.close, finance.periods.close, accounts.delete, admin.permissions.edit, admin.roles.*, admin.users.delete, agents.delete are super-admin only |
| 2026-09-19 | 20260919100700 | F1 approval limits (ACC-030): ApprovalLimit table with the working defaults, new permission finance.approvals.high_value (CEO, GM, SUPER_ADMIN). Enforced for customer refunds and manual journals; the other rows are configuration only for now. CEO/GM bundle also keeps the duty-of-care incident actions |
| 2026-09-19 | 20260919100300 – 20260919100600 | F1: recording money needs finance.payments.record and a booking refund payout finance.payments.refund; supplier transactions, TDS, financial years and finance configuration gated by permission (finance.supplier_transactions.correct for corrections); one atomic counter per document series; opening balances become vouchers and reports check the report's own *.view permission |
| 2026-09-19 | 20260919100000 | F1 journal controls: browser writes can only create pending vouchers and can never approve one; every reversal links to its original, follows its status and is capped at the original (post_journal_reversal); B2B cancellation vouchers post through post_decision_journal |
| 2026-09-19 | 20260919130000 / 20260919130100 | Duty of care (Wave 2C): added incidents.view, incidents.manage, incidents.close, incidents.confirm_death (CEO, GM: all four; OPS_MANAGER: view/manage/close; OPS_EXEC: view/manage; AUDITOR: view; IT_ADMIN/ADMIN_HR deliberately not granted — health data). Incident tables are readable only with incidents.view and writable only through open_incident, assign_incident_owner, update_incident_details, update_checklist_item, add_incident_checklist_item, add_incident_update, add_incident_document, change_incident_status (close needs incidents.close) and set_traveller_state (deceased needs incidents.confirm_death plus the typed incident number). BookingPassenger.travellerState can be set only through an incident; the private incident-documents bucket is readable with incidents.view and writable with incidents.manage |
| 2026-09-18 | 20260918120000 | Booking lifecycle (Wave 1C): status changes only via submit_booking (bookings.create/bookings.edit), ops_decide_booking (approvals.approve), finance_decide_booking (approvals.approve or finance.bookings.approve_finance / finance.bookings.reject_finance), resubmit_booking (bookings.edit), apply_booking_cancellation_status (bookings.cancel.approve); create_booking (bookings.create; typed price on a rate-sheet group needs group_pricing.edit); move_booking_to_group (groups.edit/booking.transfer/bookings.edit); delete_booking (bookings.delete, DRAFT without payments); release_passenger_allocations; PassengerCategoryConfig readable by all signed-in users, editable with admin.config.edit; cancellation approver ≠ requester; try_increment_group_booked_count revoked from anon. No new permissions |
| 2026-09-20 | 20260920100200 | Inventory counter integrity (Wave 3 I-A): added inventory.counters.repair (CEO, GM, IT_ADMIN, ADMIN_HR, DIRECTOR, OPS_MANAGER). Every inventory counter — airline block and FIT seats, hotel rooms and beds, meal-days, vehicle capacity — is now derived in the database and an over-allocation is refused rather than clamped (INV-012/INV-013). inventory_drift_report() is read-only and needs inventory.view; inventory_repair_drift() needs inventory.counters.repair, demands a reason and writes to InventoryDriftRepair (readable with inventory.view, never writable from a session). Airline blocks and FIT gain archivedAt/archivedBy/archiveReason with archive_*/restore_* functions on inventory.edit; a database trigger refuses deleting either when passengers, tickets, released seats, cancellations, B2B offers, finance rows or recorded edits hang off it (AIR §26) |
| 2026-09-27 | 20261001130000 | A verified payment has a receipt (FIN-045); a document is shared from the phone (TRV-015). No new permission. issue-document takes kind receipt (finance.view or bookings.view, or the booking's customer / payer / partner — checked in the function); IssuedDocument.kind and issue_document_record() accept receipt (a verified payment of the booking); the IssuedDocument select policy adds kind = 'receipt' AND auth_user_has_permission('finance.view'). §6.5d has the rows. Test: supabase/tests/a_receipt_is_a_document.sql |
| 2026-09-26 | 20260930200000 | A passenger sees their trip (TRV-001 amended). No permission changes, no row policy changes. New auth_passenger_ids() / auth_passenger_booking_ids() (SECURITY DEFINER, own login, anon revoked); trv_my_trips, trv_is_my_group, trv_set_location_sharing, trv_submit_passport (own row only) and trv_my_documents accept a passenger; the money and the other passengers' details are withheld. Test: supabase/tests/a_passenger_sees_their_trip.sql |
| 2026-09-26 | 20260930190000 | A closed departure is on sale nowhere (TRV-008). No permission changes. public_departures() and public_groups() leave out a departure whose statusOverride is cancelled, completed or departed; both answer fewer rows with the same columns and grants. Test: supabase/tests/the_traveller_app_works.sql, which also runs the traveller's and the tour leader's app reads as each kind of login |
| 2026-09-26 | 20260930030000 | inventory.release.file (SUPER_ADMIN, TICKET_MANAGER, TICKET_EXEC, finance.create roles): ticketing records an approved release's airline filing; file_airline_cancellation admits it only inside complete_seat_release; finance still settles the refund |
| 2026-09-26 | 20260930000000 | Ticketing owns its blocks (AIR §30): inventory.release.approve_own added (SUPER_ADMIN, TICKET_MANAGER) — a self-decided release is marked and audited; inventory.create granted to TICKET_MANAGER and TICKET_EXEC; the create-block deposit needs finance.create or inventory.block_payments.record |
| 2026-09-20 | 20260920110200 | Wave 3 I-B (holds, deadlines and seat release): added inventory.holds.manage (TICKET_MANAGER, OPS_MANAGER, OPS_EXEC, SALES_MANAGER, SALES_EXEC, B2B_MANAGER, B2B_EXEC), inventory.release.request (TICKET_MANAGER, OPS_MANAGER, OPS_EXEC), inventory.release.approve (TICKET_MANAGER, OPS_MANAGER) and inventory.release.approve_large (CEO/GM/IT_ADMIN only, via the super-admin trigger). TICKET_MANAGER also gained inventory.view and inventory.edit — AIR §30 makes it the owner of airline blocks and it previously held no inventory permission, so the sidebar linked it to pages it could not open. Holds, seat releases, penalty bands and deadline alerts are read through inventory.view / tickets.view / finance.view RLS and written only through SECURITY DEFINER functions (20260920110100); the ACC-030 seat limit lives in FinanceConfig.seatReleaseApprovalSeatLimit (default 10) |
| 2026-09-21 | 20260921110000 | Disaster recovery — the access model now survives a restore. Inserts a Role row for every RoleType value (driven off pg_enum) and re-applies this whole matrix as an explicit manifest. Before it only SUPER_ADMIN and CHARTERED_ACCOUNTANT were created by a migration, so on a database built from migrations alone every grant migration's JOIN public."Role" matched nothing and all grants for the other 18 roles were skipped silently: 2 roles / 167 permissions / 204 grants instead of 20 / 171 / 949. Added the four permissions that were referenced in code and documented in §4 but inserted by no migration — reports.view, admin.view, hotels.view, agents.delete — which made /reports, /admin and /hotels answer 403 to everyone on a fresh database, super admin included. Those three view permissions, and groups.view (previously leadership-only), now follow one rule: whoever holds a right in a module can open it, so ops, sales, B2B, visa and finance can reach the groups and reports they work on. The manifest is the post-F1 end state — nothing 20260919100100/100200/100300 revoked is handed back, with guards on the revoked sets on top. Additive and idempotent. Checked by supabase/tests/access_model.sql (run it after every restore) and by the migration-coverage tests in src/test/permissions.matrix.test.ts |
| 2026-09-21 | 20260921120000 | Release-rehearsal fixes — two rules that held on a seeded database but not on production data. Found by restoring the 2026-09-18 production backup into a local Postgres and running this release against it (docs/operations/release-rehearsal-2026-09-18.md). (1) Production carried an RLS policy auth_access (FOR ALL TO authenticated USING true) on AuditLog that no migration in this repo creates — it was applied to the project out of band. Permissive policies are OR'd, so 20260917120000's admin.audit.view restriction changed nothing there and every signed-in user, portal logins included, could still read the whole audit trail (AUD-004). This migration drops every policy on AuditLog except the two the release owns, so any other out-of-band policy goes too. (2) Production carried five RolePermission rows granting the portal roles staff permissions — AGENT → bookings.view, customers.view, quotations.view, dashboard.view and CUSTOMER → dashboard.view. §4/§5 have always said portal roles hold none, and apply_role_bundles() leaves portal roles alone (their bundle is empty), so nothing in the release cleared them; they are deleted here (ACC-001, ACC-012). No permission is added or removed. Checked by supabase/tests/access_model.sql (ACC-048) and supabase/tests/audit_trail.sql, both of which fail on restored production data without this migration |
| 2026-09-22 | 20260922100000 + 20260922100100 | Restrictive-only RLS policies denied writes to everyone, and three write permissions reached no operational role. (1) Eleven table/command pairs across nine tables carried a RESTRICTIVE policy with no permissive partner. PostgreSQL ANDs restrictive policies onto the permissive result, so with no permissive policy the command is denied to every caller, super admin included, and RLS reports it as "no rows" rather than an error — invisible until someone writes. Repaired by adding the permissive partner each restrictive policy was written to AND with, carrying the identical permission expression, on BookingPassengerMeal, BookingPassengerGroundService, GroupTemplate, JournalAttachment, JournalTemplate, exchange_rate_snapshots, VisaGroup, VisaIntakeRequest and VisaIntakeAttachment. InventoryDriftRepair's three false policies are a deliberate deny-all (append-only through its repair function) and are kept. The journal, journal-line and group-invoice write policies are re-asserted permissive so a database where the re-runnable 20260415200000 was replayed out of order converges instead of silently posting nothing to the ledger. No permission expression is relaxed; journal immutability (FIN-031), the pending-only rule for browser writes (FIN-032) and the accounting-period lock stay with their triggers. New guard rls_restrictive_only_commands() must return zero rows. (2) groups.edit → OPS_MANAGER, OPS_EXEC, SALES_MANAGER, TICKET_MANAGER; groups.view → TICKET_MANAGER; bookings.edit → OPS_MANAGER, OPS_EXEC, TICKET_MANAGER; suppliers.edit → ACCOUNTANT, FINANCE_MANAGER (§5.2 note). Nothing the F1 bundles revoked is handed back. Checked by supabase/tests/journal_write_paths.sql (FIN-060…FIN-077) |
| 2026-09-25 | 20260929130000 | An online payment starts with an order (FIN-032, TRV-009, PTR-081, ACC-063). No new permission. OnlinePaymentOrder (service role writes; authenticated reads only its own side under RLS — auth_customer_booking_ids(), auth_agent_booking_ids(), or finance.view / bookings.view); online_payment_actor(p_booking_id) answers customer / partner / staff (finance.payments.record) / null for the caller's session, anon revoked; the sign-in throttle gains action razorpay_order. Edge function razorpay-order; razorpay-webhook marks the order paid. §6.5d has the rows. |
| 2026-09-25 | 20260929120000 | An e-ticket and a hotel voucher are files (TRV-013). No new permission: issuing is gated on tickets.view + bookings.view (e-ticket) or hotels.view + bookings.view (hotel voucher), both checked in the edge function issue-document with user_has_permission. New table IssuedDocument (audited; RLS SELECT for the booking's customer via auth_customer_booking_ids(), its partner via auth_agent_id(), or bookings.view; no client write; anon revoked) and issue_document_record() (service role only). trv_my_documents() carries the live file on ticket and hotel rows; drive-file gains kinds ticket, hotel, issued. §6.5d has the rows. |
| 2026-09-25 | 20260929100000 | The partner in the app (PTR-004, PTR-080 … PTR-082). No new permission. auth_is_staff() and auth_identifier_resolve() now say staff = a staff role and no portal role, so a partner's login given TOUR_LEADER stays a partner (portal door, agency isolation, field.* only); isPortalOnlyUser in the edge functions agrees. New partner functions partner_my_account, partner_add_document, partner_submit_payment (each takes the partner from the session; anon revoked); AgentDocument.docType gains gst_certificate, address_proof, cancelled_cheque. Functions partner-signup and upload-partner-doc. §6.5d lists the screens. |
| 2026-09-25 | — | The traveller as a customer (TRV-007 … TRV-009). No migration and no permission: sign-up in the app (self_register_customer), the tours (public_departures, public_groups), the booking request (customer_create_request, type group_interest), quotations (customer_respond_quotation), payment claims (customer_submit_payment) and the profile (customer_update_profile) are the portal functions of 20260917150000, 20260918100000 and 20260926150000, each taking the customer from the session. §6.5d lists the screens. |
| 2026-09-25 | 20260928220000 | The traveller's app (TRV-001 … TRV-006, FLD-006). Added programme.manage (SUPER_ADMIN and every role holding groups.edit: CEO, GM, SALES_MANAGER, OPS_MANAGER, OPS_EXEC, TICKET_MANAGER, ADMIN_HR) for the step-by-step guide (TripGuideStep, trv_upsert_guide_step, trv_delete_guide_step). No other permission: the programme of a departure (GroupActivity, GroupNotice) is written with groups.edit or by the group's leader and read by the group's travellers, partner, leader and groups.view (trv_trip_programme); live location (TravellerLocationShare, TravellerLocation) is switched on only by the traveller's own login and read by the leader and groups.view (fld_group_locations); a passport scanned in the app (TravellerPassportSubmission, trv_submit_passport) is applied only with bookings.edit (trv_review_passport_submission). All six tables are closed to anon and writable by nobody but their functions (ACC-053). Test: supabase/tests/the_app_for_travellers.sql |
| 2026-09-25 | 20260929000000 | A traveller sees their own documents (TRV-011). No new permission. trv_my_documents() (SECURITY DEFINER, authenticated only) lists the caller's own visa cases and files, e-ticket records (no PNR), hotel stays (no price) and customer documents; it widens no row policy — the hotel tables stay staff-only and are read only inside the function. Edge function drive-file opens a file for the caller when trv_my_documents() lists it, or for staff with customers.view, visa.view or bookings.view, as a ten-minute signed link; Drive files are cached in the private bucket traveller-documents (no client policy). §6.5d has the rows. |
| 2026-09-23 | 20260923110000 | Flexible cancellation policies (PRC-020 / PRC-023). Added cancellation_policies.manage (CEO, GM, SALES_MANAGER, OPS_MANAGER, FINANCE_MANAGER; SUPER_ADMIN by trigger). Changing a policy no longer needs admin.edit, and the tables no longer accept writes from any staff user — RLS on CancellationPolicy, CancellationPolicySlab and the new CancellationPolicyComponent now checks the permission. The page opens with groups.view and moved to Operations in the sidebar. cancellation_quote (needs bookings.cancel, bookings.cancel.approve or the new permission) works out every cancellation charge; save_cancellation_policy saves a policy in one transaction. Checked by supabase/tests/cancellation_policy.sql and ACC-049 in access_model.sql |
| 2026-09-24 | 20260924130000 | A transfer moves the passenger and their money together (LC-030, PRC-004, PRC-005). transfer_passenger_to_booking checks booking.transfer — the permission the transfer route already asks for — and posts the receivable move and any price difference as the system, pending for a second person (FIN-032). No finance permission is widened. BookingPassengerTransfer is readable with bookings.view or finance.view and written only by the function. No permission is added or removed. Checked by supabase/tests/transfer_price_override.sql |
| 2026-09-25 | 20260929145900 + 20260929150000 | Ticketing pays for its blocks, finance approves (FIN-044); the Ticketing Executive (AIR §30); a seat sale from the phone (INV-014). Added role TICKET_EXEC (functional; exactly tickets.view/edit, bookings.view/edit, groups.view, inventory.view/edit, inventory.block_payments.record, dashboard.view, work.view/create/claim/close — no approvals) and permission inventory.block_payments.record (SUPER_ADMIN, TICKET_MANAGER, TICKET_EXEC, FINANCE_MANAGER, ACCOUNTANT; not CHARTERED_ACCOUNTANT, whose exact bundle apply_role_bundles() rewrites — the CA keeps the desktop route on finance.create). record_airline_block_payment (SECURITY DEFINER) derives the desktop's supplier-payment voucher for a block or FIT and posts it pending through fin_post_system_vouchers, with the overpayment guard, contract-rate FX and the TDS rule in the database; the web routes send a holder without their own gate (finance.create / suppliers.edit) through it and leave finance's browser-built path unchanged. airline_block_payment_summary, airline_block_paid_from_accounts, block_third_party_sales read for the screens; sell_block_seats_to_third_party (inventory.edit) is POST /inventory/third-party-sale in one transaction. Nothing the finance-controls work revoked comes back; no finance.* permission changes hands. Tests: supabase/tests/ticketing_pays_for_its_blocks.sql, src/lib/api.blockPayments.test.ts |
| 2026-09-25 | 20260929170000 | A tour leader reads only its field (FLD-001, ACC-012). No new permission. New predicate auth_reads_beyond_field() = auth_is_staff() and at least one permission outside field.* (role or user-level allow, minus user-level deny). The staff-wide read policies on User, UserRole, UserPermission, Account, AccountingPeriod, ApprovalLimit, FinanceConfig, FinancialAccount, FinancialAccountTransaction, FinancialYear, the nine Chat* tables, CommunicationsLog, CommunicationSetting, WhatsAppContact / WhatsAppMessage / WhatsAppTemplate (their FOR ALL write policies too) use it instead of auth_is_staff(). A TOUR_LEADER-only login, or a partner who leads, sees one User row: its own. GET /users has no permission of its own — every desktop bundle holds a right beyond the field, so the pickers are unchanged. currencies, exchange_rates and the reference tables open to any signed-in user are unchanged. Test: supabase/tests/a_tour_leader_reads_only_its_field.sql. |
| 2026-09-25 | 20260929180000 | An allocation is read by its owner (ACC-010, FIN-040). No new permission. PaymentAllocation — which receipt settles which booking, invoice or group invoice, and for how much — was SELECT ... USING (true) for every signed-in login since 20260924113000, portal logins and tour leaders included, while the Payment row itself was already scoped. Replaced by three policies in the shape of Payment's: PaymentAllocation select staff (finance.view, bookings.view or agents.ledger.view via auth_user_has_permission), select customer (the allocation's booking, the receipt's booking or an allocated invoice's booking in auth_customer_booking_ids(), or a group invoice in auth_portal_group_invoice_ids()), select agency (the same four routes through auth_agent_booking_ids() / auth_agent_id()). anon re-revoked; the insert-none policy re-asserted. No reader changed: the finance register, the staff customer ledger and the booking-totals mirror read as staff, and neither portal nor the phone app reads the table. Test: supabase/tests/an_allocation_is_read_by_its_owner.sql. |
| 2026-09-25 | 20260929200000 | Ticketing files a sale's cancellation, records the airline's refund and the deposit at purchase (FIN-044 amended, INV-014). Added inventory.b2b_cancellations.file — filing only — to SUPER_ADMIN, TICKET_MANAGER, TICKET_EXEC and, computed in the migration, every role holding finance.create (CEO, GM, FINANCE_MANAGER, ACCOUNTANT, CHARTERED_ACCOUNTANT at the time; some of those bundles are exact sets apply_role_bundles() rewrites, so file_b2b_cancellation admits finance.create as well). file_b2b_cancellation (SECURITY DEFINER) is POST /finance/b2b-cancellations' filing in one transaction; the route sends a holder without finance.create through it. The decision is unchanged: decide_b2b_cancellation on finance.cancellations.approve_b2b, never the filer. record_airline_block_payment gains p_event (supplier_payment / initial_payment / supplier_refund; the 10-argument signature is dropped and replaced) and record_airline_block_refund wraps the refund; create_quota_block_and_post records a deposit through it in the block's transaction. No finance.* permission changes hands. Tests: supabase/tests/ticketing_files_and_refunds.sql, src/lib/api.blockPayments.test.ts, apps/mobile/src/lib/inventoryAirline.test.ts |
| 2026-09-26 | 20260930010000 + 20260930040000 | A block starts as a draft (AIR §36). No new permission. AirlineQuotaBlockDraft (RLS: SELECT for inventory.create or inventory.edit; no write policy — only the functions write it; anon has nothing). create_quota_block_draft (inventory.create), update_quota_block_draft and discard_quota_block_draft (inventory.create or inventory.edit), quota_block_draft_preview (the same), submit_quota_block (inventory.create; the deposit also inventory.block_payments.record inside record_airline_block_payment). create_quota_block_and_post is now draft + submit in one transaction. Guard triggers refuse a JournalEntry, LedgerEntry or SupplierTransaction naming an open draft. 20260930010000 reached production on 26 Sep 2026 as part of an earlier supabase db push, before the screens shipped; it is additive and there were no drafts, so live blocks were unaffected. 20260930040000 closes the four pure helpers (inv_draft_time, inv_draft_date, inv_draft_leg_problems, inv_draft_payload) that were left with the default PUBLIC execute. Tests: supabase/tests/a_block_starts_as_a_draft.sql, src/lib/api.blockDrafts.test.ts |
| 2026-09-26 | — | Seat releases in the native app (AIR §16–§18, §30). No migration and no new permission: the app's Inventory → Seat releases calls preview_seat_release, request_seat_release, approve_seat_release, reject_seat_release, cancel_seat_release and complete_seat_release under the same permissions as the desktop (inventory.release.request, .approve, .approve_large, .approve_own, .file or finance.create). §6.5d has the rows. |
| 2026-09-27 | 20261001090000 | Files live on the company Shared Drive (ACC-074, ACC-073 amended). No new permission. drive-upload and drive-file gate each kind of file on the permission its screen already uses (upload: groups.edit, finance.edit, finance.tds.deduct, incidents.manage, visa.edit, tickets.edit, own customer record; open: groups.view, finance.view, finance.tds.view, incidents.view, visa.view, tickets.view, requests.view, leads.view, visa.intake.view, customers.view, bookings.view; partner, employee, issued and chat files under their own row security). drive-file's staff rule was customers.view or visa.view or bookings.view for any document; it is now the one permission of that kind. DriveFile (service role only). signed-url removed. Tests: supabase/functions/drive-file/index.test.ts, supabase/tests/files_live_on_the_shared_drive.sql |
| 2026-09-30 | 20261004090000 | A traveller or a partner deletes their own account (AUD-025 … AUD-027). No new permission. AccountDeletion (RLS: the person's own rows; travellers' rows to customers.edit, partners' to partners.edit; no write policy — only the functions write). account_delete_self, account_deletion_complete, account_deletion_finish, account_anonymise and account_deletion_blockers run for the service role only (the edge function account-delete checks the session, the password or the staff permission first); account_deletion_status for the signed-in person, account_deletion_requests and account_deletion_decline check customers.edit / partners.edit per kind. Staff logins are refused. |
| 2026-10-01 | 20261005090000 | Nobody deletes their own account; money held blocks (AUD-025, AUD-027 amended). No new permission and no grant changes. account_delete_self (service role only) now always files a request and never erases; the office's account_deletion_complete (permission checked on the verified staff id, customers.edit / partners.edit) is the only erasure. New internal functions account_deletion_reasons and account_deletion_money (service role only); account_deletion_blockers adds money held for the person; account_deletion_status and account_deletion_requests keep their grants. |
| 2026-10-03 | 20261008200000 | The travellers' guide in five languages (TRV-018 … TRV-021, DECIDED 2026-10-03). Added guide.religious.approve — SUPER_ADMIN only (trigger + backstop insert); the office grants it to named people. It approves religious text in the guide (verses, duas, their transliteration and meaning, anything marked Sunni or Shia) through trv_guide_set_status(), and never text the approver wrote or last changed (draftedBy, maker-checker); plain text is approved with programme.manage, one person. A change to approved text waits in draft beside it until approved; discard (programme.manage) drops it. programme.manage also writes parts and translations (trv_upsert_guide_part, trv_delete_guide_part, trv_save_guide_translation) and reads the editor and preview (trv_guide_editor, trv_guide_preview, also open to guide.religious.approve). New tables TripGuidePart, TripGuideTranslation, TravellerGuidePreference: row security, no policy, no browser grant — functions only. RLS TripGuideStep select narrowed: a reader without either permission sees approved, active steps for both traditions. The traveller reads through trv_guide() and sets their own language and tradition (trv_set_guide_preferences, own login only). §4.4b, §5.6, §6.5d. Test: supabase/tests/guide_languages.sql |
| 2026-10-03 | 20261009110000 | Bookings on the phone (LC-020, PRC-003, CXL-003, #559 step 4). No permission added, removed or regranted. app_reject_cancellation (SECURITY DEFINER; checks bookings.cancel.approve, the right the website's reject routes use; anon revoked; audited cancellation_rejected / passenger_cancellation_rejected). Everything else is an existing function under its existing right: update_booking (bookings.edit), request_booking_cancellation / request_passenger_cancellation (bookings.cancel), refund_preview / request_booking_refund (finance.payments.refund), approve_refund (finance.refunds.approve). §6.5d has the rows. Test: supabase/tests/app_bookings.sql. |
| 2026-10-03 | 20261009100000 | Group operations on the phone (INV-033, #559 step 3). No permission added, removed or regranted. app_link_group_flight, app_set_group_flight_pnr and app_unlink_group_flight (SECURITY DEFINER; each checks groups.edit, the right the website's Flights tab uses; anon revoked; audited flight_linked / pnr_updated / flight_unlinked). The phone's seat and room numbers are table updates under the existing bpf_update / bph_update (bookings.edit); Notify group is trv_post_notice (groups.edit). §6.5d has the rows. Test: supabase/tests/app_group_operations.sql. |