The One Forgotten Line: How a DTO Mapper Crashed the Frontend
A field existed on the entity but never made it into the response mapper: the API answered with an empty string and the edit page crashed. One line plus a regression test closed it.
TL;DR
Saving announcements crashed because the backend mapper forgot to copy Status, sending an empty string that broke the frontend badge lookup. The fix added the missing field, added a regression test, and made the UI whitelist statuses to default to draft. Navigation was also switched to router push and refresh to keep toast feedback without a full reload.
I clicked "Save" on the announcement admin page. The screen flashed white for a split second, and then the console immediately spat out a familiar red error: Cannot read properties of undefined (reading 'badge'). This happened on /admincms/informasi/{id} every single time I tried to save a new announcement. It was completely reproducible, which usually means the bug is staring me right in the face.
My first guess was that the database migration failed, or maybe the frontend form state was getting wiped during the submit handler. I opened the network tab to check the payload. The POST request returned a clean 200 OK. The JSON response looked perfectly valid at a quick glance. There were no 500 errors, no missing fields in the schema, and the database clearly accepted the write.
Then I looked closer at the JSON response. The status field was not "draft" or "published". It was an empty string: status: "".
In frontend/src/components/admin/informasi-editor.tsx, the code indexes STATUS_META[form.status] directly to render the correct UI badge. When form.status is an empty string, the lookup misses entirely because there is no key for "" in the metadata object. Accessing a property that does not exist returns undefined [2], so trying to read the .badge property on that undefined result instantly crashes the React component tree.
I traced the empty string back to the backend Go code. In api/internal/announcementadmin/dto.go, the toResponse(Announcement) AnnouncementResponse function was missing exactly one line: Status: a.Status. Every API response was silently carrying an empty status value instead of the actual entity state.
The frustrating part? The entityadmin and reviewadmin modules already mapped the Status field correctly. Only announcementadmin was missed during a recent refactor. This is the core danger of hand-rolled mappers. They drift silently over time. The code compiles fine, returns valid JSON, and the failure only explodes on the client side where debugging is harder.
Fixing the backend and testing the mapper
The backend fix was trivial: I added the missing Status: a.Status line. But a one-line fix is not enough to prevent future regressions. To make sure this specific failure mode never happens again, I added a dedicated regression test named TestToResponseCopiesStatus. It explicitly checks the draft, published, and scheduled states to guarantee the mapper copies the field every single time the function is called.
To make sure this specific failure mode never happens again, I added a dedicated regression test named TestToResponseCopiesStatus. It explicitly checks the draft, published, and scheduled states to guarantee the mapper copies the field every single time the function is called.
I also realized we cannot trust external enum values blindly, even from our own backend. The old frontend code used (d.status as FormState["status"]) ?? "draft". The nullish coalescing operator only catches null or undefined, never an empty string. An empty string is a defined value, so the coalescing operator ignores it and passes the bad data straight to the renderer.
I replaced it with a toFormStatus(v) whitelist helper in both informasi-editor.tsx and wisata-editor.tsx. Now, only "published" or "scheduled" pass through. Everything else, including empty strings or unexpected new values, safely falls back to "draft". Normalizing by whitelist is always safer than relying on nullish coalescing for enums, because it actively rejects invalid states rather than just hoping they do not exist.
Fixing the navigation reload
While I was in the file, I fixed another annoyance. After creating a new announcement, the old code used window.location.href to redirect the user. Setting href navigates to the provided URL [3], but it triggers a full page reload. That full reload instantly kills the success toast notification I just spent time setting up, leaving the user with no visual feedback that their action succeeded.
I swapped it to router.push combined with router.refresh. The router.push performs a client-side navigation and adds a history entry. Meanwhile, router.refresh re-fetches server data and merges the RSC payload without losing client state like useState or the current scroll position [1]. The toast now stays visible exactly as intended, and the page feels instantly responsive.
I used to think writing custom DTO mappers was just harmless boilerplate. Now I see it as a liability. If you map fields by hand, you must regression-test the mapping. And on the frontend, never assume the backend will send you a valid enum. Whitelist what you expect, and treat everything else as the default.
Sources
[1] https://nextjs.org/docs/app/api-reference/functions/use-router
[2] https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/undefined
[3] https://developer.mozilla.org/en-US/docs/Web/API/Location/href