package userstore import ( "context" "database/sql" "errors" "fmt" "time" "galaxy/user/internal/domain/common" "galaxy/user/internal/ports" "github.com/jackc/pgx/v5/pgconn" ) // pgUniqueViolationCode identifies the SQLSTATE returned by PostgreSQL when // a UNIQUE constraint is violated by INSERT or UPDATE. const pgUniqueViolationCode = "23505" // classifyUniqueViolation maps a PostgreSQL unique-violation error to the // matching ports sentinel. constraint identifies which UNIQUE constraint name // the caller cares about so we can surface ports.ErrUserNameConflict for the // dedicated user-name index. Returns nil when err is not a unique violation // or does not match constraint. func classifyUniqueViolation(err error, constraint string, mapped error) error { var pgErr *pgconn.PgError if !errors.As(err, &pgErr) || pgErr.Code != pgUniqueViolationCode { return nil } if constraint != "" && pgErr.ConstraintName != constraint { return nil } return mapped } // isUniqueViolation reports whether err is a PostgreSQL unique-violation, // regardless of constraint name. Useful for "any conflict ⇒ ErrConflict" // translations on simple INSERT calls. func isUniqueViolation(err error) bool { var pgErr *pgconn.PgError if !errors.As(err, &pgErr) { return false } return pgErr.Code == pgUniqueViolationCode } // nullableString returns the trimmed string when s is non-empty, otherwise // reports a NULL stand-in usable in $-parameter lists. Empty strings are // stored as NULL so optional columns round-trip through nil. func nullableString(s string) any { if s == "" { return nil } return s } // nullableActorID converts an optional ActorID (the zero value indicates // "no caller supplied this field") to a NULL stand-in for SQL parameters. func nullableActorID(id common.ActorID) any { if id.IsZero() { return nil } return id.String() } // nullableActorType mirrors nullableActorID for ActorType. func nullableActorType(t common.ActorType) any { if t.IsZero() { return nil } return t.String() } // nullableReasonCode mirrors nullableActorID for ReasonCode. func nullableReasonCode(code common.ReasonCode) any { if code.IsZero() { return nil } return code.String() } // nullableUserID mirrors nullableActorID for UserID. func nullableUserID(id common.UserID) any { if id.IsZero() { return nil } return id.String() } // nullableTime returns t.UTC() when non-nil, otherwise nil for NULL columns. func nullableTime(t *time.Time) any { if t == nil { return nil } return t.UTC() } // nullableCountry returns the upper-cased ISO 3166-1 alpha-2 string when set, // otherwise nil. func nullableCountry(code common.CountryCode) any { if code.IsZero() { return nil } return code.String() } // stringFromNullable trims an optional sql.NullString-like *string (read from // Postgres COLUMNAR_NULL) into an ActorID/ReasonCode/UserID-friendly string. func stringFromNullable(value *string) string { if value == nil { return "" } return *value } // timeFromNullable copies an optional *time.Time read from Postgres into a // new pointer normalised to UTC. func timeFromNullable(value *time.Time) *time.Time { if value == nil { return nil } utc := value.UTC() return &utc } // mapNotFound translates sql.ErrNoRows into ports.ErrNotFound, leaving every // other error untouched. func mapNotFound(err error) error { if errors.Is(err, sql.ErrNoRows) { return ports.ErrNotFound } return err } // withTimeout derives a child context bounded by timeout and prefixes context // errors with operation. Callers must always invoke the returned cancel. func withTimeout(ctx context.Context, operation string, timeout time.Duration) (context.Context, context.CancelFunc, error) { if ctx == nil { return nil, nil, fmt.Errorf("%s: nil context", operation) } if err := ctx.Err(); err != nil { return nil, nil, fmt.Errorf("%s: %w", operation, err) } if timeout <= 0 { return nil, nil, fmt.Errorf("%s: operation timeout must be positive", operation) } bounded, cancel := context.WithTimeout(ctx, timeout) return bounded, cancel, nil }