The Status Contract Lives in the Query, Not in a Parameter
Public organization detail now goes through GetActiveByID with the status clause inside the query: a missing row and a non-AKTIF row both answer 404.
TL;DR
Organization 17 is inactive. Public requests return 404. Admin requests return the full record.
Row organizations with id 17 has status NONAKTIF. The request GET /organizations/17 from the public information page answers 404. The same id opened again through GET /admin/organizations/17 with an account holding the organisasi:read permission answers 200 with the full record and its member list.
The first guess was that the public endpoint had lost its status filter. For lists, this module does use a single List method with an AllStatuses flag chosen by admin or public. For detail, this commit takes the other road: a dedicated method, with the status clause attached directly to the query string [4].
The Contract Sticks to the Query
// GetActiveByID returns only a status='AKTIF' organization (public detail).
// Missing and non-AKTIF rows both surface as ErrNotFound.
func (r *mysqlRepository) GetActiveByID(ctx context.Context, id int64) (*Organization, error) {
var org Organization
if err := r.db.GetContext(ctx, &org,
"SELECT * FROM organizations WHERE id = ? AND status = 'AKTIF'", id); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return nil, fmt.Errorf("get active organization: %w", err)
}
return &org, nil
}
// route registration (admin)
r.With(guard("organisasi:read")).Get("/organizations/{id}", h.get)
The difference is who remembers the filter. With a status parameter, every new caller has to pass the right value; with a new method, the status = 'AKTIF' clause is written once and cannot be dropped by any caller. MySQL defines the WHERE clause as the condition rows must satisfy to be selected [4]. Without it a SELECT returns every row; here the clause is part of the query, not an argument.
Missing rows and non-AKTIF rows stop at the same error. database/sql answers ErrNoRows when QueryRow returns no row at all [2], the repository layer translates it into ErrNotFound, and the handler answers 404. One error path for two different conditions.
On the admin side, the old GetByID method is kept without a status filter, so an editor can open an organization in any state. The guard sits in route registration, not in the handler: r.With(guard("organisasi:read")).Get("/organizations/{id}", h.get). chi provides route groups and middleware to mount a guard per group like that [3].
At the service layer, ErrNotFound passes through as-is instead of being wrapped into a new error. The admin handler catches it in one place: an id that is not a positive number answers 400, a missing row answers 404, a failed connection answers 500. That three-way mapping repeats in create, update, delete, and now in read detail, so the new endpoint behaves exactly like its neighbors.
One 404 for Two Reasons
RFC 9110 states that 404 means the origin server did not find a current representation for the target resource or is not willing to disclose that one exists [1]. The same specification allows a server to hide the existence of a forbidden resource by answering 404 [1]. Two different conditions, a row that never existed and a row that is no longer active, may therefore share one status code without being considered wrong.
The consequence shows up in testing. Three cases must be exercised separately: an id absent from the table, an id present with a status other than AKTIF through the public endpoint, and the same id through the admin endpoint. The first two must answer 404, the third must answer 200. If only the first case is tested, switching the public call back to GetByID passes undetected, a one-line change that leaks status back to the public.
The trade-off not taken: giving GetByID a boolean parameter as the List method does, and letting each caller decide. That saves one method but moves the guarantee into caller memory — one public handler that forgets the argument is enough to open a non-AKTIF row. For lists the flag still works because one request serves two audiences; for detail there are two contracts, so there are two queries.
The decision left by this commit: public status for detail is not a parameter, it is a method.
Sources:
- rfc-editor.org/rfc/rfc9110.html (retrieved via archived snapshot, 2026-10-07)
- pkg.go.dev/database/sql
- go-chi.io
- dev.mysql.com/doc/refman/8.4/en/select.html (archived snapshot 2026-10-07)