Root Cause Analysis · RealSkill Mobile

Why trainers could not add an existing player

Two separate defects sat on top of each other in the same flow. One stopped the player from ever being mapped; the other hid the error on iOS so the screen looked frozen. Neither was a backend data problem. A sweep for the same two patterns then found four more instances of the second, and a third defect that was quietly creating duplicate accounts.

Reported by Bryce Stanhope (trainer, user 34) Surface Add Player — Skill Lab / All Players Status Fixed and merged; not yet deployed
Defect 1 Response contract mismatch
Defect 2 Modal over modal (iOS)
Defect 3 Case-sensitive lookup
Data at fault? No

What was reported

Bryce reported two things. First, after adding a new player the app froze and had to be force-closed. Second, when he tried to add jeremiah.hawkins, the app recognised the account and showed a green check mark, but nothing happened after that.

The server log for that attempt looked like this:

/membership/get-player-by-username 404 66.159 ms - 46

A 404 on a lookup for a player who plainly existed. That was the thread we pulled.

What we ruled out first

Before accusing any code, we closed off every environmental explanation against the live production database and the running production build.

HypothesisVerdictEvidence
We queried a stale or wrong database Ruled out Newest user row was same-day; 91,316 users
The endpoint was never deployed Ruled out Unauthenticated probe returned 401, not 404
Production runs older code Ruled out Deployed dist matched repo source exactly
Player record missing or inactive Ruled out User 50731, is_active=1, is_deleted=0
Trainer lacked a licence Ruled out Plans 280384 and 317498 both active
Trainer out of seats Ruled out 36 players against 10,000 seats

Every environmental explanation failed. The database was healthy, the licence was valid, the endpoint was live, and the deployed code was current. That left the client.


Defect 1The app read a success as a failure

What the flow is supposed to do

Adding an existing player by username is a two-call sequence. The first call finds the person; the second call actually maps them to the trainer.

1
POST /membership/get-player-by-username
Finds the player. Returns their canonical email.
2
App reads the response
This is where it broke — a success was scored as a failure.
3
POST /membership/add-existing-player
Writes trainer_player_map and grants the all-access plan. Never ran.

The root cause

The two endpoints signal success in different shapes. One sends a numeric status; the other sends a boolean success and no status field at all.

// add-existing-player — success
{ msg: "Player mapped successfully", status: 200, ... }

// get-player-by-username — success
{ success: true, player_id: 50731, email: "...", ... }
//   ^ no `status` key anywhere in this response

The client scored success from status alone:

// workoutRepository.ts — parseExistingPlayerResponse
success: isSuccessStatus(status)   // status === undefined → false

So a perfectly good 200 OK carrying the player's record was classified as a failure. The guard on step 3 never opened, the mapping call was never sent, and the code fell through to its catch-all message.

The important consequence

The trainer saw “Unable to add player.” That message was never the backend's. It was the client's own fallback string, produced without a single failed request. The mapping API was not rejecting the write — it was never being called.

Why the green check mark still appeared

The check mark comes from a different endpoint, search-player, which matches loosely with ILIKE and returns its own shape. That call succeeded and rendered the tick. The strict lookup that followed was the one being misread — so the UI confirmed the person existed and then refused to move.

Evidence

We wrote a regression test using the exact response shapes production returns, then removed the fix and ran it again:

— without the fix —
✕ username path: looks up, then maps
    Unable to map existing player

— with the fix —
✓ username path: looks up, then maps using the email returned by lookup
✓ username path: does not swallow a genuine lookup failure

The failing run reproduces the trainer's exact error message. That is the proof, not an inference.

The fix

A helper that reads the boolean flag, parseSuccessFlag(), already existed in the same file — it simply was not being called here. The parser now prefers it and falls back to the numeric status.

const explicitSuccess = parseSuccessFlag(root);
success: explicitSuccess ?? isSuccessStatus(status),

Why our tests never caught it

Two gaps, and the second is the one worth remembering.

A contract test that invents its own contract is worse than no test, because it reports safety. The new test is pinned to the real response shapes.


Defect 2iOS could not draw the error at all

The root cause

