Case study · Mobile & backend · 2025–2026

A community help app where every request reaches the right neighbors

I led the rebuild of a mutual-aid app for an Israeli community-help operator. People post a request (a ride to hospital, a lost dog, a wheelchair for a week) and nearby neighbors who opted in get alerted, then reply by chat, WhatsApp, SMS or a call. The new version replaced a legacy app in the same store listings, kept its users and data, and decides in one database query who should hear about each request.

.NET 8 Web APIEF Core 8SQL ServerReact Native · ExpoTypeScriptRedux ToolkitPush notificationsGoogle Maps / Places
Client
Israeli community mutual-aid app (confidential)
My role
Team lead & architect (hands-on)
Timeline
Jul 2025 – Oct 2026 · live on iOS and Android
Status
In production
At a glance
1SQL query picks each request's audience across category, keyword, distance and city filters
0push calls left inside the “create request” API call. Fan-out runs on a background queue.
N to 1token refreshes when parallel calls hit an expired session
6 to 31legacy tables migrated, dry-run first, into a 31-table schema
4 × 341languages × UI strings, including Hebrew and Arabic right-to-left
55mobile API endpoints across 6 controllers
30app screens, one codebase for iOS and Android
15 monthsfrom the first backend commit to the latest store release, 2,000+ commits
The problem

A help request is only useful if the right people see it, fast

The operator already had an app with real users and years of requests in a MySQL database. It was hard to extend: it had no groups, no in-app chat and no per-user alert settings, so alerts could only be narrowed in coarse ways. The brief was to rebuild the backend and the app without losing a single user, and to let every person choose exactly which requests should wake their phone: which categories, which keywords, how far away, which cities and which hours.

What I owned

Architecture to store release

  • Designed the solution: a layered .NET 8 API (controllers, services, storage), the SQL Server schema and the Expo app structure.
  • Wrote most of the code myself, on both the backend and the mobile app, and led the team through reviews and conventions (commitlint, ESLint, Prettier, Husky).
  • Built the notification-targeting pipeline: settings model, audience query, quiet hours, in-app inbox and push.
  • Planned and scripted the legacy migration: MySQL to staging to the new schema, with a dry-run mode.
  • Owned the release path: environment branches (dev, QA, staging, production), EAS builds, and the switch to the legacy app's existing store identities.
Architecture

A thin app, a layered API, and targeting that runs in the database

The diagram is wider than the screen: swipe sideways to see all of it →

The app also talks to the chat service directly through its SDK, using a user token the API issues. Images go to object storage and only their URLs are kept in SQL. Login is by phone number and one-time SMS code. Blocked users are turned away by a server status code that the app maps to an “access denied” screen.

Design decisions

Do the filtering where the data is

Audience targeting as one database query

Category, keyword, distance and city rules compile into one LINQ query. Distance uses a SQL Haversine function mapped into EF Core, and the effective radius is the smaller of the reader's range and the request's own radius. No user list is loaded into memory just to throw most of it away. Trade-off: a scalar distance function can't use an index, which is fine at this scale. A spatial index is the next step if the user base grows a lot.

Inbox for everyone, push only when it's welcome

Every matched user gets an inbox row, even without a valid device token, so nothing is lost after reinstalling or turning notifications back on. Push goes only to users outside their quiet hours, and quiet hours can cross midnight (22:00–06:00). Trade-off: more rows in the notifications table, so the inbox is paged (10 per page).

Fan-out off the request path

Creating a request saves it and puts it on a queue. A hosted background worker then picks the audience, writes the inbox and sends one batched push request. The person posting doesn't wait for hundreds of phones. Trade-off: the queue is in-process, so items waiting during a restart can be lost. A separate worker host is already scaffolded for moving it out.

Rebuild and migrate, not patch

Instead of extending the old MySQL app, the data went through a staging database with bulk copy. Then a T-SQL script with a @DRY_RUN switch, guard checks and duplicate handling moved it into the new schema. The new app shipped under the old store identities, so existing installs simply updated. Trade-off: a second schema and a one-off tool to maintain until cut-over.

Engineering log

What broke, and how I fixed it

Taken from the commit history of both repositories. A selection:

Jul 2025

Every read paid for change tracking

Most endpoints only read data, but EF Core was tracking every entity it loaded.

Made no-tracking the default for the whole context, and opted in to tracking only in the update paths. Also removed includes that weren't needed from the posts query.

Oct–Nov 2025

Old data didn't fit the new model

