Who Can Call Me: Keeping Phone Numbers Private
How Duleko keeps phone numbers private: their own table, one can_call() check behind every Call button, and a chat that refuses messages with a number in them.
On Duleko, most work is still agreed the way it always has been in Nepal: someone taps Call and talks it through. That makes a phone number the most useful thing on a profile, and also the one thing a person can't take back once it's out. You can block someone inside an app. You can't block them from a number they've already written down, and most people have had the same number for years. This post covers where Duleko keeps phone numbers, how the rule for who sees them kept changing in its first month, and why one Postgres function, can_call(), now answers that question everywhere.
Numbers in their own table
From the first migration, phone numbers have lived apart from profiles. The profiles table holds what a public profile shows: name, photo, skills, area. It is readable by guests, because anyone can browse Duleko without an account. Numbers sit in a separate profile_contacts table, one row per person, and row-level security decides who can read each row. Its owner can always read and change their own row. Nobody else can read a row unless a function says yes.
Keeping the number off the profile row is what makes the rest simple. When guest browsing arrived, the profile, skills, reviews and certificates were opened to Supabase's anon role, and profile_contacts wasn't touched. There was no column to forget to hide, because the number was never in the row that went public.
Locked, then open, then a choice
In the first version, in early September 2026, the gate was strict. You could see someone's number, and message them, only after a work request between you had been accepted, and a day later accepted friends were let through too. It sounded safe. In practice it was a chicken-and-egg problem: you couldn't talk to someone to arrange the work that would let you talk to them.
The next day, for the beta, the gate was opened: any signed-in member with a finished profile could call or message anyone. Guests still saw nothing. The migration that did it kept the strict version in a comment headed "TO PUT THE GATE BACK", because I wasn't sure the open version would last.
It lasted about two weeks. Opening everything fixed the chicken-and-egg problem but made the choice for everyone, and a tutor who is happy to chat with strangers isn't always happy to be phoned by them. On 19 September the rule became a setting each person picks for themselves, "Who can call me", and chat and calls were split into two separate questions.
One function decides
The setting is a column on the profile, call_permission, and the decision is one SQL function, can_call(target). It is security definer, so it can look at friendships and jobs the caller can't read directly, and it answers in a fixed order:
- Signed out: no.
- Your own number: yes.
- An accepted friend: yes, whatever they picked.
- "Everyone": yes, for any signed-in member.
- "After work acceptance", the default: yes once a job between you is accepted, confirmed or completed.
- "Nobody": no.
Fewer choices, same rule
The first version had four choices: everyone, after accepted work, friends and nobody. The same afternoon I made accepted friends always able to call, whatever was picked, because a friendship on Duleko is something both people agreed to. That made "friends" and "nobody" the same rule, so the migration moved everyone who had picked "friends" to the default and the option went away.
The app still shows three cards, and the one stored as 'nobody' is labelled "Friends only". That's not a trick: it's what 'nobody' now means, and "Friends only" describes it better than a word that suggests no one at all. The choice is asked right under the phone number when someone joins, so it's made while the number is in front of them, not found months later in Settings.
The read policy on profile_contacts is one line: a signed-in member may select a row when can_call(profile_id) is true. The Call button simply appears when the number comes back. The app never decides whether you are allowed to call. It only asks Postgres for the number and shows what it gets.
Everything else asks the same question
Because the rule is a function rather than a policy written inline, later features could reuse it instead of copying it. When members could add bank and eSewa or Khalti details for being paid, can_see_payment_methods() let through anyone who can_call() that person, plus the other side of a job with them. When Duleko started asking callers "Did you have a deal?" after a call, the log_call() function that records each tap on Call refuses with CALL_NOT_ALLOWED unless can_call() says yes, so nobody can log a call to a person they couldn't phone.
The payoff showed up when friends became always allowed. That was one change to can_call(), and the Call button, the payment details and the call log all followed it in the same migration, with nothing in the app to update.
Chat is open, numbers aren't
Splitting chat from calls left an obvious gap. Chat is open to every signed-in member, so someone who couldn't see a number could just ask for it in a message, and someone else could send theirs to a stranger who then passes it around. Either way the setting is bypassed. So the messages table has a trigger that rejects any message containing a phone number with the error PHONE_NUMBER_NOT_ALLOWED.
The check, text_has_phone_number(), first turns Nepali digits (०-९) into ASCII ones, then looks for a run of at least nine digits, allowing spaces, dashes, dots and brackets between them. Nine catches a Kathmandu landline written as 01-4XXXXXX as well as a 10-digit mobile number, while a date like 2026-10-19 has only eight digits and passes. Commas aren't allowed inside a run, so a price written the Nepali way, Rs 1,50,000, isn't mistaken for a number. The trigger runs on edits as well as new messages, since otherwise you could send "hi" and edit it into a number a second later.
The app runs the same check before sending, so people see a friendly message instead of a database error, but the trigger is what enforces it: the public API key ships in every copy of the app, and anyone can insert into messages without it. The only exception is Duleko's welcome account, which is me: its first message to new members gives my WhatsApp number for feedback.
It isn't airtight. A number spelled out in words, or split across two messages, gets through. The trigger stops the casual case, someone pasting a number into a chat with a stranger, which is the one that actually happens.
What I'd keep from this is less the setting itself than where it lives. The rule for who sees a phone number changed four times in Duleko's first month, and each change was a migration that rewrote one function, with the Call button, payment details and call log following along on their own. If the rule had been an if-statement in the React app, each change would have meant finding every place that shows a number, and trusting that old app builds still installed on people's phones did the same.
