Skip to content

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"] }
  • clientRequestId is required and is the phone's idempotency key: the same id sent twice returns the first check-in with duplicate: true and stores nothing (FLD-004).
  • recordedAt is when the leader saved it on the phone; the server keeps it and stamps syncedAt itself. A time more than an hour in the future is refused.
  • kind is one of airport_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 / accuracyM are 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