Scope filters that must not swallow global rows
Adding a bidang scope filter to a public endpoint sounds like one WHERE clause. Then the global rows disappear.
TL;DR
The new scope filter hid global announcements because WHERE bidang equals value never matches NULL. The fix validates bidang with a 400 on invalid values and changes the query to keep NULL and ALL rows. It also adds bidang to the cache key and moves pillar intros into the settings table with a fallback.
I deployed the first draft of the new announcements filter, opened the pillar pages, and noticed something was missing. The global announcements were completely gone.
My first guess was that the frontend was aggressively filtering them out, or maybe the cache was serving stale data. I cleared the cache, checked the network tab, and saw the API was returning an empty array for those specific scopes. The backend was swallowing them.
I pulled up the Go handler. I had recently added a naive WHERE bidang = ? to the query to support the new scope filter. The catch: rows with bidang set to NULL are global content by design, exactly as the migration comments stated. In MySQL, NULL means "a missing unknown value" and any comparison touching NULL is not true [1]. So WHERE bidang = 'pariwisata' can never match a NULL row. Not a bug in my SQL driver, just how NULL works.
A 400 gate at the handler
Before touching the query, the handler validates the bidang query parameter with a switch. An empty string means no filter. Anything outside the four allowed scopes triggers a 400 with the code INVALID_BIDANG and the allowed list in the message:
bidang := q.Get("bidang")
switch bidang {
case "", scope.Pariwisata, scope.Olahraga, scope.Budaya, scope.Kepemudaan:
default:
response.Error(w, http.StatusBadRequest, "INVALID_BIDANG",
"unknown bidang; allowed: pariwisata, olahraga, budaya, kepemudaan")
return
}
Why not silently ignore weird values? Because RFC 9110 defines 400 as the response for a request the server cannot or will not process due to a client error [2], and clients that receive a 400 should expect the same request to keep failing until they change it [3]. That is far more useful to a frontend than an empty list to guess about, or a 500 surfacing later.
The predicate that keeps global rows alive
The heart of the change sits in the repository. The naive AND bidang = ? was exactly what swallowed the global rows. The final version:
if q.Bidang != "" {
where += " AND (bidang = ? OR bidang IS NULL OR bidang = 'ALL')"
args = append(args, q.Bidang)
}
Three branches inside those parentheses form the contract: rows owned by the requested scope, global rows (NULL), and rows carrying the ALL sentinel all stay visible in every scope. On the Go side the column scans into sql.NullString, whose Valid flag distinguishes a real NULL from an empty string [4]. One comment on the model field is enough to document it: NULL = global content.
Cache keys and content moving to the CMS
Two more things shipped in the same commit. First, the upcoming-list cache key gained a :bidang: segment. Without it, a request for the olahraga scope could read the pariwisata cache because the keys were identical. The rule is simple: every query dimension that changes the response body belongs in the cache key, and this is the second fan-out after the earlier ?module= parameter.
Second, the three pillar intro texts moved from hardcoded frontend constants into the settings table via migration 000062, group bidang, keys bidang_intro_* and bidang_program_*. The migration seeds the previous copy verbatim, program lists start empty until their catalog is ready, and the section stays hidden while empty. The frontend falls back to its static copy when a setting is missing, so a fresh environment never renders a blank hero. One detail looks odd but is deliberate: the kebudayaan page maps to the database scope budaya while keeping the kebudayaan settings prefix. Two namespaces with two histories, and keeping them is cheaper than renaming half the stack for aesthetics.
The lesson I take from this commit: filtering by scope is less about deciding what gets hidden and more about deciding what must always survive the filter. I keep leaning toward failing fast, like the 400 gate above, over filters that quietly return an empty list. The next scope is just one more switch case away.