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 }