Add web.FieldErrorsFromDB — a constraint-name-to-field translator that converts PostgreSQL constraint violations into 422 FieldErrors (per the §6 error contract) instead of leaking raw driver text to users. - New web/dberrors.go: FieldErrorsFromDB + ConstraintMessages map - New web/dberrors_test.go: constraint hits, per-class fallbacks (23505/23503/23514/22001), nil map, non-Postgres, 23P01 exclusion requires named entry - Fix 9 leak sites: workspace creation, enrollment (IssueGrant/ExtendGrant/ CreateNonPlanGrant/RevokeGrantAndTransition), org type default-plan update, entitlement set CRUD, product create/update - New lint rule raw-error-render: flags err.Error() on any line touching a UI sink (render*, fireErrorToast, fireSuccessToast, http.Error), exempts slog lines - Docs: add §4 db-write-failure contract and §9 anti-pattern row Closes: openspec/changes/db-error-translation
57 lines
1.9 KiB
Go
57 lines
1.9 KiB
Go
package web
|
|
|
|
import (
|
|
"errors"
|
|
|
|
"github.com/jackc/pgx/v5/pgconn"
|
|
)
|
|
|
|
// ConstraintMessages maps Postgres constraint names to the form field and
|
|
// message the operator should see when that constraint rejects a write.
|
|
// Handlers pass the constraints their form can plausibly hit; the names live
|
|
// next to the form they belong to (per docs/operator-ux-conventions.md §6)
|
|
// rather than in one global registry.
|
|
type ConstraintMessages map[string]FieldMessage
|
|
|
|
// FieldMessage is one field-level error: which form field to flag and what to say.
|
|
type FieldMessage struct {
|
|
Field string
|
|
Message string
|
|
}
|
|
|
|
// FieldErrorsFromDB translates a failed database write into field-level form
|
|
// errors, so constraint violations render as 422 + FieldErrors (the §6
|
|
// validation contract) instead of leaking raw driver text ("SQLSTATE 23505")
|
|
// into the UI. It returns ok=false when err is not a recognizable constraint
|
|
// violation — the caller should then treat it as a server error (log + generic
|
|
// message), never render err.Error().
|
|
//
|
|
// Resolution order: an exact constraint-name match in msgs wins; otherwise a
|
|
// generic per-SQLSTATE-class message lands on the form-level "" field.
|
|
func FieldErrorsFromDB(err error, msgs ConstraintMessages) (FieldErrors, bool) {
|
|
var pgErr *pgconn.PgError
|
|
if !errors.As(err, &pgErr) {
|
|
return nil, false
|
|
}
|
|
|
|
fe := New()
|
|
if fm, hit := msgs[pgErr.ConstraintName]; hit {
|
|
fe.Set(fm.Field, fm.Message)
|
|
return fe, true
|
|
}
|
|
|
|
switch pgErr.Code {
|
|
case "23505": // unique_violation
|
|
fe.Set("", "A record with these values already exists.")
|
|
case "23503": // foreign_key_violation
|
|
fe.Set("", "A referenced record no longer exists or is still in use.")
|
|
case "23514": // check_violation
|
|
fe.Set("", "This combination of values is not allowed.")
|
|
case "22001": // string_data_right_truncation
|
|
fe.Set("", "One of the values is too long.")
|
|
default:
|
|
return nil, false
|
|
}
|
|
return fe, true
|
|
}
|