COALESCE at the API Door: Keep the ALL Sentinel in the Query
A super-admin without a bidang returns NULL from the database. One COALESCE in the login query keeps the API contract total and the admin badge correct.
TL;DR
The badge broke for super-admins because their bidang is NULL and old tokens don't even have the field. Rather than patching each Go handler, the team used COALESCE(bidang, 'ALL') in SQL to guarantee a valid string everywhere. TypeScript keeps bidang optional for legacy sessions, and React only renders the badge when the value isn't ALL.
The first time I tested the admin dashboard update, I signed in as a super-admin and stared at the screen. The division badge that showed up yesterday on a division admin's session was nowhere on mine. My first instinct said display bug: just show bidang in the query, pass it to the frontend, done. That guess was very wrong.
The problem was not only the super-admin who legitimately has no bidang, so the database returns NULL. There was another layer that almost slipped past me: old session tokens. Users who logged in before this update still carry payloads that lack the field entirely. If the frontend is forced to render data that is not there, you get a glaring undefined in a government sidebar.
First guess: check for NULL in the handlers
I considered handling this in the Go handlers. Check in the login handler, and if user.Bidang is empty, swap in the string ALL. Repeat the same in the profile handler. But that approach is fragile and breaks DRY. Every new endpoint that returns user context needs the same check, and one developer forgetting it brings the bug right back. That is a leaking abstraction.
Move it to the earliest boundary: the SQL query
The saner decision: move the logic to the earliest boundary, the database query itself. The SELECT in the login handler became COALESCE(bidang, 'ALL') AS bidang [1]. I applied the same change to the profile endpoint, so LoginResponse and MeResponse stay honest with each other.
COALESCE returns the first non-NULL value in its argument list, or NULL when there is none, and its result type is the aggregated type of the arguments [1]. With 'ALL' as the fallback, every backend response is guaranteed to carry a valid string. No more NULL leaking into the application layer. The data contract becomes total: every API consumer, including an old session refreshed later, receives the same shape.
There is an easy-to-miss bonus here. The database is the single source of truth for user status. Enforcing the contract at the widest funnel means the Go code above it only scans a plain string. No null conditionals pile up in the business layer just to cover an edge case that should have been settled earlier.
The extra seatbelt in TypeScript
The backend now guarantees a string, yet I still declared bidang as optional on the AdminSession type: bidang?: string [2]. It looks contradictory, but it is a seatbelt for deployment reality. Tokens stored in browsers before the release date obviously lack the property. There is a gap between the backend deploy and each client session refreshing. If the type were required, the frontend could throw a type error reading a property that old tokens simply do not have. That question mark exists exactly for objects that might not have the property set [2].
A badge that knows when to appear
On the React side, rendering stays clean thanks to the stable contract. The blue badge in the header and sidebar only appears when bidang && bidang !== "ALL" holds [3]. Super-admins never see anything odd; a Pariwisata admin always sees their division.
Worth noting: 'ALL' here is purely a contract value, a sentinel, not display data. For display there is formatBidang(): a label map that falls back to the raw value when an entry is missing, ALL becomes "Semua Bidang", PARIWISATA becomes "Pariwisata", and so on. The badge never renders the raw sentinel.
This pattern also means adding new bidang values later touches no UI at all. The rejection condition only targets 'ALL', so once a new division lands in the label map its badge appears without touching any component. The backend never worries about who handles empty values; the frontend never guesses whether the property exists. Every party works from the same assumption: data arrives in its safest shape.
If your situation looks similar, a nullable column plus live old sessions, settle it at the boundary, not the outermost layer. Patching at the far end only creates technical debt that will haunt you later. Solve it at the source and the inside of the application stays clean.