rails-contact 0.1.11 → 0.1.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 50cd2c0740daf7405d8085d2956f810cf0bf8883388ff12afb72ee07a6497b6c
4
- data.tar.gz: 64db2836ff0701a348e215ea294dae2cef1326ef3fb633a4a69676c4b5e477ef
3
+ metadata.gz: a91f250f66bfd11f808193354a723ffb15cf9d8fefc5a52052bb11c7c7ca54e5
4
+ data.tar.gz: 18871d517ce8e9a9fca74e1e74ff775d0eab9ca7208d84397f390754f1a6c280
5
5
  SHA512:
6
- metadata.gz: 6b8653a3e39f49d9d6aa1bb04463df1f4b5b10ac31d759af6b5bb465b8b2b4ecfbf9a83ee8cbf60a70296d805b5df91e942031ea85f0d530075d00e18bb5a4bc
7
- data.tar.gz: e42099d4322b591d5ae7646560afda7178e5ce1674512efab58ef172d34fe74b99b46583553e6cad43ff2a47c96cf95efe34bdd9d2e68958fc77a3a164585812
6
+ metadata.gz: 510ec523c32aedbfb1f867f990f06626e07a99a6170fbad9e96ef70cca883a34a097961cd92a4f335737fa77a103d611158e35ccf1a28069daba6e5ced1c4d49
7
+ data.tar.gz: 8ebc2b2e8a8b28df33bce4f265e4750fa7282ebc37f1208204e1ad8f23b25a178d8b6bb335bc1c3d2f458d6a7d586feba392efa75e60612484c3b0205f9d8d14
data/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.14
4
+
5
+ - Contacts index filters: **`region` and `csv_import_id` are now multi-select**. `filter_params` permits `region: []` / `csv_import_id: []`, strips the hidden blank a `<select multiple>` submits, and coerces a legacy scalar (`?region=Europe`) to a one-element array. The database backend matches `metadata->>'csv_import_id' IN (...)` across the selected imports (a single id behaves exactly as before); `region` was already array-safe via `where(region_name:)`.
6
+ - Database backend: **search query is now sanitized internally** — LIKE metacharacters (`% _ \`) are escaped and input is capped at 200 chars, so a user typing `%` can no longer widen the match to every row. Host apps no longer need to wrap `search` to be safe.
7
+
8
+ ## 0.1.12
9
+
10
+ - `_google_sync_panel`: render the card whenever `google_sync_ui_on_index`; if `google_sync_enabled` is false, show short instructions instead of hiding the section entirely.
11
+
3
12
  ## 0.1.11
4
13
 
5
14
  - Default **Google sync panel** back on the engine index via `_google_sync_panel` when `google_sync_enabled` and `google_sync_ui_on_index` (default true). Host apps can override the partial or disable with `google_sync_ui_on_index = false`.
@@ -127,7 +127,37 @@ module Rails
127
127
  end
128
128
 
129
129
  def filter_params
130
- params.permit(:city, :region, :sync_eligible, :starred)
130
+ permitted = params.permit(
131
+ :city,
132
+ :sync_eligible,
133
+ :starred,
134
+ :travel_date_start,
135
+ :travel_date_end,
136
+ :contact_created_at_start,
137
+ :contact_created_at_end,
138
+ region: [],
139
+ csv_import_id: []
140
+ )
141
+
142
+ normalize_multi_select!(permitted, :region)
143
+ normalize_multi_select!(permitted, :csv_import_id)
144
+ permitted
145
+ end
146
+
147
+ # region and csv_import_id are multi-select filters: a <select multiple>
148
+ # submits param[] (an array) plus a hidden param[]="" that Rails always
149
+ # sends, so the blank must be stripped — otherwise IN ('', 'x') matches
150
+ # every blank-valued row. Legacy bookmarks may still send a scalar
151
+ # (?region=Europe); coerce those to a one-element array so the search
152
+ # backend only ever sees an array (or no key at all).
153
+ def normalize_multi_select!(permitted, key)
154
+ if permitted[key].blank? && params[key].is_a?(String) && params[key].present?
155
+ permitted[key] = [ params[key] ]
156
+ end
157
+ return unless permitted[key].is_a?(Array)
158
+
159
+ permitted[key] = permitted[key].reject(&:blank?)
160
+ permitted.delete(key) if permitted[key].empty?
131
161
  end
132
162
 
133
163
  def google_pending_sync_count
@@ -1,27 +1,35 @@
1
- <% if Rails::Contact.configuration.google_sync_enabled && Rails::Contact.configuration.google_sync_ui_on_index %>
1
+ <% if Rails::Contact.configuration.google_sync_ui_on_index %>
2
2
  <div class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm space-y-4">
3
3
  <h2 class="text-sm font-semibold text-gray-900">Google Contacts</h2>
4
4
 
5
- <div>
6
- <%= form_with url: google_sync_rolling_window_contacts_path, method: :post, local: true, class: "flex flex-wrap items-center gap-3" do %>
7
- <%= submit_tag "Re-sync recent contacts with Google",
8
- class: "inline-flex rounded-md bg-emerald-600 px-4 py-2 text-sm font-semibold text-white hover:bg-emerald-700",
9
- data: { confirm: "Queue Google sync for the recent contact window? This updates contacts already in Google and creates any that are missing. Runs in the background." } %>
10
- <% end %>
11
- <p class="mt-2 text-xs text-gray-500">
12
- Pushes the most recently updated contacts (up to your configured limit) to Google—new rows and changes to names, emails, phones, etc.
13
- </p>
14
- </div>
15
-
16
- <% if @google_contacts_pending_sync.to_i.positive? %>
17
- <div class="border-t border-gray-100 pt-4">
18
- <%= form_with url: google_sync_unsynced_contacts_path, method: :post, local: true, class: "flex flex-wrap items-center gap-3" do %>
19
- <%= submit_tag "Sync #{@google_contacts_pending_sync} contact#{'s' if @google_contacts_pending_sync != 1} with no Google link yet (any age)",
20
- class: "inline-flex rounded-md border border-emerald-700 bg-white px-4 py-2 text-sm font-semibold text-emerald-800 hover:bg-emerald-50",
21
- data: { confirm: "Queue Google sync for #{@google_contacts_pending_sync} contact(s) that are not linked yet? This runs in the background." } %>
5
+ <% if Rails::Contact.configuration.google_sync_enabled %>
6
+ <div>
7
+ <%= form_with url: google_sync_rolling_window_contacts_path, method: :post, local: true, class: "flex flex-wrap items-center gap-3" do %>
8
+ <%= submit_tag "Re-sync recent contacts with Google",
9
+ class: "inline-flex rounded-md bg-emerald-600 px-4 py-2 text-sm font-semibold text-white hover:bg-emerald-700",
10
+ data: { confirm: "Queue Google sync for the recent contact window? This updates contacts already in Google and creates any that are missing. Runs in the background." } %>
22
11
  <% end %>
23
- <p class="mt-2 text-xs text-gray-500">For contacts outside the recent window or older imports that never received a Google <code class="rounded bg-gray-100 px-1">resourceName</code>.</p>
12
+ <p class="mt-2 text-xs text-gray-500">
13
+ Pushes the most recently updated contacts (up to your configured limit) to Google—new rows and changes to names, emails, phones, etc.
14
+ </p>
24
15
  </div>
16
+
17
+ <% if @google_contacts_pending_sync.to_i.positive? %>
18
+ <div class="border-t border-gray-100 pt-4">
19
+ <%= form_with url: google_sync_unsynced_contacts_path, method: :post, local: true, class: "flex flex-wrap items-center gap-3" do %>
20
+ <%= submit_tag "Sync #{@google_contacts_pending_sync} contact#{'s' if @google_contacts_pending_sync != 1} with no Google link yet (any age)",
21
+ class: "inline-flex rounded-md border border-emerald-700 bg-white px-4 py-2 text-sm font-semibold text-emerald-800 hover:bg-emerald-50",
22
+ data: { confirm: "Queue Google sync for #{@google_contacts_pending_sync} contact(s) that are not linked yet? This runs in the background." } %>
23
+ <% end %>
24
+ <p class="mt-2 text-xs text-gray-500">For contacts outside the recent window or older imports that never received a Google <code class="rounded bg-gray-100 px-1">resourceName</code>.</p>
25
+ </div>
26
+ <% end %>
27
+ <% else %>
28
+ <p class="text-sm text-gray-600">
29
+ Google sync is turned off. Set environment variable
30
+ <code class="rounded bg-gray-100 px-1">RAILS_CONTACT_GOOGLE_SYNC_ENABLED=true</code>
31
+ (and OAuth client + token path in <code class="rounded bg-gray-100 px-1">rails_contact</code> initializer), then restart the server.
32
+ </p>
25
33
  <% end %>
26
34
  </div>
27
35
  <% end %>
@@ -19,6 +19,22 @@
19
19
  <%= f.label :region, "Interested Region", class: "mb-1 block text-sm font-medium text-gray-700" %>
20
20
  <%= f.text_field :region, value: params[:region], class: "w-full rounded-md border border-gray-300 px-3 py-2 text-sm" %>
21
21
  </div>
22
+ <div>
23
+ <%= f.label :travel_date_start, "Travel Start", class: "mb-1 block text-sm font-medium text-gray-700" %>
24
+ <%= f.date_field :travel_date_start, value: params[:travel_date_start], class: "w-full rounded-md border border-gray-300 px-3 py-2 text-sm" %>
25
+ </div>
26
+ <div>
27
+ <%= f.label :travel_date_end, "Travel End", class: "mb-1 block text-sm font-medium text-gray-700" %>
28
+ <%= f.date_field :travel_date_end, value: params[:travel_date_end], class: "w-full rounded-md border border-gray-300 px-3 py-2 text-sm" %>
29
+ </div>
30
+ <div>
31
+ <%= f.label :contact_created_at_start, "Created Start", class: "mb-1 block text-sm font-medium text-gray-700" %>
32
+ <%= f.date_field :contact_created_at_start, value: params[:contact_created_at_start], class: "w-full rounded-md border border-gray-300 px-3 py-2 text-sm" %>
33
+ </div>
34
+ <div>
35
+ <%= f.label :contact_created_at_end, "Created End", class: "mb-1 block text-sm font-medium text-gray-700" %>
36
+ <%= f.date_field :contact_created_at_end, value: params[:contact_created_at_end], class: "w-full rounded-md border border-gray-300 px-3 py-2 text-sm" %>
37
+ </div>
22
38
  <div class="flex gap-2">
23
39
  <%= f.submit "Filter", class: "inline-flex rounded-md bg-gray-900 px-4 py-2 text-sm font-medium text-white hover:bg-gray-700" %>
24
40
  <%= link_to "Reset", contacts_path, class: "inline-flex rounded-md border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50" %>
@@ -22,5 +22,8 @@ class CreateRailsContactContacts < ActiveRecord::Migration[7.1]
22
22
  add_index :rails_contact_contacts, :updated_at
23
23
  add_index :rails_contact_contacts, :sync_eligible
24
24
  add_index :rails_contact_contacts, :google_resource_name, unique: true
25
+ add_index :rails_contact_contacts, "(metadata->>'travel_date')", name: 'idx_contacts_travel_date'
26
+ add_index :rails_contact_contacts, "(metadata->>'contact_created_at')", name: 'idx_contacts_contact_created_at'
27
+ add_index :rails_contact_contacts, "(metadata->>'csv_import_id')", name: 'idx_contacts_csv_import_id'
25
28
  end
26
29
  end
@@ -3,7 +3,12 @@ module Rails
3
3
  module Search
4
4
  module Backends
5
5
  class Database
6
+ # Cap user input so the LIKE pattern stays bounded; 200 chars covers
7
+ # any realistic name/email/phone substring.
8
+ MAX_QUERY_LENGTH = 200
9
+
6
10
  def search(query, filters, page:, per_page:)
11
+ query = sanitize_query(query)
7
12
  offset = (page - 1) * per_page
8
13
  scope = Contact.includes(:emails, :phones, :labels).recent_first
9
14
  scope = apply_filters(scope, filters)
@@ -33,6 +38,16 @@ module Rails
33
38
 
34
39
  private
35
40
 
41
+ # Escape LIKE metacharacters (% _ \) so a user typing "%" can't widen
42
+ # the match to every row, and cap length to keep the pattern bounded.
43
+ # The backend builds raw "%…%" LIKE patterns, so this guard belongs
44
+ # here — host apps should not have to wrap search() to stay safe.
45
+ def sanitize_query(query)
46
+ return query if query.blank?
47
+
48
+ ActiveRecord::Base.sanitize_sql_like(query.to_s[0, MAX_QUERY_LENGTH])
49
+ end
50
+
36
51
  def apply_filters(scope, filters)
37
52
  scoped = scope
38
53
  scoped = scoped.where(current_city: filters["city"]) if filters["city"].present?
@@ -41,6 +56,32 @@ module Rails
41
56
  if filters["sync_eligible"].present?
42
57
  scoped = scoped.where(sync_eligible: ActiveModel::Type::Boolean.new.cast(filters["sync_eligible"]))
43
58
  end
59
+
60
+ if filters["travel_date_start"].present?
61
+ scoped = scoped.where("metadata->>'travel_date' >= ?", filters["travel_date_start"])
62
+ end
63
+
64
+ if filters["travel_date_end"].present?
65
+ scoped = scoped.where("metadata->>'travel_date' <= ?", filters["travel_date_end"])
66
+ end
67
+
68
+ if filters["contact_created_at_start"].present?
69
+ scoped = scoped.where("metadata->>'contact_created_at' >= ?", filters["contact_created_at_start"])
70
+ end
71
+
72
+ if filters["contact_created_at_end"].present?
73
+ scoped = scoped.where("metadata->>'contact_created_at' <= ?", filters["contact_created_at_end"])
74
+ end
75
+
76
+ # csv_import_id is a multi-select filter: it may be a single id or an
77
+ # array of ids. region above is already array-safe via where(region_name:),
78
+ # but this JSON-extraction predicate needs an explicit IN. A single id
79
+ # yields IN ('5'), identical to the old = '5'.
80
+ if filters["csv_import_id"].present?
81
+ ids = Array(filters["csv_import_id"]).map(&:to_s).reject(&:blank?)
82
+ scoped = scoped.where("metadata->>'csv_import_id' IN (?)", ids) if ids.any?
83
+ end
84
+
44
85
  scoped
45
86
  end
46
87
  end
@@ -120,8 +120,8 @@ module Rails
120
120
 
121
121
  def filter_clauses(filters)
122
122
  clauses = []
123
- clauses << { term: { current_city: filters["city"] } } if filters["city"].present?
124
- clauses << { term: { region_name: filters["region"] } } if filters["region"].present?
123
+ clauses << match_clause(:current_city, filters["city"]) if filters["city"].present?
124
+ clauses << match_clause(:region_name, filters["region"]) if filters["region"].present?
125
125
  unless filters["starred"].nil?
126
126
  starred_value = ActiveModel::Type::Boolean.new.cast(filters["starred"])
127
127
  clauses << { term: { starred: starred_value } }
@@ -133,6 +133,12 @@ module Rails
133
133
  clauses
134
134
  end
135
135
 
136
+ # city and region are multi-select filters: an array uses a `terms`
137
+ # clause (match any), a single value keeps the scalar `term` clause.
138
+ def match_clause(field, value)
139
+ value.is_a?(Array) ? { terms: { field => value } } : { term: { field => value } }
140
+ end
141
+
136
142
  def document_for(contact)
137
143
  {
138
144
  id: contact.id,
@@ -1,5 +1,5 @@
1
1
  module Rails
2
2
  module Contact
3
- VERSION = "0.1.11"
3
+ VERSION = "0.1.14"
4
4
  end
5
5
  end
@@ -19,5 +19,4 @@ namespace :rails_contact do
19
19
  Rails::Contact::GoogleSyncJob.perform_now
20
20
  puts "Google sync completed"
21
21
  end
22
-
23
22
  end
@@ -4,10 +4,28 @@ RSpec.describe Rails::Contact::ContactsController do
4
4
  let(:controller) { described_class.new }
5
5
 
6
6
  describe "private filter params" do
7
- it "permits city/region/sync_eligible keys" do
7
+ it "permits city/sync_eligible and coerces a scalar region to an array" do
8
8
  controller.params = ActionController::Parameters.new(city: "Delhi", region: "Europe", sync_eligible: "true", x: "1")
9
9
  permitted = controller.send(:filter_params)
10
- expect(permitted.to_h).to eq({ "city" => "Delhi", "region" => "Europe", "sync_eligible" => "true" })
10
+ expect(permitted.to_h).to eq({ "city" => "Delhi", "region" => [ "Europe" ], "sync_eligible" => "true" })
11
+ end
12
+
13
+ it "permits multi-select region[] and csv_import_id[] arrays" do
14
+ controller.params = ActionController::Parameters.new(region: [ "Europe", "Asia" ], csv_import_id: [ "5", "7" ])
15
+ permitted = controller.send(:filter_params)
16
+ expect(permitted.to_h).to eq({ "region" => [ "Europe", "Asia" ], "csv_import_id" => [ "5", "7" ] })
17
+ end
18
+
19
+ it "strips the hidden blank a <select multiple> submits" do
20
+ controller.params = ActionController::Parameters.new(csv_import_id: [ "", "7" ])
21
+ permitted = controller.send(:filter_params)
22
+ expect(permitted.to_h).to eq({ "csv_import_id" => [ "7" ] })
23
+ end
24
+
25
+ it "drops a multi-select key entirely when only blanks are submitted" do
26
+ controller.params = ActionController::Parameters.new(region: [ "" ])
27
+ permitted = controller.send(:filter_params)
28
+ expect(permitted.to_h).to eq({})
11
29
  end
12
30
  end
13
31
 
@@ -0,0 +1,49 @@
1
+ require "rails_helper"
2
+
3
+ RSpec.describe Rails::Contact::Search::Backends::Database do
4
+ # metadata is a text column read back through JSON.parse, so store JSON for
5
+ # the SQL `metadata->>'csv_import_id'` extraction to resolve.
6
+ def make(name, import_id)
7
+ create(:rails_contact_contact, given_name: name, metadata: { "csv_import_id" => import_id }.to_json)
8
+ end
9
+
10
+ let!(:alice) { make("Alice", "imp_1") }
11
+ let!(:bob) { make("Bob", "imp_2") }
12
+ let!(:carol) { make("Carol", "imp_3") }
13
+
14
+ def records(filters)
15
+ described_class.new.search("", filters, page: 1, per_page: 25).records
16
+ end
17
+
18
+ describe "csv_import_id filter (multi-select)" do
19
+ it "matches contacts across every selected import (array -> IN)" do
20
+ expect(records("csv_import_id" => [ "imp_1", "imp_2" ])).to match_array([ alice, bob ])
21
+ end
22
+
23
+ it "matches a single import exactly as before (scalar)" do
24
+ expect(records("csv_import_id" => "imp_1")).to match_array([ alice ])
25
+ end
26
+
27
+ it "applies no constraint when the selection is blank only" do
28
+ expect(records("csv_import_id" => [ "" ])).to match_array([ alice, bob, carol ])
29
+ end
30
+ end
31
+
32
+ describe "query sanitization" do
33
+ def search_for(query)
34
+ described_class.new.search(query, {}, page: 1, per_page: 25).records
35
+ end
36
+
37
+ it "treats % as a literal, not a match-everything wildcard" do
38
+ expect(search_for("%")).to be_empty
39
+ end
40
+
41
+ it "still matches a real substring" do
42
+ expect(search_for("Ali")).to include(alice)
43
+ end
44
+
45
+ it "does not blow up on pathologically long input" do
46
+ expect { search_for("a" * 1000) }.not_to raise_error
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,33 @@
1
+ require "rails_helper"
2
+
3
+ RSpec.describe Rails::Contact::Search::Backends::Elasticsearch do
4
+ # Captures the query body so we can assert filter-clause shape without a real
5
+ # Elasticsearch server. Returns an empty hit set so #search resolves cleanly.
6
+ let(:captured) { {} }
7
+ let(:client) do
8
+ cap = captured
9
+ Class.new do
10
+ define_method(:initialize) { cap }
11
+ define_method(:search) do |index:, body:|
12
+ cap[:index] = index
13
+ cap[:body] = body
14
+ { "hits" => { "total" => { "value" => 0 }, "hits" => [] } }
15
+ end
16
+ end.new
17
+ end
18
+
19
+ def filter_clauses(filters)
20
+ described_class.new(client: client).search("", filters, page: 1, per_page: 25)
21
+ captured[:body][:query][:bool][:filter]
22
+ end
23
+
24
+ it "emits a terms clause for a multi-select region array" do
25
+ expect(filter_clauses("region" => [ "Europe", "Asia" ]))
26
+ .to include({ terms: { region_name: [ "Europe", "Asia" ] } })
27
+ end
28
+
29
+ it "emits a scalar term clause for a single city" do
30
+ expect(filter_clauses("city" => "Delhi"))
31
+ .to include({ term: { current_city: "Delhi" } })
32
+ end
33
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails-contact
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.11
4
+ version: 0.1.14
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kshitiz Sinha
@@ -193,6 +193,8 @@ files:
193
193
  - spec/helpers/application_helper_spec.rb
194
194
  - spec/models/contact_spec.rb
195
195
  - spec/rails_helper.rb
196
+ - spec/search/database_backend_spec.rb
197
+ - spec/search/elasticsearch_backend_spec.rb
196
198
  - spec/services/merge_contacts_service_spec.rb
197
199
  - spec/spec_helper.rb
198
200
  - spec/views/contact_templates_spec.rb