The Add Player sheet is itself a <Modal>. Its result dialogs were routed through QuickAlert, which renders in GlobalDialogHost — mounted at the application root, outside that modal.

On Android a modal is just a view in the window hierarchy, so a second one draws on top without complaint. On iOS a modal is a native view-controller presentation, and a controller mounted outside the presented modal cannot present above it. The dialog was created, marked visible, and never appeared.

Why “it works on Android” confirmed the diagnosis

Both platforms hit Defect 1 and both produced the same error. Only the delivery differed: Android showed the alert, so the trainer read “Unable to add player.” iOS swallowed it, so the trainer saw a sheet that had stopped responding. One defect, two very different bug reports.

The Skill Lab path was unaffected because it passes mode='skilllab' and uses a dialog nested inside the sheet's own modal. Neither of the two screens that call this widget passes that prop, so both took the broken branch.

The fix


Verification

CheckResult
TypeScript compile0 errors
App test suites107 / 107 pass
Backend compile and build0 errors, clean dist
New regression test, real shapesFails without fix, passes with it
iOS build and install on deviceSucceeded
Runtime confirmation on deviceNot yet done
Backend deployed to productionMerged, not deployed

The device build installed and launched, but the app cannot reach the Metro bundler over USB, so no JavaScript-level runtime logs have been captured yet. Defect 1 is proven by test. Defect 2 is proven by code path and by the Android/iOS behavioural split, but has not yet been watched working on hardware. It is being called fixed-pending-verification, not verified.

Defect 3 is fixed and merged but not yet live. Production is still serving a build from before these changes, so the reporting trainer will see no difference until the backend is rebuilt and restarted.


Defect 3Lower-case email, second account

The root cause

Three endpoints in this one flow disagreed about how to match a person:

EndpointMatchTrims input
search-playerILIKE, partialYes
get-player-by-usernameExact, case-sensitiveNo
add-existing-playerExact, case-sensitiveNo

A trainer who types an address in lower case when the stored value carries capitals — JeremiahJahree.2017@gmail.com is a real address on this account — passed the loose search, earned the green tick, then failed the strict lookup.

This one was not just a wrong message

In add-existing-player the branch immediately below the failed lookup creates a user. A case mismatch did not merely report “player not found” — it silently registered a second account, and a second Shopify customer, for a person who already existed. This flow has been quietly manufacturing duplicates.

The fix, and what we deliberately did not do

Both lookups now try the exact match first and fall back to a case-insensitive one. The tempting one-line version — dropping mode: 'insensitive' onto a findFirst — was rejected. With 91,316 users and known duplicate accounts (the reporting trainer has three), two records differing only by case would let the database return either one, mapping the wrong player to the trainer. That is a worse failure than the bug being fixed.

The fallback therefore accepts a single match and refuses to choose between two, returning an explicit message instead. Both lookups also trim their input, which neither did before.


The same defect, four more times

Defect 2 is a pattern, not a one-off, so we swept for it: any component that is itself a modal and also raises a root-level dialog. Four more live instances turned up.

ScreenWhat broke on iOS
Schedule Workout sheetA failed schedule showed no error whatsoever
Skill Lab · PlayersThe failure path never dismissed the sheet before alerting — unlike the success path written directly beside it
Skill Lab · PlayersThe “select a trainer first” warning never appeared
Skill Lab · Trainer listRaised its dialog before closing its own menu, racing the dismissal

Rather than patch each call site and wait for the next one, the dispatcher itself changed: showAppDialog now delivers to the most recently mounted host instead of every host. A host rendered inside an open modal mounts after the root host and therefore wins. With no nested host mounted — every other screen in the app — the root host is still the only listener and nothing changes.

One further hit, MyPlayers.tsx, carries the same defect but is imported nowhere. It was fixed for consistency; no user could reach it.


Still open

Production is running a build older than its checkout

An unrelated finding worth acting on. On the production backend the checked-out commit was created the same day, while the compiled dist was three days old and the process had not restarted since. Someone pulled without rebuilding.

Nothing is broken today, but the next routine restart would silently promote three days of unreviewed code. Worth fixing as a deploy-process issue rather than waiting for it to become an incident.

What we would change