14 · Field operations, health and insurance
Tour leader / mutawwif app
FLD-001 · Tour Leader role
Status: DECIDED 2026-09-17, amended 2026-09-25 (owner: a business partner may lead; amended again 2026-09-25: anyone may lead — see FLD-007) · Owner: Operations
A Tour Leader role (group leader / mutawwif) sees only the groups they are assigned to, on a phone. A tour leader is any login — an employee, a business partner's login (PTR-004) or a traveller's — appointed on a departure by staff with groups.edit (FLD-007). The appointment grants the TOUR_LEADER role and nothing else; the dismissal takes it away. Whatever else the login is, it stays that: a partner who leads stays a partner, a traveller who leads stays a traveller (a portal role wins — no staff table opens to them), and an employee keeps their own bundle. Either way they see only the groups assigned to them (TravelGroup.leadUserId). They can: view manifest, rooming, itinerary and emergency contacts for their group; record headcounts and check-ins; report incidents (file 13); message the operations desk. They cannot see prices, payments, other groups, or anyone else's User row.
Enforced by (20260928170000_a_tour_leader_role.sql, 20260928170100_the_tour_leader_sees_their_group.sql): the TOUR_LEADER role holds field.view and field.checkin and nothing else; TravelGroup."leadUserId" names the leader; fld_my_groups(), fld_group_manifest() and fld_group_checkins() answer only the group's leader (or staff with groups.view) and carry no money; the /field pages have their own shell with no sidebar; the blanket staff read policies (User, role grants, the ledger, chat, WhatsApp) ask auth_reads_beyond_field() — a permission outside field.* — so a leader reads only their own User row and takes every name on their screens from the fld_* functions (20260929170000_a_tour_leader_reads_only_its_field.sql, ACC-012); the role is granted and removed only by appoint_tour_leader() / dismiss_tour_leader() (FLD-007) or Admin → Users. Tests: supabase/tests/the_tour_leader_sees_their_group.sql, supabase/tests/a_tour_leader_reads_only_its_field.sql; a partner who leads: supabase/tests/a_partner_who_leads_a_group.sql; a traveller who leads: supabase/tests/a_tour_leader_is_appointed.sql.
Not built: reporting an incident and messaging the desk from the field app. A leader calls the office; the emergency contact shown is the booking's contact (INC-005 is not built).
FLD-002 · Check-ins
Status: DECIDED 2026-09-25 (owner: check-ins are optional) · Owner: Operations
Tour leaders record check-ins at key moments: airport arrival, flight boarded, hotel check-in (per city), daily headcount, bus boarding, ziyarat departure/return, hotel check-out, return flight. Each check-in records who, when, where (optional GPS), and which travellers are present / missing. Missing travellers at a check-in raise an alert. A check-in is optional: a leader who records none is not chased; every one recorded is kept and shown.
Enforced by: GroupCheckIn / GroupCheckInPassenger, written only by fld_record_checkin() (needs field.checkin and the group's lead, or groups.edit), which refuses a traveller who is not on the group, a traveller both present and missing, and an unknown kind. The alert is the group's Overview card and the Customer 360 "where they are today" panel, both naming the missing travellers; a work-inbox item for a missing traveller is not built.
FLD-003 · Location shown on Customer 360
Status: DECIDED 2026-09-17 (owner) · Owner: Operations
"Where they are today" (C360-001) shows the planned location from the itinerary and the latest tour-leader check-in. If the latest check-in disagrees with the plan, both are shown and the difference is highlighted.
Enforced by: jrn_traveller_location() carries the latest check-in that counted the traveller (kind, time, place, present or missing, who recorded it); the Customer 360 panel highlights a check-in city that differs from the planned city.
FLD-004 · Works without signal
Status: DECIDED 2026-09-25 · Owner: IT
The tour-leader app works offline (installable web app); check-ins queue on the phone and sync when connected, keeping the time they were recorded.
Enforced by: the field pages keep the last manifest and group list on the phone; a check-in goes into an outbox (src/lib/fieldQueue.ts) with the time it was saved and a client request id, is sent when the connection is back, and fld_record_checkin() stores a request id once, so a check-in sent twice is one check-in. Tests: src/lib/fieldQueue.test.ts, the DB test above.
Not built: incidents from the field, so nothing about them queues.
FLD-005 · Consent for location
Status: DECIDED 2026-09-25 (owner: consent at booking; amended 2026-09-25: the traveller's own switch, FLD-006) · Owner: Management · Source: DPDP Act 2023 At a check-in, GPS is captured only from the tour leader's device at that moment; a check-in never reads a pilgrim's phone. Consent is asked at booking: the booking form (staff wizard, partner portal) records whether the travellers agree that the leader's location may be kept with their check-ins. Presence is counted whether or not they agree — that is duty of care — but without consent no location is attached to the traveller.
Amended 2026-09-25. Before this date the rule read "never continuous tracking of pilgrims". From 2026-09-25 a traveller may also switch on live sharing of their own position from their own phone, in the app, under FLD-006: consent is given by the person themselves, in the app, and revoked in one tap; the purpose is limited to the trip (the switch does nothing outside the travelling window); only the latest position is kept, one row per person, no history. Nobody else can switch it on for them — not the office, not the leader, not the booking's partner — and the check-in consent above does not switch it on. The privacy page (§10) says both things.
Enforced by: BookingFieldConsent, written by set_field_consent() (staff with bookings.edit/bookings.create, the booking's partner or customer); the booking page shows and can change the answer; jrn_traveller_location() returns the check-in's location only when the booking consented; the field app's check-in screen takes the location from the leader's phone only, on the leader's tap; live sharing under FLD-006 is written only from the traveller's own login; the privacy page (§10) says so.
FLD-006 · Live location of travellers
Status: DECIDED 2026-09-25 (owner) · Owner: Management · Source: DPDP Act 2023 (consent, purpose limitation, data minimisation) A traveller may share where they are, from their own phone, with their tour leader and the office, while they are travelling. The rules of it:
- The traveller's switch, nobody else's. Only the traveller's own login can switch sharing on or off, per booking, and only for a booking that is theirs. The app turns it off in one tap and says plainly who sees it.
- Only during the trip. A position is stored only while the booking is travelling: from two days before the departure to the day after the return (a group with no return date is treated as 45 days long). Outside that window the switch may be on but nothing is stored.
- One row, no history. The database keeps one row per person — the latest position — and overwrites it. Switching off deletes it at once. Nothing is ever kept as a track.
- Who sees it. The group's tour leader and staff with
groups.view; the traveller sees their own row. Another traveller, another group's leader, a partner and the anonymous key see nothing. - What is kept. Latitude, longitude, accuracy, heading, speed, battery, and when. The name and phone shown beside it are the passenger's from the booking.
Enforced by: TravellerLocationShare (the switch, one row per login and booking; written only by trv_set_location_sharing(p_booking_id, p_enabled), which refuses a booking outside auth_customer_booking_ids() and deletes the position when switched off); TravellerLocation (userId primary key, so one row per person; written only by trv_report_location(lat, lng, …) from the traveller's own session, which refuses when no enabled switch of theirs is on a booking where trv_booking_is_travelling() is true, and refuses a latitude or longitude outside the world); RLS SELECT on both tables (own row, or groups.view; on TravellerLocation also the group's leader through fld_can_see_group); fld_group_locations(p_group_id) answers the leader and staff with groups.view and returns NULL to anyone else; TravellerLocation is REPLICA IDENTITY FULL and in the supabase_realtime publication so a leader's screen sees a position change or a switch-off live; every function and both tables are closed to anon (ACC-053). Migration 20260928220000_the_app_for_travellers.sql. Test: supabase/tests/the_app_for_travellers.sql.
Not built: directions on the in-app map (it is OpenStreetMap in a WebView; "Open in Maps" hands over to the phone's maps app); an alert when a traveller stops sharing or strays; sharing from a leader's phone for a traveller who has no phone.
FLD-007 · Appointing a tour leader
Status: DECIDED 2026-09-25 (owner) · Owner: Operations The owner's question: a tour leader can be anyone — a customer, a business partner or an employee — and they must see nothing beyond what concerns them. So appointing one is one step, on the departure, and the role follows the appointment:
- Who appoints. Staff with
groups.edit, on the group page (desktop) or the group screen (app). Nobody appoints themselves to a group they do not already administer: a tour leader holds nogroups.edit. - Who can be appointed. Any active login: an employee, a partner's login (
Agent.userId), a traveller's login (Customer.userId), or a person invited just to lead. Never a paused (ACC-070), deactivated or deleted login. Staff search by name, phone, email, username or agency; a traveller's phone is shown as its last four digits only and their email is not shown. - What the appointment does. Grants the
TOUR_LEADERrole — and only that role, never another — and names the login onTravelGroup.leadUserId. Nothing else about the login changes. The person is told in their inbox ("You are the tour leader fordeparting ", REQ-002). An outgoing leader, when one is replaced, is told too. - What the dismissal does. Clears the name; if the person leads no other departure the
TOUR_LEADERrole is removed as well — only that role. The person is told. - A new person. Someone who is none of the three gets a login first (Admin → Users, or Invite a new person on the app's group screen for staff who may create logins), with no role; the appointment then makes them a tour leader and nothing more. The app then gives them a way in: a password-reset email is requested through
auth-login(reset, the staff door — a leader-only login is staff toauth_identifier_resolve()), and, when the inviter holdscommunications.sendand a phone was given, a WhatsApp note throughwhatsapp-send— "You are the tour leader for; open the app and use Forgot password with " — which never carries the temporary password. whatsapp-sendapplies Meta's rule itself: a free-text first message reaches only a number that wrote to Alhuda in the last 24 hours, so for most new people it is skipped and the card says so and why; the office then tells them by phone. - Audit. Every appointment and dismissal is one
AuditLogrow (tour_leader_appointed/tour_leader_dismissed, entityTravelGroup), naming the actor, the group and the person (AUD-001).
Enforced by (20260929190000_a_tour_leader_is_appointed.sql): appoint_tour_leader(p_group_id, p_user_id) (SECURITY DEFINER; groups.edit; refuses a missing, deleted, deactivated or paused login; inserts the UserRole for TOUR_LEADER when missing; sets leadUserId; releases the outgoing leader's role when they lead nothing else; audits; notifies), dismiss_tour_leader(p_group_id) (groups.edit; clears leadUserId; fld_release_tour_leader_role() removes the role only when no group is led; audits; notifies), tour_leader_candidates(p_q) (groups.edit; runs as the definer so a thin ops login needs no read on User; at most 20; never paused, inactive or deleted). PATCH /groups/:id with leadUserId goes through the same two functions, so an old caller grants the role the same way. All three are closed to anon (ACC-053). Test: supabase/tests/a_tour_leader_is_appointed.sql (a traveller appointed holds exactly CUSTOMER + TOUR_LEADER, is not staff, sees their group and nothing of another, records a check-in, reads no User row but their own; dismiss removes the role only with the last group; a partner and an employee the same way; a viewer, anon, a paused and a deactivated login refused; the audit rows and the notification exist).
Not built: an approved WhatsApp template for the invitation (so the note reaches a number outside the 24-hour window); an invitation from the desktop group page; a leader for a group without groups.edit (a sales user without it asks operations).
Health and insurance
HLT-001 · Vaccination records
Status: PROPOSED · Owner: Operations · Source: Saudi Ministry of Health requirements for Hajj/Umrah (verify each season) Each traveller has required vaccinations recorded (e.g. meningococcal ACWY; others as notified) with certificate and date. Missing required vaccination blocks JRN-003 readiness.
HLT-002 · Insurance
Status: PROPOSED · Owner: Operations Each traveller has an insurance record: insurer, policy number, cover period, emergency helpline, and whether it is the insurance bundled with the Saudi visa or additional cover. Shown to tour leaders and on incidents.
HLT-003 · Special needs and medical notes
Status: PROPOSED · Owner: Operations Wheelchair / mobility, dietary, chronic conditions, medication, pregnancy, and emergency medical notes are recorded with the traveller's consent, visible only to users with health permission and the assigned tour leader.
HLT-004 · Fitness declaration
Status: OPEN · Owner: Management Decide whether elderly or high-risk travellers must provide a medical fitness declaration before confirmation (and for Hajj, per the Haj Policy).