20 · The traveller's app
The owner decided on 2026-09-25 to build a native phone app (Expo, apps/mobile) separate from the ERP, with three stacks in one app: the staff ERP on a phone, the tour leader's field app, and a portal for the traveller (the customer). This file holds the rules for the traveller's side and for the programme of a departure. The tour leader's own rules stay in 14 · Field operations (FLD-001 … FLD-006); the staff stack follows the same rules as the desktop.
Everything a traveller can see or do is decided by the database: every read and write is a SECURITY DEFINER function or a row-level policy that takes the person from the session. The app hides what a person may not do; it stops nobody (ACC-001).
Migration: 20260928220000_the_app_for_travellers.sql. Test: supabase/tests/the_app_for_travellers.sql. TRV-011 (documents): 20260929000000_a_traveller_sees_their_documents.sql, test supabase/tests/a_traveller_sees_their_documents.sql. TRV-013 (the e-ticket sheet and the hotel voucher as files): 20260929120000_an_eticket_and_a_voucher_are_files.sql, test supabase/tests/an_eticket_and_a_voucher_are_files.sql. TRV-007 … TRV-009 add no migration: they call the portal functions the website already uses (20260917150000, 20260918100000, 20260926150000). TRV-010 (the notification inbox): 20260928235000_a_notification_is_kept.sql, test supabase/tests/a_notification_is_kept.sql. TRV-008 (a closed departure is on sale nowhere) and TRV-014: 20260930190000_the_traveller_app_works.sql, test supabase/tests/the_traveller_app_works.sql, which also runs every read of the traveller's and the tour leader's screens as each kind of login. TRV-016 (sign-up with a WhatsApp code): 20261005110000_a_customer_signs_up_with_a_whatsapp_code.sql, test supabase/tests/a_customer_signs_up_with_a_whatsapp_code.sql. TRV-017 (sign-up by chatting on WhatsApp): 20261005140000_a_customer_signs_up_by_chatting_on_whatsapp.sql, test supabase/tests/a_customer_signs_up_by_chatting_on_whatsapp.sql. TRV-018 … TRV-022 (the guide in five languages, verses and duas, men and women, Sunni and Shia, only your own trip's guide): 20261008200000_guide_languages.sql, test supabase/tests/guide_languages.sql.
The traveller
TRV-001 · A traveller sees their own trip
Status: DECIDED 2026-09-25 (owner); amended 2026-09-26 (owner: a passenger sees their trip) · Owner: Operations/Management
A traveller — a customer whose login is linked to their record (Customer.userId, never matched by email, PTR-003) — sees in the app every booking that is theirs as customer or payer: the booking and its status, the departure (name, code, dates, trip type, the tour leader's name and phone), the passengers with their passport details, the emergency contact (INC-005), and the money — total, paid, balance — that they may already read in the portal. Nothing of anybody else's, and nothing internal: no PNR, no costs, no notes.
A passenger sees their trip too. A traveller whose customer record is a passenger (not cancelled) on someone else's live booking — the father books, the mother and the son travel — sees that trip in their own app, marked as "Passenger on trv_my_trips() — answers the bookings in auth_customer_booking_ids() (role customer or payer) and in auth_passenger_booking_ids() (role passenger, with bookedBy, the money null, the other passengers' passport, date of birth and nationality null, isMe on the caller's own row); carries travelling (FLD-006 window), pendingPassport per passenger (TRV-005), fieldConsent (FLD-005) and locationSharing (FLD-006). auth_passenger_ids() / auth_passenger_booking_ids() (SECURITY DEFINER, the caller's own login only, anon revoked) are read only inside trv_my_trips, trv_is_my_group (so trv_trip_programme and the GroupActivity / GroupNotice row policies), trv_set_location_sharing, trv_submit_passport (own passenger row only) and trv_my_documents. No row policy changes: Booking, Payment, BookingPassenger, Quotation and CustomerRequest stay customer-or-payer only, so a passenger reads none of them directly, and customer_submit_payment, customer_create_request (with a booking) and razorpay-order still refuse them. Migrations: 20260928220000_the_app_for_travellers.sql, 20260930200000_a_passenger_sees_their_trip.sql. Tests: the_app_for_travellers.sql, a_passenger_sees_their_trip.sql.
Not built: a push of a group notice to a passenger's phone (trv_group_audience names the customers and payers; a passenger reads the notices on the programme, but they are not pushed to their phone or kept in their inbox).
TRV-002 · One step-by-step guide per trip type
Status: DECIDED 2026-09-25 (owner) · Owner: Operations
The company writes one step-by-step guide per trip type — Umrah, Hajj, ziyarat, Umrah + ziyarat, Hajj + ziyarat, domestic, international — in phases (before you go, travel day, arrival, the rituals, the stay, coming home). Steps for ALL apply to every trip and are shown with the trip type's own. The guide is written by staff with programme.manage, read by every signed-in traveller in the app, and a change reaches the phones live. An inactive step is read only by those who write the guide. The company starts with a seeded guide for every trip type and edits it in the app; a seeded step that staff have edited is never overwritten by a release.
Enforced by: TripGuideStep (UNIQUE (tripType, position); RLS SELECT for authenticated on isActive or programme.manage; no table write for anyone); trv_upsert_guide_step(p) and trv_delete_guide_step(p_id) (need programme.manage); the seed in the migration inserts WHERE NOT EXISTS on (tripType, position); the table is in the supabase_realtime publication. programme.manage is granted to SUPER_ADMIN and to every role that holds groups.edit (PERMISSIONS.md §5.6). Test: the_app_for_travellers.sql.
Amended 2026-10-03: the guide is read in five languages, with verses and duas as their own parts, for men and women, and for Sunni and Shia travellers — TRV-018 … TRV-021. The app reads it through trv_guide(). Read straight from the table (an app installed before 2026-10-03), a traveller now gets only approved steps marked for both traditions, of the trip types of their own trips (TRV-022; RLS TripGuideStep select, 20261008200000). An app installed before then therefore no longer shows the seeded rituals, which are now marked Sunni (TRV-021), until it updates.
TRV-018 · The guide is read in five languages
Status: DECIDED 2026-10-03 (owner) · Owner: Operations · Source: issue #560, decisions log 2026-10-03
English is the master; the guide is also written in Urdu, Hindi, Kashmiri and Arabic. Urdu, Kashmiri and Arabic read right to left. Each step's title and text, and each part's text or meaning, has a version per language with a status, draft or approved. A traveller reads only approved text. Where a step or part has no approved text in their language, they read the English with a small "Not translated yet" note. Staff write each language by hand in the guide editor; a new translation is a draft. Changing approved text keeps the approved text (owner, 3 Oct 2026): travellers go on reading the last approved version, and the change waits beside it as a draft until it is approved, when it replaces it. This holds for translations, plain text and religious text alike. The editor shows both, marked "Change waiting", with Approve and Discard the change. Text that was never approved shows nothing in its place (religious English) or gives way to the English (a translation). Clearing a translation removes it. The plain English of a step or part is approved by being saved by a programme.manage holder, as before; religious English waits for approval (TRV-019).
When the source changes (Hamid's review of #562): once a change to a step's English, or to a part's Arabic, transliteration or English text or meaning, reaches travellers, every approved translation of it is marked Source changed — needs review. Travellers keep reading the approved translation; the editor lists it as needing someone, and approving it again (by whoever may approve that text) clears the mark.
Saving keeps what it does not send. A field left out of a save keeps its value — an app installed before this release sends no audience or tradition, and its saves no longer reset a step to Everyone and Both. A save that changes nothing leaves someone else's waiting change as it is; Discard the change is the way to drop one.
The traveller chooses the language in Me → Guide (or Change at the top of the Guide); until they choose, the phone's language is used, and English when the phone's is none of the five. The choice is kept on their own login.
Fonts: Urdu and Kashmiri show in Noto Nastaliq Urdu, Arabic and every verse or dua in Amiri (Naskh), both under the SIL Open Font License, bundled with the app's JavaScript so an over-the-air update carries them; until they load, the phone's own font is used. Each block of text takes its direction and font from its own language; the app's layout is not mirrored. Because Android ignores the writing direction, each line of Urdu, Kashmiri or Arabic is shown with a right-to-left mark (U+200F) in front — on the screen only; nothing stored changes, and the Arabic in the database is untouched.
Enforced by: TripGuideTranslation (one row per step or part and language, UNIQUE per target and language; status draft/approved; sourceChangedAt for "needs review"; origin staff, with machine reserved); trv_save_guide_translation(p) (programme.manage; a new translation is a draft; a change to an approved one goes into draft beside it, with draftedBy; empty → removed); trv_guide_set_status(p_target, p_id, p_lang, p_status) (approved puts what waits in place of the approved text; discard drops a waiting change); the same draft/draftedBy columns on TripGuideStep and TripGuidePart; trv_guide(p_trip_type, p_lang, p_show_both) returns approved text only, the English otherwise with notTranslated; TravellerGuidePreference written only by trv_set_guide_preferences(p_lang, p_tradition) for the caller's own login. The new tables have row security and no policy: only these functions read or write them (ACC-053). Migration 20261008200000_guide_languages.sql. Tests: supabase/tests/guide_languages.sql, apps/mobile/src/lib/guideLanguages.test.ts.
Not built: automatic drafting. No translation is made by a machine; the origin column is where a machine draft would be marked, and such a draft would still need Approve (it needs a translation service and a key set by Hamid — issue #560). Also not built: a list of every translation waiting across trip types (the editor filters one trip type at a time with Only steps not approved in …); editing translations on the website; the reminder e-mail's "What to carry" in any language but English.
TRV-019 · Religious text is approved by a named person
Status: DECIDED 2026-10-03 (owner) · Owner: Operations · Source: issue #560
Religious text is a verse or dua part — its Arabic, its transliteration, its meaning in every language — every step in the phase The rituals (how the rites are done is fiqh, whatever its marking; Hamid's review of #562), and anything marked Sunni or Shia (a step or a part, or anything inside such a step), with their translations. It is approved only by a holder of guide.religious.approve, which the office gives to the people it trusts for religious content; SUPER_ADMIN holds it, no other role. Other guide text is approved by programme.manage holders. Writing and changing the guide still needs programme.manage; an approver of religious text without it can read the editor and approve, not write. A second person approves religious text (owner, 3 Oct 2026): whoever wrote or last changed it (draftedBy) cannot approve it, even holding guide.religious.approve; another holder must — the maker-checker rule of ACC-020. The editor disables Approve for them and says why. Plain text stays one-person: a programme.manage holder may approve a translation they wrote. Every approval records who approved it and when, and is in the audit trail.
A verse or dua is its own part of a step: the Arabic exactly as staff entered it from a verified source, a transliteration in Latin letters, the meaning (English, and per language), and the source (surah and ayah, or the hadith reference). The Arabic is never machine-generated and never changed by code: it is stored and shown byte for byte — not trimmed, not normalised (a waiting change keeps it the same way, in draft). A change to approved religious text waits beside it; travellers keep reading the approved verse, transliteration and meaning until a second person approves the change. Text is religious if it is so as it stands or as the change would make it, so moving a step from Both to Sunni, or from Shia to Both, or into or out of the rituals, also waits for a second holder.
Enforced by: trv_guide_step_religious(phase, tradition), trv_guide_is_religious(kind, tradition, step religious); trv_guide_set_status() refuses religious text without guide.religious.approve, refuses its draftedBy (insufficient privilege, "another holder … must"), refuses plain text without programme.manage, and a repeated call changes nothing; trv_upsert_guide_step() / trv_upsert_guide_part() (need programme.manage; plain text approved by the save; a change to approved religious text kept in draft with draftedBy); TripGuidePart (CHECK: a verse or dua has Arabic and a source); audit triggers on TripGuideStep, TripGuidePart, TripGuideTranslation (AUD-001). Permission seeded by 20261008200000 (PERMISSIONS.md §4.4b, §5.6). Tests: guide_languages.sql.
TRV-020 · Men and women read what applies to them
Status: DECIDED 2026-10-03 (owner: "if women and men have separate things to do, keep that in mind") · Owner: Operations · Source: issue #560
Each step, and each part of a step, is marked Everyone, Men or Women — for example ihram dress, raml and idtiba, the talbiyah aloud or quietly, shaving or trimming, and guidance for women during menstruation. A traveller reads what is for everyone and what is for their gender, taken from their own customer record (Customer.gender), else their passenger rows (BookingPassenger.gender); where those disagree or say nothing, the gender is not known. A Show both switch on the Guide shows the other's steps too, each marked. With no gender recorded both are shown, each marked, and the Guide says why. The gender is not stored again and nobody else sees it here.
Enforced by: audience on TripGuideStep and TripGuidePart; trv_guide_gender_of() (internal); trv_guide_resolve() filters by audience unless "show both" or the gender is unknown. Tests: guide_languages.sql.
TRV-021 · Sunni and Shia are kept apart
Status: DECIDED 2026-10-03 (owner: "If Shias and Sunnis have different [practices] then keep them separate, default be Sunni, but add switch or dropdown to switch to Shia") · Owner: Operations · Source: issue #560
Each step, and each part (verses and duas included), is marked Both, Sunni or Shia. Where practice differs, each tradition gets its own version, written and approved separately (TRV-019). Two parts of one step with the same sort order are versions of one another. The traveller's Tradition — Sunni by default, or Shia — is set in Me → Guide, kept on their own login, and changeable at any time. It is never inferred from a name or anything else, and no staff screen reads it, sorts by it or labels anyone with it; it is not in the audit trail and is deleted with the account. A traveller never reads the other tradition's text in place of their own: where a version for their tradition has not been written or approved, the guide says so plainly ("The Shia version of this part has not been written or approved yet"), and a step for their tradition still waiting for its first approval is counted in its phase, not shown. Changing a step's tradition waits like any change to religious text: until a second holder approves it, the step is read under its old marking, by the travellers it was approved for; once approved, the step moves to the new tradition with its parts and translations as they were approved, and the travellers of the old tradition no longer see it. (Showing the old version to the old tradition after approval was not kept: one step has one marking.)
The seeded rituals are Sunni until Shia versions are written. The guide the company started with (20260928220000) describes the rites as Sunni practice — the prayers at Mina shortened but not joined, Dhuhr and Asr joined at Arafat, raml and idtiba, the end of Umrah after the hair, the farewell Tawaf, when the Talbiyah stops, ihram on the plane — so 20261008200000 marks every seeded Umrah and Hajj step in the rituals, and Umrah 12 and 13 and Hajj 12, 13 and 19, as Sunni (a step the office has written since is left as it is). Documents, health, packing, arrival, the days in Makkah and Madinah, ziyarat and general travel stay for both: travel practice, not fiqh. Because the rites apply to everyone, a Shia traveller is told in the rituals phase "N steps here are written for Sunni travellers only. The Shia version is not available yet." (counted per phase: the steps of the other tradition less those of their own; not paired step by step), and is never shown the Sunni text.
Language, tradition and gender together decide what a traveller reads; staff can Preview as any combination in the guide editor.
Enforced by: tradition on TripGuideStep and TripGuidePart; trv_guide_resolve() (parts in a slot with nothing approved for the tradition become a missing marker; waiting counts draft steps); trv_guide_preview() (programme.manage or guide.religious.approve); TravellerGuidePreference (no policy, no audit trigger; account_anonymise() deletes it); the "What to carry" reminder e-mail (booking_reminder_carry) takes only approved steps for both traditions. Tests: guide_languages.sql.
Not built: outside the rituals, a marker for a whole step that exists for one tradition only — such a step is simply not shown to the other (a practice of one tradition, such as a ziyarat, may have no counterpart); a counterpart is marked by writing it, when it is then counted as waiting until approved. Pairing a Shia step with the Sunni step it answers: the rituals marker counts them.
TRV-022 · A traveller reads only their own trip's guide
Status: DECIDED 2026-10-03 (owner: travellers on domestic and international tours never see the Hajj, Umrah or ziyarat guides) · Owner: Operations · Source: Hamid's review of #562
A traveller reads the steps for every trip (ALL) and the guide of the trip types of their own live bookings — as customer, payer, passenger or partner — and nothing else: a traveller on a domestic or international tour never reads the Hajj, Umrah or ziyarat guides (HAJ, UMRAH, ZIYARAT, UMRAH_ZIYARAT, HAJ_ZIYARAT). A departure with no trip type is general, never religious: the departure has no field that tells domestic from international reliably, so such a trip reads the steps for every trip only (until 3 Oct 2026 it got the Umrah guide). A traveller with no trip yet (no booking, or still loading) sees only the steps for every trip, with "Your trip's guide appears here once you are booked"; there is no trip picker for a traveller. A tour leader reads the guide of the trip types of the groups they lead. Staff with programme.manage, groups.view or guide.religious.approve read every guide, and the picker on The travellers' guide (/trip-guide) is for staff and tour leaders.
Enforced by: trv_guide_readable_types(); RLS TripGuideStep select (an ordinary login reads only those types); trv_guide() answers any other requested type with the ALL steps only; trv_my_trips() and trv_trip_programme() give an untyped departure the trip type ALL (patched in place by 20261008200000, anchor asserted); booking_reminder_carry() likewise. The app: guideTripChoice() in apps/mobile/src/lib/guideLanguages.ts (unit-tested), components/TripGuide.tsx. Tests: guide_languages.sql, apps/mobile/src/lib/guideLanguages.test.ts.
Not built: telling domestic from international for a departure with no type (the departure would need a destination country).
TRV-003 · The programme of a departure
Status: DECIDED 2026-09-25 (owner) · Owner: Operations
Every departure has a programme: what happens on which day, when and where (rituals, ziyarat, bus, flight, hotel, meal, meeting point, free time), each line with a status (planned, live, done, cancelled). The flights and hotels come from the itinerary the office already keeps; the activities are written by staff with groups.edit or by the group's own tour leader. The programme is read by the travellers on the group, the partner who booked them, the group's leader, and staff with groups.view — a traveller and a partner without the PNR. A change reaches the phones live.
It is the departure's only programme (INV-008, amended 2 Oct 2026): the office plans it day by day on the web (the group's Itinerary tab) and the tour leader keeps it up to date on the phone — the same rows. The activities come once, in activities; the itinerary carries the flights, hotels and transfers.
Enforced by: GroupActivity, written only by trv_upsert_activity(p) / trv_delete_activity(p_id) / trv_reorder_activities(p_group_id, p_ids) (trv_can_write_group: groups.edit, or field.checkin and TravelGroup.leadUserId = auth.uid()); on the web through POST /groups/:id/activities, POST /groups/:id/activities/reorder and DELETE /groups/:id/activities/:activityId (groups.edit); read through trv_trip_programme(p_group_id) (trv_can_see_group: fld_can_see_group or trv_is_my_group — a live booking on the group that is mine as customer, payer or partner), which strips pnr and ref from the itinerary unless the caller may see the group as leader or staff; RLS SELECT on GroupActivity uses trv_can_see_group; in the realtime publication. Since 20261006160000, trv_trip_programme leaves the activities out of itinerary and orders them by date, start time (untimed last) and sortOrder. Tests: the_app_for_travellers.sql, one_programme_per_departure.sql.
TRV-004 · Notices to a group
Status: DECIDED 2026-09-25 (owner) · Owner: Operations
The office or the tour leader posts a notice to everyone on a departure ("bus at 9", "Rawdah slot moved"); a notice can be pinned. It reaches the group's phones live and as a push alert to the logins on the group: every traveller with a login (as customer or payer) and the group's leader. A notice is never edited or deleted from the app; a correction is a new notice.
Enforced by: GroupNotice, written only by trv_post_notice(p_group_id, p_title, p_body, p_pinned) (trv_can_write_group); RLS SELECT uses trv_can_see_group; in the realtime publication. The audience for the push is trv_group_audience(p_group_id) — user ids only, answered only to a caller who may write the group — and the app hands them to push-send as userIds. Test: the_app_for_travellers.sql.
Not built: a history of the notices a phone was sent; the phone shows the notices on the programme, and the alert opens that screen.
TRV-005 · A passport scanned in the app waits for review
Status: DECIDED 2026-09-25 (owner) · Owner: Operations · Source: LC-006, PAX-007
A traveller (or a member of staff, or the booking's partner) photographs a passport in the app. The fields read from it — by the company's extractor or from the machine-readable lines on the phone — become a pending submission against one passenger. Nothing on the passenger changes until a person with bookings.edit reviews the submission and applies it; they may reject it instead. A decided submission is never decided again. A scan never creates a customer: a customer record is made by a booking, not on its own (LC-006), so a scan is always attached to a passenger who already exists on a booking.
Enforced by: TravellerPassportSubmission (RLS SELECT for the booking's customer, partner, or bookings.view; no table write); trv_submit_passport(p_passenger_id, p_fields) — refuses a passenger who is not on my booking (customer, payer or partner) unless I hold bookings.edit, keeps only the ten passport fields, and refuses a scan with no passport number; trv_review_passport_submission(p_id, 'apply' | 'reject') — needs bookings.edit, copies the fields onto BookingPassenger under FOR UPDATE, and returns a decided row unchanged. The photo itself is not kept by the app; it is filed on the company Google Drive as the customer's passport document by the edge function upload-customer-doc, which allows staff with customers.edit or customers.create for any customer and a traveller only for the Customer row whose userId is their own login (the same test as auth_customer_ids()), and, when given a passengerId, refuses one the caller may not see or one linked to a different customer. Test: the_app_for_travellers.sql.
TRV-006 · Who the app serves
Status: DECIDED 2026-09-25 (owner) · Owner: Management
One app, three stacks, chosen from the login: staff (any staff role; each tab and action appears only for the permission it needs, the same functions the desktop calls), the tour leader (TOUR_LEADER, FLD-001), and the traveller (CUSTOMER). A partner (AGENT) gets the partner's stack (PTR-004, PTR-080 … PTR-082). Nobody gets more in the app than on the web: a permission the person does not hold hides the screen, and the database refuses the call in any case.
Enforced by: auth-login (one door for signing in, ACC-062) and the app's session, which reads the person's roles and permissions and routes to the stack; every function above checks the caller itself. The web phone layout /m (Phone app) stays for staff who prefer the browser.
Not built: the partner stack; incident reporting from the app (INC-001 — the SOS sheet calls the leader and the office); directions or search on the in-app map (OpenStreetMap in a WebView; "Open in Maps" hands over to the phone's maps app).
The traveller as a customer
The owner's words (2026-09-25): the app should serve as travel planner and companion for customers; they should be able to sign up and book tours. Nothing in this section adds a table or a function. Every write is a portal function the web customer portal has called since 20260918100000_data_isolation_portals.sql (Portals §1: a portal user never writes a table directly), and every read is the same table under the same row policy. The app is a second door to the same room, so the two cannot drift.
TRV-007 · A traveller creates their own account
Status: DECIDED 2026-09-25 (owner); amended 2026-10-01 (owner: an email sign-up is a login only — LC-OPEN-5 "Yes") · Owner: Sales/Operations · Source: Portals §2, LC-006, LC-OPEN-5
A traveller creates an account with an email address — in the app (first and last name, a mobile number, an email, a password, the privacy tick) or on the website's customer sign-up (name, email, password, the captcha). Both call the customer-signup function: it creates the auth user unconfirmed, has the database write the User row (the name, the number in E.164, the email) and the CUSTOMER role through auth_customer_login_create(), and mails the confirmation link itself. No Customer row is made at sign-up (LC-006): the first booking request (TRV-008) makes it from the login — name, number, email, and a SIGNUP-… placeholder passport until the booking brings the passport. Until then the portal and the app read empty and say "Your details will be added with your first booking"; any other request is refused in those words. A record is linked by Customer.userId only — never matched by email (PTR-003). An address that already has an account gets the same answer as a new one (ACC-062). A new account is a customer and nothing more (ACC-001): it sees no booking until the office creates one from its request or links an existing one to it. The app's customer-exchange (a confirmed login whose rows were never written) writes the same login, never a record.
Amended 2026-10-01. Before, the website's sign-up wrote the User row from the browser and called self_register_customer, and the app's customer-signup wrote a Customer row too — each with a blank passport number. Customer."passportNo" is unique, so after the first blank row the app's sign-up silently made no record and the website's reported an error after the login was already made. Now no sign-up writes a record, and self_register_customer — no longer called at sign-up, kept for an old cached page — makes the record through customer_record_for_login() with a placeholder passport, never a blank one. Existing rows are not touched.
Enforced by: customer-signup (createEmailLogin() in its logic.ts; the auth user is deleted again if the database refuses) and customer-exchange, both through auth_customer_login_create() (service role only; never a Customer row; a staff login is left alone); customer_create_request() → customer_record_for_login() on the first booking request; self_register_customer() (migration 20261005120000_an_email_sign_up_is_a_login_only.sql); portal_require_customer() refusing in plain words; RLS Customer select own (20260918100000) — a login reads its own row and nobody else's. Nothing in the browser or the app inserts into User, Customer or UserRole at sign-up. Tests: supabase/tests/an_email_sign_up_is_a_login_only.sql, supabase/tests/security_lockdown.sql, supabase/functions/customer-signup/logic.test.ts (Deno), src/pages/customer/CustomerAuth.test.tsx, src/lib/api.portalIsolation.test.ts, apps/mobile/src/lib/emailSignup.test.ts.
TRV-016 · A customer signs up with a code on WhatsApp
Status: DECIDED 2026-10-01 (owner: "lets create customer sign up through whatsapp" — both a code on the sign-up page now and a sign-up inside a WhatsApp chat later; sign-up makes a login only) · Owner: Sales/Management · Source: TRV-007, LC-006, LC-OPEN-5, ACC-062, ACC-069, ACC-076
A customer creates an account on the website's customer sign-up (/customer/auth → Sign Up → Mobile (WhatsApp), the default) or on the app's sign-up screen in three steps: (1) their name, their mobile number and, if they like, an email address, then Send code on WhatsApp; (2) the six-digit code; (3) a password (with the eye toggle) — the account is made and they are signed in. Customers only: not partners, not staff.
- The same words for every number. "Send code" is answered with the same words whether the number is new, already has an account, or asked too soon (ACC-062). A number that already has an account — on a login, on a customer record with a login, or on a partner record with a login, compared on the last ten digits — gets no code; the page always offers Already have an account? Sign in · Forgot password. A number shared by two accounts is taken twice and cannot sign up either.
- The code is made on the server, sent through the approved Meta authentication template the password reset uses (
WHATSAPP_OTP_TEMPLATE_NAME; its words are generic), stored as a hash keyed by a hash of the number, lives ten minutes, is issued at most once a minute per number, and locks for good after five wrong guesses; a new code supersedes the old. A sign-up code and a password reset code are kept apart (two tables, two hashes): neither passes for the other. Every request and every guess is an attempt under the sign-in throttle (ACC-063); the website's form carries the captcha, the app its app key. A code WhatsApp refuses is kept with Meta's words for the office, as for reset codes (ACC-069). - What is made: a login, not a customer record — as for an email sign-up (TRV-007). A
Userwith theCUSTOMERrole (ACC-080 — a portal role, outside the staff role lock), the name, the number as proved (E.164) andUser."phoneVerifiedAt". NoCustomerrow (LC-006). The customer record is made by the login's first booking request (Book this trip, agroup_interestrequest, TRV-008): its name, number and real email are copied from the login, its passport number is a placeholder (SIGNUP-…, read as "no passport") until the booking brings the passport. A question or any other request from a login with no customer record is refused as before. Once the record exists the login's number and the record's are kept in step (ACC-076). - The sign-in identity. Supabase Auth needs an email or a phone identity, and the project's phone provider is not switched on (it would need an SMS provider). The auth user therefore gets a placeholder address that can never receive mail —
wa.<digits>.<random>@signup.alhudatravels.invalid(.invalidis reserved, RFC 2606) — confirmed, with the person's password. The person never sees it. They sign in with their mobile number and password (the one door resolves the number, ACC-062), or with the email they gave. - The optional email is kept on the login (
User.email) unverified — no confirmation email is sent and nothing waits for one — when no other login holds it and it is not a company address; otherwise the login keeps the placeholder and the person signs in by number. The answer is the same either way. A placeholder address is never copied to a customer record or mailed. A forgotten password is reset with a WhatsApp code only: Forgot password? by email sends nothing to such an account (the placeholder receives no mail, and a link to an unconfirmed email could hand the account to whoever owns that mailbox) and answers as always. - The proof goes with the number.
phoneVerifiedAtis cleared by the database when the login's number changes to another number (a new spelling keeps it); no browser session sets or clears it. - Audited as
customer_signed_up(the channel, whether an email was given and kept, the last four digits) and, at the first booking request,customer_record_from_login.
Enforced by: auth-login actions signup_code and signup_verify (supabase/functions/auth-login/signup.ts, the body in logic.ts; its reset action skips a placeholder address); createVerifiedCustomerLogin() in supabase/functions/_shared/customerLogin.ts (the auth user, then the database call; deletes the auth user if the database refuses) and the placeholder address in _shared/signupIdentity.ts; in the database SignupCode, auth_signup_code_issue(), auth_signup_code_check(), auth_signup_phone_taken(), auth_signup_create_login() (only after a code for that number was used in the last 15 minutes and has made no login; never a Customer row), customer_record_for_login(), customer_create_request() and the User_phone_verified_guard trigger — service role only, migration 20261005110000_a_customer_signs_up_with_a_whatsapp_code.sql. Web: src/components/auth/WhatsAppSignUp.tsx on CustomerAuth. App: apps/mobile/src/components/WhatsAppSignUpForm.tsx, apps/mobile/src/lib/whatsappSignup.ts. Tests: supabase/tests/a_customer_signs_up_with_a_whatsapp_code.sql, supabase/functions/auth-login/signup.test.ts (Deno), src/services/authLoginLogic.test.ts, src/lib/authLogin.test.ts, src/components/auth/WhatsAppSignUp.test.tsx, apps/mobile/src/lib/whatsappSignup.test.ts.
Needs from the owner: nothing new — the authentication template already approved for reset codes (WHATSAPP_OTP_TEMPLATE_NAME) is reused. Until WhatsApp is configured the page says so and offers the email sign-up.
Amended 2026-10-01: the sign-up inside a WhatsApp chat is built — TRV-017; it calls createVerifiedCustomerLogin() with its own proof of the number ('whatsapp_flow') in auth_signup_create_login(), whose sign-up-page channel ('whatsapp_code') is unchanged.
Not built: confirming the optional email; adding or changing an email later from the portal. Linking an office-made customer record (no login) that has the same number — the first booking request makes a new record and the office merges duplicates as today. Bookings or orders over WhatsApp: deliberately not built (the owner's standing rule).
TRV-017 · A customer signs up by chatting on WhatsApp
Status: DECIDED 2026-10-01 (owner, asked how customers should sign up through WhatsApp — a code on the sign-up page or a sign-up inside the chat: "Both") · Owner: Sales/Management · Source: TRV-016, LC-006, ACC-062, ACC-063, ACC-069
A customer can make an account without opening the website: they write "sign up" to the business number +91 95419 10494 — by tapping the link https://wa.me/919541910494?text=Sign%20up (or its QR code), or by typing it. The reply is a form inside WhatsApp (a Meta WhatsApp Flow, button Create account): one screen, Create your Alhuda account — full name (required), city (optional), email (optional) and a box I agree to the privacy policy (required, links to https://alhudatravels.in/privacy). When they send the form the account is made for the number they are chatting from, and a message comes back: "Your Alhuda account is ready. Set your password here: ". The link opens /customer/set-password, where they choose a password and are signed in. From then on they sign in on the website or in the app with their mobile number and password. Customers only. Sign-up only: nothing in the chat takes an order or a booking (the owner's standing rule).
- The words that start it. "sign up", "signup", "sign-up", "register", "registration", "account", "create account", "new account", "open account", and the same with a greeting or "I want to"/"please" in front, in any case and with any punctuation; the common Urdu/Hindi spellings in Latin letters ("register karna", "account banana hai", "mera account banao", "account kholna") and in Devanagari or Urdu script ("रजिस्टर करना है", "رجسٹر کرنا ہے"). A longer sentence ("I want to book Umrah", "my account balance") is not a sign-up and gets no reply from this.
- A number that already has an account — on a login, on a customer record with a login, or on a partner record with a login, compared on the last ten digits (TRV-016) — gets no form. It gets "This number already has an Alhuda account. Sign in at https://alhudatravels.in/customer/auth or in the Alhuda app with your mobile number and password. Forgot your password? Tap "Forgot password" and choose the WhatsApp code." A number shared by two accounts is taken and gets the same words; it cannot sign up. Why this is not the leak ACC-062 forbids: ACC-062 stops a stranger typing somebody's number into a page and learning whether it has an account. Here the answer goes only to the WhatsApp of that number, as a reply to a message WhatsApp delivered from that number — the person reading it holds the number, and is told about their own number. Nobody can ask about another number this way.
- The form token. The form carries a random token (32 bytes) made on the server, stored as a hash, bound to the number it was sent to (a hash of its last ten digits), live for 30 minutes and spent once. A filled form is accepted only from the same number; from any other number it does nothing. A new form supersedes the old one. At most one form a minute and five an hour per number.
- The proof of the number is WhatsApp itself: Meta signs every delivery to the webhook (the signature is checked, as for every message — ACC-003), and the filled form arrives from the number the token was sent to. The login is made through the same
createVerifiedCustomerLogin()as the sign-up page; the database'sauth_signup_create_login()demands the proof for the channel named — for this channel a form token for that number spent in the last 30 minutes that has made no login — and refuses everything else. A spent form token is not a sign-up code and a sign-up code is not a form token. - What is made is what TRV-016 makes: a
Userwith theCUSTOMERrole, the name, the number as proved (E.164) andphoneVerifiedAt; the optional email kept unverified when nobody else holds it; a placeholder sign-in address; noCustomerrow (LC-006) — the first booking request makes it. The city is kept on the login's sign-up details (auth metadata) only; it is not copied to the customer record. The consent box must be ticked; the server checks it again and the audit row records it. - No password yet. The login gets a long random password nobody knows. The set-password link is a second token: random, stored as a hash, 30 minutes, one use, a new one supersedes the old, and only for an active login that is a customer and nothing else (never staff or a partner). Setting the password goes through
auth-login(set_password_with_token) under the sign-in throttle per link and per address (ACC-063); a wrong, expired or used link gets one answer. A person whose link has expired resets with a WhatsApp code (ACC-069). The app opens the same web page; there is no separate app screen. - Throttles. Every sign-up reply to a number is an attempt under the sign-in throttle (action
signup_whatsapp_flow): after five in 15 minutes the number gets no reply until the window passes; a login made clears it. - Kept and audited. The inbox keeps a filled form as
[sign-up form sent]— never its fields or its token. A reply WhatsApp refused (the form, "already has an account", the set-password link) is kept with Meta's words for the office on WhatsApp delivery problems, as for codes (ACC-069). The sign-up is audited assignup_via_whatsapp_flow(the channel, whether an email was given and kept, the consent, the last four digits); setting the password aspassword_set_with_link. - Setting it up. The form is published once on the company's WhatsApp account from Admin → Integrations → WhatsApp → Set up WhatsApp sign-up form (
admin.integrations.edit). Until it is published, "sign up" in the chat is answered with a pointer to the website's sign-up.
Enforced by: whatsapp-webhook (supabase/functions/whatsapp-webhook/signup.ts; keywords, messages and the form parser in _shared/whatsappSignupFlow.ts; the form in _shared/whatsappSignupFlow.json); createVerifiedCustomerLogin() (_shared/customerLogin.ts, channel 'whatsapp_flow'); auth-login set_password_with_token (auth-login/setPassword.ts); whatsapp-flow-setup (create, upload, publish; setup.ts); in the database SignupFlowToken, auth_signup_flow_issue(), auth_signup_flow_use(), auth_signup_create_login(..., p_channel), PasswordSetToken, auth_password_set_token_issue(), auth_password_set_token_use() and whatsapp_delivery_failures() — service role only, migration 20261005140000_a_customer_signs_up_by_chatting_on_whatsapp.sql. Web: src/pages/customer/SetPassword.tsx (/customer/set-password), src/components/admin/WhatsAppSignupFlowCard.tsx. Tests: supabase/tests/a_customer_signs_up_by_chatting_on_whatsapp.sql, supabase/functions/whatsapp-webhook/signup.test.ts, supabase/functions/auth-login/setPassword.test.ts, supabase/functions/whatsapp-flow-setup/setup.test.ts (Deno), src/pages/customer/SetPassword.test.tsx, src/components/admin/WhatsAppSignupFlowCard.test.tsx.
Needs from the owner: press Set up WhatsApp sign-up form once after the release; the WhatsApp token must be allowed to manage Flows (whatsapp_business_management). No template is needed: every reply is inside the 24-hour window the customer's own message opens.
Not built: a QR code drawn by the system (make it from the link with any QR code maker); a set-password screen inside the app (the app opens the web page); keeping the city on the customer record; the form in any language but English; orders or bookings over WhatsApp (deliberately — the owner's standing rule).
TRV-008 · A traveller books a tour by request
Status: DECIDED 2026-09-25 (owner) · Owner: Sales/Operations · Source: LC-006, SAL-002, PUB-001 / PUB-002 (decisions log, 2026-09-23)
The Tours tab shows the departures on sale: public_departures() — the posters the website shows, with the image, inclusions, hotels and day-by-day — merged with public_groups(), every departure on open sale with its adult price and the seats left. Both are public reads, so a visitor browses before signing in. Book this trip on a departure is a booking request, never a booking: customer_create_request with type group_interest, the departure's id, and a message naming every traveller (name; date of birth and passport number if given), the room preference, a phone number the office can call and anything else the traveller wrote — the same request the website's customer portal sends. The office reads it, calls, sends a quotation (TRV-009) and creates the booking: a booking is made by the office, and the customer record of a traveller who is new is made by that booking (LC-006). A traveller never writes a booking, a passenger, a seat or a price from the app. The names in the request are kept as what they are — text the traveller typed — on the request, so nobody asks for them twice (SAL-002); they are not passengers and hold no seat.
A departure the office has marked cancelled, completed or departed is on sale nowhere — not on the website, not in the app, not in the customer or partner portals — and a request for it is refused; one marked full stays listed as a waiting list. The app also leaves out a departure whose date has passed.
Enforced by: customer_create_request(p_type, p_title, p_message, p_booking_id, p_group_id, p_attachments) (20260918100000) — takes the customer from the session (portal_require_customer), refuses a group that is neither on open sale nor already the traveller's, refuses a booking that is not theirs, caps the title and message, and returns the first request instead of a second identical one within a minute; RLS CustomerRequest select customer — a traveller reads only their own requests; public_departures() and public_groups() return only what a poster says to show — never cost, margin, rate sheet or another customer — and are the only reads on the anonymous key's allow-list (ACC-053). No function a traveller can call inserts into Booking. Since 20260930190000_the_traveller_app_works.sql both public_departures() and public_groups() leave out a departure whose statusOverride is cancelled, completed or departed, so customer_create_request and partner_create_request (which test public_groups()) refuse it too; the app's merge is apps/mobile/src/lib/tours.ts. Tests: supabase/tests/data_isolation.sql, supabase/tests/the_traveller_app_works.sql, apps/mobile/src/lib/tours.test.ts.
TRV-009 · Quotations and payment claims in the app
Status: DECIDED 2026-09-25 (owner) · Owner: Sales/Finance · Source: SAL-001, FIN-032, REQ-001, LC-OPEN-3
Requests and quotes has three segments. Requests — what the traveller asked (a booking request, a question, a document, a change, a cancellation) and what the office answered; the reason on a decision reaches the traveller (REQ-001). Quotes — the quotations the office sent, with the lines, the discount, the total and the date they are valid until; the traveller accepts or declines in the app, and a decline asks why so the office can revise. customer_respond_quotation answers only the traveller's own quotation, only while it is sent or pending, and not past validUntil (how long a quotation is valid is LC-OPEN-3; the function honours whatever date the office put on it); the note is appended to the quotation's notes. Accepting creates no booking — the office does (LC-006). Payments — the balance on each booking, and I have paid: the traveller records a claim (amount, method, reference) for a payment already made by bank transfer, UPI, cheque, cash or card. customer_submit_payment stores it pending; the office verifies it against the bank before it counts (FIN-032). Pay online — Razorpay (card, UPI, netbanking) on a booking priced in rupees, up to its balance: the app asks razorpay-order for an order (it checks the caller is the booking's customer or payer, the amount and the balance, and holds the key the phone never sees), opens Razorpay Checkout on the phone, and records nothing itself; the verified receipt is recorded by razorpay-webhook on Razorpay's signed event (FIN-032), and the app watches for it before saying "Confirmed" or "Still confirming". Closing Checkout records nothing. Until the owner sets the Razorpay secrets the app says online payment is not switched on and points to I have paid. Nothing else moves money through the app. Only the traveller pays online (DECIDED 2026-09-30, owner: "there shouldn't be option to pay online (via Razorpay for example) for bookings for employees in the mobile app or on the web app, right?"). The staff booking screen has no Pay online and staff are not handed the phone to pay; staff record the customer's payment with Take payment (FIN-032). razorpay-order refuses a staff login that is not the booking's customer or payer.
Enforced by: razorpay-order (online_payment_actor() with the caller's session — customer or partner only since 20261003180000_staff_do_not_pay_online.sql, test supabase/tests/staff_do_not_pay_online.sql — Booking.balanceAmount as the cap, the sign-in throttle with action razorpay_order), razorpay-webhook (HMAC signature), RLS on OnlinePaymentOrder (own bookings, read only) — 20260929130000_an_online_payment_starts_with_an_order.sql, test supabase/tests/an_online_payment_starts_with_an_order.sql; customer_respond_quotation(p_quotation_id, p_decision, p_note) and customer_submit_payment(p_booking_id, p_amount, p_method, p_reference, p_proof_storage_key) (20260918100000) — the payment claim refuses a cancelled or rejected booking, an amount over the outstanding balance, an unknown method and a link in place of a proof file, returns the first payment for a second identical claim within two minutes, and is always pending; RLS Quotation select customer, QuotationLineItem select portal, Payment select customer and Booking select customer — a traveller reads only their own. Tests: supabase/tests/data_isolation.sql, supabase/tests/money_integrity.sql.
Not built: online payment for a booking in another currency; a receipt photo with the claim (p_proof_storage_key is sent as null); a cancellation request with a policy preview — a cancellation is a text request and the office confirms the charge.
TRV-014 · My trip shows the flights, the hotels, the payments and the office
Status: PROPOSED 2026-09-26 · Owner: Operations/Finance · Source: TRV-001, TRV-003, TRV-009, FIN-032
My trip answers the three questions a traveller asks most without opening another screen. When do I fly and where do I stay? — every flight of the departure with its date and times, and the hotel in each city with its dates, nights and room type, read from the itinerary the office keeps; shown before departure as well as during it, and never with the PNR. What have I paid? — every payment recorded on the booking, with its method, date, reference and state in plain words: received (verified by the office), being checked (a claim the office has not matched to the bank yet, FIN-032), not accepted, reversed. A claim counts towards the balance only once the office verifies it. How do I reach the office? — a call, or a written request that lands in Requests. Nothing new is readable: the itinerary is trv_trip_programme (TRV-003) and the payments are the rows the traveller already reads in the web portal.
Enforced by: trv_trip_programme(p_group_id) (strips pnr and ref for a traveller); RLS Payment select customer (20260918100000) — the booking in auth_customer_booking_ids(), nobody else's. The app: apps/mobile/src/lib/tripGlance.ts (pure, unit-tested) and (customer)/index.tsx. Tests: supabase/tests/the_traveller_app_works.sql, apps/mobile/src/lib/tripGlance.test.ts.
Not built: an invoice, statement or receipt to print (the web customer portal prints them); a WhatsApp link to the office (the office's WhatsApp number is not configured in the app).
Notifications
TRV-010 · A notification is kept
Status: DECIDED 2026-09-25 (owner: "a strong app — a notification a person missed must be findable") · Owner: Operations/IT · Source: TRV-004, PLT-060
Every notification sent to a login is kept, one row per person, and the person finds it later in the app's Notifications inbox (and on the web phone layout at /m/notifications): newest first, unread marked, a tap marks it read and opens the screen it is about, "Mark all read" clears the lot. A push that never reached the phone — no device, phone off, alert dismissed — changes nothing: the row is there. A group notice is kept for everyone on the group the moment it is posted, whether or not the push goes out, and not for the person who posted it. A person reads their own rows and nobody else's; nothing a client can call inserts, edits or deletes a row directly. Read rows are dropped after 90 days; an unread row is never dropped by the retention job.
Enforced by: AppNotification (20260928235000_a_notification_is_kept.sql; RLS SELECT on "userId" = auth.uid(); no client write; GRANT SELECT to authenticated only; in the supabase_realtime publication so an open inbox updates by itself); written only by push-send with the service role (one row per userId, before delivery, never failing the send — store:false when the database kept it already), by ntf_notify(p_user_ids, p_title, p_body, p_url, p_kind, p_source_ref) (SECURITY DEFINER; refuses a caller without one of approvals.approve, bookings.create, bookings.edit, bookings.cancel, bookings.cancel.approve, groups.edit — the permissions whose holders already send pushes; at most 500 logins a call, PLT-053), by trv_post_notice (one row per login in trv_group_audience, minus the poster; kind notice, url /programme, sourceRef the notice), by booking_created_notify (one row per sales approver for a new booking, kind approval, sourceRef the booking — LC-007), and by work_tell() (work notes, kind work, marked "pushWanted" so the communications-dispatcher rings the phone — WRK-009, WRK-018, COMM-041). ntf_mark_read(p_ids) and ntf_mark_all_read() touch the caller's rows only; ntf_unread_count() is the badge; ntf_prune(p_days) is executable by the service role only and refuses fewer than 7 days. The anonymous key can execute none of it (ACC-053). Test: supabase/tests/a_notification_is_kept.sql.
Not built: a scheduler for ntf_prune (pg_cron runs only the communications dispatcher, LC-007, the WhatsApp template sync, AUD-024, and the leave accruals, LV-062 — see Communications → Not built); notifications for events the ERP does not push yet (a new enquiry, a WhatsApp message); a notification from the web that is stored without a push — the web goes through push-send, except a new booking, whose rows the database keeps in booking_created_notify before the web pushes with store:false (LC-007).
TRV-011 · A traveller sees their own documents
Status: DECIDED 2026-09-25 (owner) · Owner: Operations/Visa · Source: TRV-001, TRV-003, AUD-020
A traveller sees, in the app, the documents of their own trip and nobody else's: the visa — where each passenger's case stands and the files on it (the visa PDF, a passport scan the office filed); the e-ticket per passenger — its number once issued and its status, never the PNR; the hotel stays of their passengers — hotel, city, dates, room type and room number, never the price; and the papers filed under their own customer record (the passport photographed in the app, a photo). A visa file and a passport copy open on the phone; a ticket record and a hotel stay open the company's e-ticket sheet and hotel voucher once the office has issued them (TRV-013), and until then the screen says the office has not issued it yet. Nothing internal rides along: no PNR, no cost, no supplier, no note. The list is saved on the phone so it opens without signal; opening a file needs a connection.
Enforced by: trv_my_documents() (SECURITY DEFINER, authenticated only, anon revoked) — bookings from auth_customer_booking_ids(), visa cases of those bookings or of auth_customer_ids(), VisaDocument rows not deleted, CustomerDocument rows of the caller's own customer ids, TicketRecord rows of their bookings (no VOID, no pnr), BookingPassengerHotel rows of their passengers (no cancelled, no price); the row policies a customer already has on VisaCase, VisaDocument, CustomerDocument and TicketRecord are not widened, and the staff-only tables the hotel stay comes from are read only inside the function. A file is opened through the edge function drive-file, which runs trv_my_documents() as the caller and serves only a document that list contains (staff with the view permission of that record — customers.view for a customer document, visa.view for a visa document, bookings.view for a ticket or hotel row), answering a link to itself that lives ten minutes and streams the file from the company Shared Drive (ACC-073, ACC-074; until 27 Sep 2026 any of the three permissions opened any document, and a Drive file was copied into a bucket to be signed). Migration: 20260929000000_a_traveller_sees_their_documents.sql. Test: a_traveller_sees_their_documents.sql. Since 20260929120000_an_eticket_and_a_voucher_are_files.sql (TRV-013) the ticket rows and the hotel rows also carry the live issued file — source 'storage', storageBucket, storagePath and file {bucket, path} from the newest un-superseded IssuedDocument of the booking (kind eticket) or of the stay (kind hotel_voucher, refId = the assignment) — so drive-file signs it for the caller with kind ticket or hotel, exactly as it signs a visa file; drive-file also serves an IssuedDocument id directly (kind issued), read as the caller so the row policy decides. Test: an_eticket_and_a_voucher_are_files.sql.
Not built: a document the traveller uploads other than a passport scan (a request with an attachment is a text request). Sharing an issued e-ticket sheet or hotel voucher from the app is TRV-015; a visa file or a passport copy is not shared from the app.
TRV-013 · An e-ticket and a hotel voucher are files
Status: DECIDED 2026-09-25 (owner) · Owner: Operations/Ticketing · Source: TRV-011, AIR §24, LC-004, PTR-010
The office issues, from the booking, the company's own e-ticket sheet and hotel voucher as PDF files, and the traveller and the partner open them in the app. The e-ticket sheet is one per booking: the booking number and group code, every traveller ticketed by Alhuda (name and the last four of the passport) with their ticket number and PNR, the departure's flights (airline, flight number, route, dates and times, PNR), the office's contact for emergencies and a terms line. It is issued only when every such traveller has an issued ticket number — otherwise it is refused and the refusal names who is missing — and it says on its face that it is Alhuda's sheet prepared from the airline's ticket numbers, not the airline's own ticket document. The hotel voucher is one per hotel stay of the booking: the hotel (name, city, stars, distance), check-in and check-out, nights, room type, rooms and the guests, the office's contact and a terms line; it names the group booking as the reference, because the hotel's own confirmation number is not stored, and says it is not the hotel's confirmation. Nothing is issued for a booking finance has not confirmed or that is cancelled (LC-004). Issuing is a staff act needing tickets.view and bookings.view (e-ticket) or hotels.view and bookings.view (voucher). A re-issue replaces the previous file for the travellers; the previous file is kept, superseded, never deleted. The file is read by the booking's traveller, the booking's partner, and staff who may read the booking — nobody else — and it never carries a price, a cost or a supplier.
Enforced by: the edge function issue-document (POST {kind, bookingId, assignmentId?}), which checks both permissions with user_has_permission before reading anything, gathers the booking with the service role, renders the A4 sheet (pdf-lib, the company logo embedded), refuses a missing ticket number by name, a booking not APPROVED / PARTIALLY_CANCELLED, a departure with no flight, a booking with no stay, stores the file on the company Shared Drive under Issued/<booking> (ACC-074; storagePath holds drive:<id>, driveFileId the id) and records it through issue_document_record(p_booking_id, p_kind, p_ref_id, p_label, p_storage_path, p_file_name, p_issued_by) (SECURITY DEFINER, service role only; in one transaction supersedes the live row for the same booking, kind and stay and inserts the new one); the same person's second call within a minute returns the file just issued. IssuedDocument (audited; RLS SELECT for auth_customer_booking_ids(), the booking's agentId = auth_agent_id(), or bookings.view; no client INSERT/UPDATE/DELETE; anon revoked). trv_my_documents() carries the live file on the ticket and hotel rows (TRV-011), and drive-file opens it for the caller (kinds ticket, hotel, issued). The app: apps/mobile/src/components/IssuedDocuments.tsx on the staff booking (Issue, gated by both permissions, Yes/No) and on the partner booking (read only); (customer)/documents.tsx opens the file from the ticket or hotel row. The desktop: src/components/bookings/BookingDocumentsSection.tsx on the booking page (the live rows under RLS, Open through drive-file kind issued, Issue / Re-issue gated by the same pairs, confirmed first, the refusal shown). Migration: 20260929120000_an_eticket_and_a_voucher_are_files.sql. Test: an_eticket_and_a_voucher_are_files.sql; apps/mobile/src/lib/travellerDocs.test.ts.
Not built: the airline's own e-ticket PDF and the hotel's own confirmation (neither is fetched or stored); a sheet per passenger; a push notification to the travellers when a file is issued; a copy of the file on the company Google Drive.
The same table and function also carry the payment receipt since 27 Sep 2026 (kind receipt, refId = the payment): FIN-045.
TRV-015 · An issued document is shared from the phone
Status: PROPOSED (2026-09-27) · Owner: Operations · Source: the owner, 27 Sep 2026 — "give staff more options to work from phone"
An issued e-ticket sheet, hotel voucher or payment receipt (TRV-013, FIN-045) can be shared from the phone as the PDF itself by whoever may open it — staff on the booking, the partner on its booking, the traveller on their own documents and payments. What is shared is the file, never the link: a drive-file link lives ten minutes and opens nothing for the person it is sent to (ACC-073). Staff and the partner also get WhatsApp to the customer (staff: the booking's customer; partner: the first traveller with a phone): it opens the chat with a short note saying which document is ready and where the customer finds it in their app — no link and no attachment; the PDF goes through Share.
Enforced by: the app only — apps/mobile/src/lib/share.ts (which way the file goes, the file name, the WhatsApp number and note; unit-tested in share.test.ts), src/lib/shareNative.ts, src/components/DocumentActions.tsx (Open, Share, WhatsApp) and src/components/ReceiptAction.tsx. Opening and sharing both ask drive-file for the file first, so its checks decide as for opening; nothing new is allowed on the server. The PDF is downloaded into the app's cache with expo-file-system and handed to the share sheet by expo-sharing's native module when the installed build carries it, else — on iOS — by React Native's Share with the local file URL.
Not built: sharing a file on Android with the current builds — they do not include expo-sharing, and React Native's Share carries text only on Android, so Share there opens the PDF and says to use the viewer's own Share; a build that adds expo-sharing shares the file with no change to the app's code. A WhatsApp message with the PDF attached by the system (that needs a Meta document template; see Communications); sharing a visa file or a passport copy.
TRV-012 · Reminders and the offline copy live on the phone
Status: DECIDED 2026-09-25 (owner) · Owner: Operations · Source: TRV-001, TRV-003
The app is a companion, so it works when the phone has no signal and it speaks up before the programme does. Reminders are local notifications planned on the phone from the programme — thirty minutes before every timed item, and at eight the evening before a day with a flight or a bus — never a push from the server; the phone holds at most the next 64, the schedule is kept equal to the programme (a moved or cancelled item's reminder goes, nothing is scheduled twice), and the traveller switches them off on their phone. The offline copy: the trip, the programme, the guide and the documents list are saved on the phone after each load, scoped to the login, shown with "Last updated … · offline" when the network is away, and dropped at sign-out. Nothing is written to the server by either; nothing of one login is shown to another on the same phone.
Enforced by: the app only — apps/mobile/src/lib/reminders.ts (pure planning and diff, unit-tested) and src/hooks/useProgrammeReminders.ts (expo-notifications, the per-phone switch); src/lib/offlineCache.ts (per-login keys, unit-tested) read by useMyTrips, useTripProgramme, useMyDocuments and the Guide. No table, function or policy: the server is not involved, so there is nothing for the database to enforce.
Not built: reminders planned without opening the Programme tab (a background refresh); a reminder for a notice; an offline copy of the tours on sale, the requests and the quotations.