The legacy database had requests with free-form times, reports with duplicates, a separate location table, and users identified only by phone.

Built a two-step migration: bulk copy from MySQL into a staging schema, then a T-SQL mapper with overflow-safe times, report de-duplication, guard THROWs on missing reference data, and a dry-run mode that reports counts and rolls back.

Oct 2025

Posting waited for notifications

The first version sent notifications inside the create-post call, so posting got slower as the audience grew.

Turned off sending inside the call and added a queue with a hosted worker. Device tokens are refreshed on login, and only users with valid tokens get a push.

Oct 2025

Group posts visible outside the group

Posts in private groups could show up in the feeds of people who weren't members.

Filtered feeds and notifications by group membership, added an explicit “public post in a group” option, and closed other access gaps found in the posts API during a security pass in Feb 2026.

Dec 2025

Alerts at the wrong distance and the wrong time

The location and quiet-hours filters disagreed with what users had set, and a request's own radius was ignored.

The effective radius is now min(reader range, request radius), and the quiet-hours check handles ranges that cross midnight.

Feb 2026

Alerts used where you were, not where you are

Targeting relied on the location saved in the profile, which goes stale for people who move around.

The app sends the current location on open and every 5 minutes, and skips refreshing the home feed when the location hasn't changed. Users can also follow several cities.

Mar–Apr 2026

Right-to-left that libraries ignored

With Hebrew and Arabic as first-class languages, some third-party components ignored the layout direction (the slider, the places autocomplete, the chat header on iOS).

Forced the direction through the i18n layer, patched the places input with patch-package, upgraded the slider library, and went through RTL alignment screen by screen.

Apr–May 2026

Session edge cases

Several requests failing with an expired token at once each tried to refresh it, and blocked users could get stuck in a loading state.

The Axios interceptor now shares one in-flight refresh between all callers. Blocked users get a dedicated status code that takes them to an “access denied” screen.

Jun–Jul 2026

Replacing the old app, not adding a new one

Shipping as a new store listing would have meant asking every existing user to install it again.

Moved the iOS and Android configuration to the legacy app's existing package identities and aligned version codes, so stores accept it as an update.

Try it

Who gets alerted?

A small replay of the targeting rules with six made-up neighbors. No client code or data. Change the request and see who gets an inbox entry, who gets a push, and why the others don't.

Category
Request radius
Posted at
Mentions “wheelchair”
Outcome

Where it landed

Area
Before
After
Platform
Legacy app on MySQL, hard to extend
.NET 8 + SQL Server (31 tables), Expo app on iOS and Android, existing users and data migrated
Alert targeting
Coarse, one-size-fits-all alerts
5 personal controls (category, keyword, distance, cities, quiet hours), evaluated in 1 SQL query
Posting a request
Push fan-out inside the API call
Saved and queued. Audience, inbox and 1 batched push request run in the background.
Missed alerts
No token or notifications off meant no record
Every match lands in a paged in-app inbox. Push respects quiet hours.
Talking to the requester
Phone number only
In-app chat per request, plus WhatsApp, SMS or a call, including anonymous and “on behalf of” posts
Languages
Hebrew-first UI
Hebrew, Arabic, English and Russian, 341 strings each, with RTL and LTR layouts
Login
Type the SMS code by hand
The OTP fills itself (Android SMS consent, iOS autofill). 15-minute access tokens with shared refresh.
Upgrade path
A new app would mean a new install
Shipped under the existing store identities, so existing installs update in place
Stack

Technology stack

Backend
.NET 8 Web API, C#, AutoMapper, JWT bearer, hosted background services, NLog
Data
SQL Server, EF Core 8 with NetTopologySuite, T-SQL functions and procedures
Mobile
React Native 0.79, Expo 53, Expo Router, TypeScript, Redux Toolkit, React Hook Form, Reanimated
Integrations
Push (Expo, FCM, APNs), SMS gateway, hosted chat SDK, Google Maps and Places, object storage
i18n
i18next with 4 languages, RTL and LTR
Delivery
EAS builds, branches for dev, QA, staging and production, commitlint, ESLint, Prettier, Husky

Free scoping call · 30 minutes · No commitment

Bring one process. Leave with a plan and a number to beat.

Write a few lines: the process or system, what hurts today (time, errors, cost or an AI pilot that stalled) and who uses it. After a short call you get a written plan and a fixed quote within ≈3 days.

Get a free scoping callafif.mansour@gmail.com

Opens a pre-filled email · reply within one business day

The model reads. The code counts.

All projects