Mapping People Without Revealing Where They Live
How Duleko's map shows skilled people nearby without revealing where anyone lives: a work area picked once, ~1 km squares and counts for guests, in Postgres.
Duleko is a local skills marketplace for Nepal, and "who's near me" is most of the point: the electrician two villages over, the tutor in the next ward. A map is the obvious way to show that. It's also the easiest way to publish where people live, because in a village someone's work area and their home are usually the same place. Duleko now has a map, on Home and full screen, built on Leaflet and OpenStreetMap. Its first version didn't last the day: a few hours after it shipped, I rewrote its privacy rules. This post covers what was wrong with the first version, the rules the map follows now, and why every one of them lives in Postgres rather than in the app.
Why the first version wasn't private enough
The first version already did the obvious things. Exact points moved out of the profile row into a profile_locations table that only its owner can read, and a trigger blanked the old columns, so older Android builds that still wrote there leaked nothing new. Other people saw each point snapped to a ~300 m grid, and anyone could switch on "Show my exact spot".
Looking at it again that afternoon, too much was still findable. Anyone with the app's public API key, which ships inside every copy of the app, could list every worker's name, photo and ~300 m square, and in a village 300 m is a handful of houses. The point was the phone's GPS at its last sign-in, so it followed people around, home included. And "Show my exact spot" put a live position on the open internet for anyone who said yes without thinking it through.
A place you choose, not where your phone is
The biggest change isn't about precision at all: Duleko no longer uses your phone's position to decide where you are. Each person picks a work area once. The map moves under a fixed pin, and Save keeps the point under it. "Go to where I am now" only moves the map, and nothing is stored until Save. The area only changes when its owner changes it, so it describes where you work, not where you happen to be tonight.
Older app builds still send the phone's GPS at every sign-in, and people update apps slowly, so the database deals with that instead of trusting the client. A trigger on the profile row takes whatever coordinates an old build writes, turns them into a work area only if the person doesn't have one yet, and blanks the columns. An old phone can give a new member their first area, but it can never move an area someone already chose.
Your own blue dot is the one live position left, and it never leaves the phone: it's used to draw the dot on your screen and nothing else.
Squares, not points
Other members see a person at the middle of their ~1 km square. A small SQL function, area_coord(), floors each coordinate to a 0.01-degree cell and returns its centre, which in Nepal is about 1.1 km north to south and 1 km east to west. It's deterministic on purpose. Adding random noise to a point looks safer, but ask for it enough times and the noise averages out to the real spot. A fixed cell gives the same answer every time, so asking again tells you nothing new.
Distances follow the same rule. Search still sorts by distance, but only signed-in members get one, and only in whole kilometres, with anything under 1 km shown as 1 km. Precise distances measured from a few different spots would be enough to work the square back out.
The one place an exact pin is allowed is a shop or workshop. A business wants to be found, so its owner can tick "This is my shop or workshop", and signed-in members then see the pin at its real spot. That's a statement about a place, not a person, which is why it replaced the old exact-spot switch instead of sitting beside it.
Guests get counts, not people
A signed-out visitor sees no names and no pins. The function behind the people map, map_people(), checks for a signed-in member inside the function itself, and the guest role isn't allowed to call it at all. Guests get map_area_counts() instead: how many people are in each ~5 km square, built by snapping the already-coarse 1 km points again onto a 0.05-degree grid. The map still answers "is there anyone near me?", which is what a guest is really asking. Signing in answers "who?".
Each square needs a name, and the first attempt went wrong in an instructive way. The database picked the most common district and, separately, the most common municipality among the people in the square. Where two places share a square, those two answers can come from different people: the square at Butwal came out as Butwal Sub-Metropolitan City in Kapilvastu, and its "See the list" link opened Kapilvastu's list. The fix was to pick the most common district and municipality pair, as people actually entered it, with ties going to a pair that has a municipality and then alphabetically, so a square always gets the same name.
Then the app went a step further and now asks OpenStreetMap's Nominatim service where the square really is, so the name describes the map rather than what people typed on their profiles. It asks only when someone opens an area, one request at a time and at least 1.1 seconds apart, as the free service's usage policy asks, and remembers each answer on the device. The answer is matched against Duleko's own district and municipality lists with a forgiving comparison, so "Kapilbastu" and "Kapilvastu Municipality" count as the same place and the name can show in proper Nepali. If the lookup fails, the database's name is used instead.
Exact only when there's a job
There's one moment when an exact place is genuinely needed: when someone is coming to do the work. A function called engagement_places() answers that and nothing else. It only responds to the two people in a work request, and only while the job is live. They get the job's exact pin while the request is waiting, accepted or confirmed, and the other person's saved place from acceptance until the job is done. The app turns that into an "Open in Maps" button.
Job posts follow the same pattern. When someone posts a job, a trigger moves the exact place and pin into a job_post_places table that only the poster can read, and leaves the public post with its municipality, district and a ~1 km pin. The trigger writes that row a moment before the post itself exists, so its foreign key is deferrable and only checked when the transaction commits. When the poster chooses a worker's offer, the work request is built with the place read from that private table, so the chosen worker gets the address and nobody else ever sees it.
Why every rule lives in the database
Each of these rules is a Postgres function, trigger or policy, not a check in the React app. The public API key is in every copy of the app, so anyone can call the API directly. Old Android builds stay installed for months. And the app itself changes every week. A rule that only lived in the UI would be bypassed by any one of those.
Keeping the rules in the database also made the switch-over safe. The migration that installed the new trigger also moved every existing post's place into the private table and widened its public pin, in the same file, so there was never a moment when old posts followed the old rule.
The question I keep coming back to is "what could someone work out from this if they asked enough times?" A 300 m square passes the obvious privacy test and fails that one. Most of the fixes were about making the data coarser before it ever leaves Postgres, and making it the same every time it's asked for. A map that tells you there are four people near Butwal who can do the job is almost as useful as one that shows their front doors, and far easier to trust.
