package recruiting import ( "errors" "fmt" "strings" "github.com/jackc/pgx/v5/pgconn" ) var ( ErrNoDatabase = errors.New("recruiting: no database configured") ErrNotFound = errors.New("recruiting: not found") // ErrConflict is an optimistic concurrency failure: the row moved after the caller read it. ErrConflict = errors.New("recruiting: the record was changed by someone else") ErrInvalid = errors.New("recruiting: invalid input") ) // DuplicateError names the field that collided so the caller can point at the right input rather than // showing a generic failure. type DuplicateError struct{ Field string } func (e *DuplicateError) Error() string { return "recruiting: duplicate " + e.Field } // ValidationError carries a per field message. Returning the field lets the UI mark the offending input // instead of dropping one sentence at the top of a form. type ValidationError struct { Field string Message string } func (e *ValidationError) Error() string { return e.Field + ": " + e.Message } func (e *ValidationError) Unwrap() error { return ErrInvalid } // translatePG converts a Postgres integrity failure into a typed domain error. // // The database is the only place uniqueness can be checked without a race, so the service inserts and // interprets the result rather than pre checking with a SELECT and hoping nothing changes in between. // Doing it the other way round produces a 500 under concurrency, which is exactly the case that matters. // // The constraint NAMES are matched deliberately. They are declared in migrations 0047 and 0048 and // changing one without changing this mapping would silently turn a clean 409 back into a 500, so the // names are load bearing and the tests pin them. func translatePG(err error) error { var pg *pgconn.PgError if !errors.As(err, &pg) { return err } switch pg.Code { case "23505": // unique_violation switch { case strings.Contains(pg.ConstraintName, "uq_clients_org_name"): return &DuplicateError{Field: "name"} case strings.Contains(pg.ConstraintName, "uq_clients_org_uei"): return &DuplicateError{Field: "uei"} // The same candidate cannot be on one job order twice. Postgres names this constraint after its // columns, and without this case the raw name reached the user as "that // applications_organization_id_requisition_id_candidate_id_key is already in use". case strings.Contains(pg.ConstraintName, "applications_organization_id_requisition_id_candidate"): return &DuplicateError{Field: "candidate"} case strings.Contains(pg.ConstraintName, "uq_client_contacts_primary"): // Reached only if the demote and promote were not done in one transaction. The service does // them together, so this is a guard against a future caller taking a shortcut. return &DuplicateError{Field: "is_primary"} default: return &DuplicateError{Field: pg.ConstraintName} } case "23514": // check_violation switch { case strings.Contains(pg.ConstraintName, "clients_name_check"): return &ValidationError{Field: "name", Message: "must be between 1 and 200 characters"} // Rate ranges. Only one end of a range is often being changed, and the other end is whatever is // already stored, so the database is the only place the pair can be compared. Naming the field // turns an unactionable failure into one the form can mark. case strings.Contains(pg.ConstraintName, "requisitions_bill_range_chk"): return &ValidationError{Field: "bill_rate_min", Message: "cannot be greater than the maximum bill rate"} case strings.Contains(pg.ConstraintName, "requisitions_pay_range_chk"): return &ValidationError{Field: "pay_rate_min", Message: "cannot be greater than the maximum pay rate"} case strings.Contains(pg.ConstraintName, "requisitions_salary_range_chk"): return &ValidationError{Field: "salary_min", Message: "cannot be greater than the maximum salary"} case strings.Contains(pg.ConstraintName, "requisitions_rates_nonneg_chk"): return &ValidationError{Field: "bill_rate_min", Message: "rates cannot be negative"} case strings.Contains(pg.ConstraintName, "requisitions_target_after_open_chk"): return &ValidationError{Field: "target_fill_date", Message: "cannot be before the date the order was opened"} case strings.Contains(pg.ConstraintName, "requisitions_title_check"): return &ValidationError{Field: "title", Message: "must be between 1 and 300 characters"} case strings.Contains(pg.ConstraintName, "requisitions_openings_check"): return &ValidationError{Field: "openings", Message: "must be at least 1"} // The two clock invariants from 0047 and 0052. Reaching either means the transition logic moved a // status without moving the clock that status implies, which is a defect in this service rather // than bad input, so it is reported as an internal failure and not blamed on the caller. case strings.Contains(pg.ConstraintName, "requisitions_closed_at_chk"), strings.Contains(pg.ConstraintName, "requisitions_held_since_chk"): return fmt.Errorf("recruiting: requisition clock invariant %s violated: %w", pg.ConstraintName, err) case strings.Contains(pg.ConstraintName, "status"): return &ValidationError{Field: "status", Message: "is not a permitted value"} // The VMS intake rules from migration 0102. They are named here rather than left to the generic // fallback because both are ordinary data entry mistakes with an obvious fix, and "failed a database // constraint, field ck_requisitions_vms_named" tells a recruiter nothing they can act on. case strings.Contains(pg.ConstraintName, "ck_requisitions_vms_named"): return &ValidationError{Field: "vms_name", Message: "is required when the requisition came through a vendor management system, " + "because a submission cannot be reconciled without knowing which portal it came from"} case strings.Contains(pg.ConstraintName, "ck_requisitions_vms_counts"): return &ValidationError{Field: "competing_vendors", Message: "and max_submissions cannot be negative"} default: return &ValidationError{Field: pg.ConstraintName, Message: "failed a database constraint"} } case "23503": // foreign_key_violation // The composite key added in 0052 carries client_id, so this fires when a contact exists but works // at a different client. "Not found" would be misleading, because the contact is right there. if strings.Contains(pg.ConstraintName, "requisitions_hiring_manager_fkey") { return &ValidationError{Field: "hiring_manager_id", Message: "is not a contact at this client"} } return ErrNotFound case "23502": // not_null_violation return &ValidationError{Field: pg.ColumnName, Message: "is required"} } return fmt.Errorf("recruiting: database error: %w", err) }