Field (tour leader) API
Handler: handleField in src/lib/api.ts. Rules: FLD-001 … FLD-005.
Every read and write is a SECURITY DEFINER function that checks the permission and that the
caller leads the group (or holds groups.view / groups.edit); the routes only shape the
call. The anonymous key can execute none of them (ACC-053).
| Route | Method | Permission | Function | Purpose |
|---|---|---|---|---|
/field/groups |
GET | field.view |
fld_my_groups() |
The groups whose leadUserId is the caller, from two weeks before return onward: dates, head-count, last check-in |
/field/groups/:id |
GET | field.view for a group I lead, or groups.view |
fld_group_manifest(p_group_id) |
Header (with groupLeaderPassengerId, the traveller who leads the group — PAX-035), active travellers (name, category, passport, phone or booking contact, room stays, meal plans, transfers, what they bought, whether a cancellation is waiting, traveller state, consent, isGroupLeader on the one leader; the partner's code only for groups.view; familyStatus, familyId, relationship, relationshipLabel, guardianPassengerId — PAX-036, PAX-021), hotels, itinerary, last check-in, families (each with its head and members), familyReadiness and familyRelationships (the active list). No money |
/field/groups/:id/checkins?limit= |
GET | as above | fld_group_checkins(p_group_id, p_limit) |
Check-ins, newest first, each naming the missing travellers. limit 1–500, default 50 |
/field/groups/:id/checkins |
POST | field.checkin for a group I lead, or groups.edit |
fld_record_checkin(p_payload) |
Record a check-in (below) |
/field/emergency-contact/:bookingId |
GET | bookings.view, incidents.view, the booking's partner / customer, or the leader of its group (RLS) |
table read | The booking's emergency contact, or null |
/field/emergency-contact/:bookingId |
POST | bookings.edit / bookings.create, or the booking's partner / customer |
set_booking_emergency_contact |
Record or change it: name and phone required, relation, altPhone optional (INC-005) |
/field/consent/:bookingId |
GET | bookings.view, or the booking's partner / customer (RLS) |
table read | The travellers' answer on location sharing, or null |
/field/consent/:bookingId |
POST | bookings.edit / bookings.create, or the booking's partner / customer |
set_field_consent(p_booking_id, p_consented, p_source) |
Record or change the answer. source: booking_wizard, partner_portal, customer_portal, booking_detail |
Appointing a tour leader (FLD-007)
Handler: handleGroups in src/lib/api.ts. A tour leader is any active login — an employee,
a partner's or a traveller's. The appointment grants the TOUR_LEADER role and nothing else;
the dismissal removes it when no other group is led. Both are audited and the person is told.
| Route | Method | Permission | Function | Purpose |
|---|---|---|---|---|
/groups/tour-leader-candidates?q= |
GET | groups.edit |
tour_leader_candidates(p_q) |
[{id, name, kind: employee \| partner \| customer \| leader, phone, email, detail, isLeader, leads}] by name, phone, email, username or agency; at most 20; never a paused, deactivated or deleted login; a traveller's phone as its last four, no email |
/groups/:id/tour-leader |
POST {userId} |
groups.edit |
appoint_tour_leader(p_group_id, p_user_id) |
{groupId, leadUserId, name, phone, roleGranted, previousLeadUserId, previousRoleRemoved, alreadyAppointed}; a paused or deactivated login → 400, an unknown one → 404 |
/groups/:id/tour-leader |
DELETE | groups.edit |
dismiss_tour_leader(p_group_id) |
{groupId, userId, roleRemoved, alreadyNone} |
/groups/:id |
PATCH {leadUserId} |
groups.edit |
the two functions above | Kept for old callers: a user id appoints, null dismisses; the column is never written directly |
Functions the native app calls
The native app has no route layer: it calls these database
functions directly (supabase.rpc). Each one checks the caller itself; the anonymous key can
execute none of them. Rules: TRV-001 … TRV-006,
FLD-006.
| Function | Who | Returns |
|---|---|---|
trv_my_trips() |
the traveller (bookings in auth_customer_booking_ids()) |
[{bookingId, bookingNo, status, currency, totalAmount, paidAmount, balanceAmount, agreedDue, awaitingApproval, travelling, group {id, name, code, tripType, active, departureDate, returnDate, leader {name, phone}}, passengers [{id, name, passportNo, passportExpiry, dob, nationality, needsVisa, needsTicket, isPrimary, pendingPassport}], emergencyContact, fieldConsent, locationSharing}] — a departure with no type has tripType ALL (until 20261008200000, UMRAH; TRV-022) |
trv_trip_programme(p_group_id) |
the group's travellers and partner, its leader, groups.view |
{group, itinerary [...without pnr/ref for a traveller or partner; noactivityrows since 20261006160000], activities [... by date, start time (untimed last), sortOrder], notices [{id, title, body, pinned, postedAt, postedBy}]}, or null |
trv_upsert_guide_step(p) / trv_delete_guide_step(p_id) |
programme.manage |
the step ({id, tripType, phase, position, title, body, isActive, audience, tradition, status, approvedBy, approvedAt, draft, draftedBy, draftedAt, updatedBy, updatedAt}); p.id set = edit; p.audience everyone/men/women, p.tradition both/sunni/shia (TRV-020, TRV-021); a new step marked Sunni or Shia is draft; a change to approved religious text is kept in draft (jsonb) beside it, the row's own columns unchanged (TRV-019) |
trv_upsert_guide_part(p) / trv_delete_guide_part(p_id) |
programme.manage |
the part ({id, stepId, kind: text\|verse\|dua, sortOrder, audience, tradition, arabicText, transliteration, source, body, status, draft, draftedBy, …}); p.arabicText is stored exactly as sent; a verse or dua needs arabicText and source; a new religious part is draft; a change to an approved one waits in draft (TRV-019) |
trv_save_guide_translation(p) |
programme.manage |
{target: step\|part, id, lang: ur\|hi\|ks\|ar, title, body} → the translation row: new = draft; a change to an approved one waits in draft beside it; the same text as stands drops a waiting change; empty title and body remove it and return null (TRV-018) |
trv_guide_set_status(p_target, p_id, p_lang, p_status) |
approved — religious text: guide.religious.approve and not its draftedBy; plain text: programme.manage. discard: programme.manage |
approved puts what waits in place of the approved text and records approvedBy; discard drops a waiting change; p_lang en is the master; nothing waiting = the row unchanged (TRV-018, TRV-019) |
trv_guide(p_trip_type, p_lang, p_show_both) |
any signed-in user; a trip type outside trv_guide_readable_types() (the caller's own trips' types, the groups they lead; every type for programme.manage, groups.view, guide.religious.approve) is answered with the ALL steps only (TRV-022) |
{lang, tradition, gender, showBoth, genderSpecific, waiting [{phase, count}], unavailable [{phase, count}] (rituals written for the other tradition only, TRV-021), steps [{id, phase, position, audience, tradition, title, body, lang, notTranslated, parts [{id, kind, sortOrder, audience, tradition, arabicText, transliteration, source, body, lang, notTranslated} \| {kind: missing, sortOrder, tradition, otherTradition}]}]} — approved text only, for the caller's tradition and gender (TRV-018 … TRV-021) |
trv_guide_preview(p_trip_type, p_lang, p_tradition, p_gender, p_show_both) |
programme.manage or guide.religious.approve |
the same answer for the choices given |
trv_guide_editor() |
programme.manage or guide.religious.approve |
{me, canEdit, canApprovePlain, canApproveReligious, steps [step + {religious, approvedByName, draftedByName, translations {lang: {…, status, draft, draftedBy}}, parts [part + {religious, approvedByName, draftedByName, translations}]}]} |
trv_my_guide_preferences() / trv_set_guide_preferences(p_lang, p_tradition) |
the caller, for their own login only | {lang (null until chosen), tradition (sunni by default), saved, gender}; null keeps what was there (TRV-018, TRV-021) |
trv_upsert_activity(p) / trv_delete_activity(p_id) |
groups.edit, or the group's leader with field.checkin |
the activity ({id, groupId, onDate, startsAt, endsAt, kind, title, place, notes, lat, lng, status, sortOrder, sourceKey, …}); p.sortOrder sets the order within the day (left out: kept on an edit; a new activity, or one moved to another day, goes to the end of that day — INV-008); p.id set = edit, fields not sent are kept |
trv_reorder_activities(p_group_id, p_ids) |
as above | how many rows took a new place — the day's order, ids in the order wanted (INV-008) |
trv_clone_programme(p_from, p_to, p_days) |
groups.create, or as above on the new group |
how many activities were copied — a cloned departure's programme, dates moved by p_days, back to planned, cancelled ones left; once per source row (INV-003) |
trv_post_notice(p_group_id, p_title, p_body, p_pinned) |
as above | the notice |
trv_group_audience(p_group_id) |
as above | the user ids to push to (travellers with a login, the leader) — for push-send userIds |
ntf_unread_count() |
any signed-in user | the number of the caller's own unread notifications (TRV-010) |
ntf_mark_read(p_ids) / ntf_mark_all_read() |
any signed-in user | how many of the caller's own rows were marked read; another person's id changes nothing |
ntf_notify(p_user_ids, p_title, p_body?, p_url?, p_kind?, p_source_ref?) |
approvals.approve, bookings.create, bookings.edit, bookings.cancel, bookings.cancel.approve or groups.edit |
how many AppNotification rows were written (one per distinct login, at most 500) |
ntf_prune(p_days = 90) |
service role only | how many read rows older than p_days were deleted (never fewer than 7 days; unread rows stay) |
trv_set_location_sharing(p_booking_id, p_enabled) |
the traveller, own booking only | {userId, bookingId, enabled, updatedAt}; off deletes the position |
trv_report_location(p_lat, p_lng, p_accuracy?, p_heading?, p_speed?, p_battery?) |
the traveller, while their switch is on and the booking is travelling | {bookingId, groupId, recordedAt}; one row per person, overwritten |
fld_group_locations(p_group_id) |
the group's leader, groups.view |
[{userId, bookingId, name, phone, lat, lng, accuracy, battery, recordedAt}], or null |
tour_leader_candidates(p_q) / appoint_tour_leader(p_group_id, p_user_id) / dismiss_tour_leader(p_group_id) |
groups.edit |
as the routes above (FLD-007); the app's group screen calls them directly, and Invite a new person calls the admin-users edge function (create_user, allowNonStaffEmail) before appointing |
set_group_leader(p_group_id, p_passenger_id) |
groups.edit |
{groupId, passengerId, name, bookingId, bookingNo, previousPassengerId, alreadyLeader} — the traveller who leads the group (one per group, live on it); null clears it. Not the tour leader (PAX-035) |
family_save(p_group_id, p_family), family_remove_member(p_family_id, p_passenger_id, p_mark_not_family?, p_reason?), family_dissolve(p_family_id, p_reason) |
bookings.edit, or a partner for their own travellers |
the family with its members — as POST /families… in Bookings API (PAX-036) |
travellers_set_family_status(p_passenger_ids, p_status, p_reason?), traveller_set_guardian(p_passenger_id, p_guardian_id, p_reason?) |
as above | {done[], skipped[]}; {passengerId, guardianPassengerId, guardianName, unchanged} (PAX-021) |
booking_families(p_booking_id), family_prefill_from_booking(p_booking_id) |
bookings.view, or the partner who owns the booking |
as GET /families/booking/:id and …/prefill |
place_travellers(p_group_id, p_kind, p_assignment_id, p_passenger_ids, p_room_share_group?, p_meal_plan?) |
bookings.edit |
{kind, assignmentId, placed[], skipped[], refused[], placedCount} — selected travellers into one of the group's hotels, meal plans or transfers (p_kind hotel | meal | ground), each refusal with its reason |
request_passenger_cancellation(p_passenger_id, p_reason) |
bookings.cancel |
{passengerId, bookingId, bookingNo, status: 'pending', alreadyCancelled} — the same request as POST /sales/bookings/:b/passengers/:p/cancel; nothing is cancelled until someone else approves it on the desktop; the approvers get an inbox notice (LC-020) |
transfer_traveller_to_group(p_passenger_id, p_target_group_id, p_target_booking_id?, p_price?, p_reason) |
booking.transfer |
{passengerId, name, fromBookingNo, newBookingId, newBookingNo, newBookingCreated, newBookingStatus, targetGroupName, movedFlights, releasedFlights, releasedServices, carried, sourceClosed, price} — one traveller to another group in one transaction: unless a booking is given, the one already made for their booking on that group or a new one with the old booking's customer, partner and sales owner (sent for approval — LC-033); the money paid for them carried with them (LC-032); an emptied booking closed as TRANSFERRED (LC-031); transfer_passenger_to_booking() for them and their money, seats moved or released, visa case and tickets follow, the old departure's room, meals and coach seat released (LC-030, PRC-005, INV-020) |
trv_submit_passport(p_passenger_id, p_fields) |
the passenger's booking's customer or partner, or bookings.edit |
the pending submission; only the ten passport fields are kept; no passportNo → 400 |
trv_review_passport_submission(p_id, 'apply' \| 'reject') |
bookings.edit |
the submission; a decided one is returned unchanged |
create_food_inventory(p_food) |
food.create |
{food, posting {posted, alreadyPosted?, reason?, error?, amountINR?, entryIds?}} — the contract and its purchase voucher (FIN-030, FIN-034); p_food is POST /food's body (itemName, unit, supplierId, pricePerMealDay, capacityPerDay, currency, exchangeRate?, availableFromDate?, availableToDate?, totalQuantity?, lowQuantityThreshold?, gstEnabled?, gstAmount?, notes?). Refusals: no caterer, no rate, no capacity, riyals with no rate and none for the day |
inv_post_food_purchase(p_food_id) |
food.create |
the same posting for an existing contract; idempotent |
update_food_inventory(p_food_id, p_patch) |
food.edit |
the contract; quantity in the patch → 400 (INV-042) |
assign_hotel_rooms(p_assignment) |
hotels.edit |
{assignment, availableRooms, posting}; p_assignment is POST /hotels/assignments's body; the desktop's refusals in its words |
unassign_hotel_rooms(p_assignment_id) |
hotels.edit |
{ok, hotelId, availableRooms, reversed} |
assign_food_plan(p_assignment) |
food.edit |
{assignment, fed, billed, posting}; p_assignment is POST /food/assignments's body (foodId, groupId, quantity? — typed = fixed, mealDays?, checkIn?, checkOut?, mealRatePerDay, mealCurrency, excludeChildren, excludeGroupLeader, notes?) |
unassign_food_plan(p_assignment_id) |
food.edit |
{ok, foodId, freeMealDays, reversed} |
assign_ground_transfer(p_transfer_id, p_group_id, p_quantity, p_price_override?, p_currency?, p_notes?) |
inventory.edit |
{assignment, availableCapacity, posting}; "Insufficient available capacity" and the INV-012 sentence as refusals |
unassign_ground_transfer(p_assignment_id) |
inventory.edit |
{ok, transferId, availableCapacity, reversed} |
create_hotel_lease(p_hotel) |
hotels.create |
{hotel, posting} — the HotelInventory row (as create_hotel_inventory returns it) and the posting below, in one transaction; a refusal leaves no row (FIN-030) |
post_hotel_purchase_from_hotel(p_hotel_id) |
hotels.create |
{hotelId, posted: true, entryIds, leaseDays, totalCost, totalCostINR, gstInput, gstInputINR, currency}; {posted: false, reason: 'unpriced'} when rooms × rate × nights is nought; {posted: false, alreadyPosted: true, entryIds} when the hotel already carries its hotel_purchase journal. Refuses with no Finance Settings, no supplier or an inactive one |
create_quota_block_and_post(p_block) |
inventory.create |
the AirlineQuotaBlock row (as create_quota_block returns it) plus posting (below), in one transaction; a refusal leaves no row (FIN-030). p_block is the body POST /inventory/quota-blocks takes; the browser's checks apply (tripType one of one_way/return/multi_city, a unique PNR, focSeats ≤ totalSeats, focTreatment spread/margin); an initialPaymentAmount above nought with initialPaymentSourceAccountId (optional initialPaymentDate, initialPaymentReference, initialPaymentMethod) is recorded in the same transaction through record_airline_block_payment (initial_payment; needs inventory.block_payments.record) and returned as deposit; a deposit with no account is refused and leaves no row |
airline_block_payment_summary(p_block_id, p_kind = 'quota_block') |
inventory.view or finance.view |
{kind, id, label, currency, exchangeRate, pricePerSeat, supplierId, supplierName, archived, cancelled, paidSeats, totalCost, initialPayment, payments, refunds, paidSoFar, outstanding, payments: [{id, type, amount, currency, description, category, transactionDate, journalEntryId, voucherNo, status, sourceAccountName, createdAt, createdBy}]} — in the contract currency; p_kind 'quota_block' or 'fit' (INV-025, AIR §15) |
airline_block_paid_from_accounts() |
inventory.block_payments.record or finance.view |
[{id, code, name, currency, groupCode, groupName, allowDebit, allowNegativeBalance}] — the desktop's paid-from list, banks and cash first |
record_airline_block_payment(p_block_id, p_kind, p_amount, p_currency?, p_exchange_rate?, p_paid_from_account_id, p_method?, p_reference?, p_paid_on?, p_notes?, p_event? = 'supplier_payment') |
inventory.block_payments.record |
{alreadyRecorded, event, journalEntryId, voucherNo, supplierTransactionId, status: 'pending', amount, currency, amountInr, exchangeRate, netInr, tds, memo, paidOn, paidSoFarAfter, outstandingAfter, refundsAfter}; p_event 'initial_payment' posts the desktop's deposit voucher, 'supplier_refund' the refund below — Dr the supplier's SUP- ledger / Cr the account, the ledger mirror and the supplier transaction, pending finance (FIN-044). Refusals, in the desktop's words: over the outstanding, the wrong currency, no rate on a foreign contract, a future date, an archived or cancelled purchase, a ledger not under Current Assets, an account that may not go negative and cannot cover it, "TDS applies to this supplier (section …); finance records this payment." without finance.tds.deduct |
record_airline_block_refund(p_block_id, p_kind, p_amount, p_currency?, p_exchange_rate?, p_received_into_account_id, p_method?, p_reference?, p_received_on?, p_notes?) |
inventory.block_payments.record |
the same shape with event: 'supplier_refund' — Dr the account the money came into / Cr the supplier's SUP- ledger, the supplier_refund mirror and a SupplierTransaction refund, pending finance (FIN-044). Refuses "Refund exceeds the … paid so far for the selected airline block.", an account that takes no credit, a future date, an archived purchase; a fully cancelled block still takes it; no TDS |
file_b2b_cancellation(p_offer_id, p_reason, p_buyer_cancellation_charge? = 0, p_supplier_side? = null, p_seats? = all, p_refund_amount? = sale − charge) |
inventory.b2b_cancellations.file or finance.create |
{ok, alreadyFiled, cancellationId, status: 'pending_approval', seats, refundAmount, buyerCancellationCharge, originalSaleAmount, cancelWithSupplier, isFullOffer, filingJournalEntryId: null} — POST /finance/b2b-cancellations' filing in one transaction (INV-014); p_supplier_side is {cancellationCharge, refund, approvalRef, notes}. Refuses, in the route's words: a cancelled or group-linked offer, more seats than sold, refund + charge above the sale value, a negative charge, a filing already pending (the same one within ten minutes returns the first); a reason under three characters. Finance decides with decide_b2b_cancellation — never the filer (FIN-032) |
sell_block_seats_to_third_party(p_block_id, p_seats, p_margin_per_seat?, p_currency?, p_notes?, p_buyer_account_id?, p_gst_enabled?, p_gst_rate?, p_loss_reason?) |
inventory.edit |
{ok, offerId, seats, costPerSeat, sellingPrice, totalSelling, totalMargin, totalMarginInclusive, gstOnMargin, netProfit, isLoss, lossReason, buyerAgentId, invoiceNumber, entryIds} — POST /inventory/third-party-sale in one transaction (INV-014); refuses "Not enough available seats", a negative selling price, a loss with no reason, an archived block |
block_third_party_sales(p_block_id) |
inventory.view or finance.view |
[{id, seats, seatsAvailable, pricePerSeat, currency, status, notes, createdAt, buyerAgentId, buyerName, names: [{id, name, passportNo, saleId}], invoiceNumber, invoiceBalance, invoiceStatus, resoldSeats, cancellation: {id, status, seats, isFullOffer, refundAmount, buyerCancellationCharge, cancelWithSupplier, supplierRefund, supplierCancellationCharge, filedAt, filedBy, filedNotes, approvedAt, rejectedAt, rejectionReason} \| null, pendingCancellation}], live sales first |
b2b_set_manifest(p_offer_id, p_passengers) |
inventory.edit |
{offerId, blockId, seats, names} — replaces the buyer's traveller names on a live sale, at most one per seat (INV-015) |
post_quota_block_purchase_from_block(p_block_id) |
inventory.create |
{blockId, blockCode, posted: true, alreadyPosted: false, entryIds, payable, totalInr, baseInr, gstInr, supplierId} — Dr 1310 Stock-in-Hand for the paid seats at the contract rate, Dr GST Input Credit when the block carries input GST, Cr the supplier's SUP- ledger, the purchase_invoice mirror and the supplier transaction in the contract currency (AIR §15); {posted: false, alreadyPosted: true, entryIds} when the block already carries its purchase. Refuses with no Finance Settings, no supplier (by id or by name), no contract rate on a foreign currency (FIN-034), a payable of nought, or an archived block |
Errors map as everywhere: 42501 → 403, 22023 → 400.
The inbox itself is a table read under row security: AppNotification
(id, userId, title, body, url, kind, sourceRef, createdAt, readAt), SELECT on
"userId" = auth.uid(), newest first, keyset on createdAt; in the realtime publication.
No client inserts, updates or deletes it.
push-send and the kept row
POST /functions/v1/push-send (a signed-in user's token or the service role) takes
{ userIds, title, body, url?, icon?, kind?, sourceRef?, store? }. Before delivering
(Web Push or FCM) it writes one AppNotification per userId with the service role —
kind defaults to system, sourceRef is the record the alert is about — and answers
{ sent, total, failures, stored }. A failed insert is logged and never fails the send.
store: false skips the row: the app sends it for a group notice, which trv_post_notice
has already kept for the audience. Rule: TRV-010.
Who may call it (COMM-042):
| Caller | May send |
|---|---|
| Service role | anything |
Active staff login holding one of communications.send, bookings.create, bookings.edit, bookings.cancel, bookings.cancel.approve, approvals.approve, finance.bookings.approve_finance, finance.bookings.reject_finance, partners.edit, partners.approve, groups.edit |
its own title and message, to anyone |
| Anyone else signed in (a partner, a traveller, a leader without those rights) | only store: false, and only to people who already have an AppNotification row with the same title, message and kind (and sourceRef, when given) from the last 15 minutes; others are left out (notRung in the answer); nobody left → 403 |
Limits for every caller: at most 500 userIds (else 400); title cut to 200 characters,
body to 2,000. A signed-in caller's url must be a path of the app (starting with /,
else 400) and its icon is ignored. kind: "work" from a signed-in caller is skipped
({ sent: 0, skipped }, COMM-041).
A deactivated login gets 403, a bad token 401.
Recording a check-in
{ "clientRequestId": "0d2f…", "kind": "daily_headcount", "recordedAt": "2026-10-08T06:00:00.000Z",
"city": "Makkah", "place": "Hotel lobby", "note": null,
"lat": 21.4225, "lng": 39.8262, "accuracyM": 12,
"present": ["pax-1", "pax-2"], "missing": ["pax-3"] }
clientRequestIdis required and is the phone's idempotency key: the same id sent twice returns the first check-in withduplicate: trueand stores nothing (FLD-004).recordedAtis when the leader saved it on the phone; the server keeps it and stampssyncedAtitself. A time more than an hour in the future is refused.kindis one ofairport_arrival,flight_boarded,hotel_checkin,daily_headcount,bus_boarding,ziyarat_departure,ziyarat_return,hotel_checkout,return_flight,other.- At least one traveller must be named. A traveller not on the group, or named both present
and missing, is refused (
400). lat/lng/accuracyMare the leader's device, optional (FLD-005).
200 — the check-in summary: id, kind, recordedAt, syncedAt, recordedBy {id, name},
city, place, note, lat, lng, presentCount, missingCount, missing [{id, name, phone}],
clientRequestId, duplicate.
Errors
| Status | Meaning |
|---|---|
| 400 | Missing request id, nobody named, unknown kind, a stranger on the list, present and missing at once, a future time |
| 403 | No field.view / field.checkin, or not the leader of this group |
| 404 | Group or booking not found |