rails-contact 0.1.5 → 0.1.8
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 +4 -4
- data/CHANGELOG.md +14 -0
- data/README.md +3 -2
- data/app/controllers/rails/contact/contacts_controller.rb +27 -1
- data/app/jobs/rails/contact/google_sync_unsynced_job.rb +13 -0
- data/app/views/rails/contact/_index_pagination.html.erb +25 -0
- data/app/views/rails/contact/index.html.erb +17 -0
- data/config/routes.rb +1 -0
- data/docs/parity_matrix.md +1 -1
- data/lib/generators/rails/contact/install/templates/rails_contact.rb.tt +2 -0
- data/lib/rails/contact/configuration.rb +4 -1
- data/lib/rails/contact/engine.rb +5 -0
- data/lib/rails/contact/google/client.rb +14 -2
- data/lib/rails/contact/google/payload_mapper.rb +31 -5
- data/lib/rails/contact/google/sync_service.rb +22 -2
- data/lib/rails/contact/search/backends/database.rb +13 -8
- data/lib/rails/contact/search/backends/elasticsearch.rb +34 -8
- data/lib/rails/contact/search/query.rb +12 -2
- data/lib/rails/contact/search/result.rb +13 -0
- data/lib/rails/contact/version.rb +1 -1
- data/lib/rails/contact.rb +1 -1
- data/lib/tasks/rails/contact_tasks.rake +0 -8
- metadata +6 -4
- data/lib/rails/contact/csv/import_service.rb +0 -87
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 11aea6cb5a1f51337bf1c48e9cb0e3d34ada33d16af8fc5a8476a687d78eb5ba
|
|
4
|
+
data.tar.gz: 63ce98be235f82c0dfe65a22abe308566d44a72e423e0a2351d8e8d04a5ef0f4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1dc45c5cbcb8d9db9bfa9e8374408e8746c2d0e03fd6bd7bea77acf767df515c74ce7b0334761e31f94ac9fdabb86a19af3d5a8ce4c3c6876b81184336e25704
|
|
7
|
+
data.tar.gz: 4345e73338be0e56ad00d29d9796fa3ea08e7136f319eb6df79939823f17fe1b898b8ef53d8047f9863142c3ab0751f60b4f00f40bc3b11e177dc6c9c3db95b4
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.8
|
|
4
|
+
|
|
5
|
+
- Contacts index: paginated search (total count, previous/next, page indicator) with Elasticsearch and database backends.
|
|
6
|
+
- `POST /google_sync_unsynced` and **Sync … not yet in Google** button when sync is enabled; `GoogleSyncUnsyncedJob` runs `SyncService#sync_unsynced!` for contacts with no `google_resource_name`.
|
|
7
|
+
|
|
8
|
+
## 0.1.7
|
|
9
|
+
|
|
10
|
+
- Google People API: `updateContact` sends required `updatePersonFields`; update payloads include `resourceName` and `etag`; sync preloads emails, phones, and addresses.
|
|
11
|
+
- Google: optional `google_contact_family_name_suffix` (and env `RAILS_CONTACT_GOOGLE_CONTACT_FAMILY_NAME_SUFFIX`) for family name in sync payloads only.
|
|
12
|
+
- Google: payload mapper skips blank emails/phones, omits empty association arrays, biography uses `TEXT_PLAIN` when present.
|
|
13
|
+
- Elasticsearch search backend reuses one `Elasticsearch::Client` per process (fewer product-check warnings).
|
|
14
|
+
- Remove CSV import from the gem (keep imports in the host application). Drops `rails_contact:import_csv` and `Csv::ImportService`.
|
|
15
|
+
- Engine registers stylesheet/javascript paths for Propshaft.
|
|
16
|
+
|
|
3
17
|
## 0.1.5
|
|
4
18
|
|
|
5
19
|
- Scope engine stylesheet to contacts content only (`.rails-contact-page`) so host app layout/header styling is not overridden.
|
data/README.md
CHANGED
|
@@ -9,7 +9,7 @@ It provides:
|
|
|
9
9
|
- labels/tags
|
|
10
10
|
- dynamic add/remove nested rows
|
|
11
11
|
- Elasticsearch-backed search with DB fallback
|
|
12
|
-
-
|
|
12
|
+
- Google sync scaffolding
|
|
13
13
|
- merge and bulk-delete operations
|
|
14
14
|
- Devise-style override generators
|
|
15
15
|
|
|
@@ -128,6 +128,8 @@ Rails::Contact.configure do |config|
|
|
|
128
128
|
config.google_sync_enabled = false
|
|
129
129
|
config.google_max_contacts = 25_000
|
|
130
130
|
config.rolling_window_sort = :updated_at
|
|
131
|
+
# Optional: appended to familyName in Google People API payloads only (blank = unchanged).
|
|
132
|
+
# config.google_contact_family_name_suffix = "_by_vendor"
|
|
131
133
|
config.default_per_page = 25
|
|
132
134
|
end
|
|
133
135
|
```
|
|
@@ -139,7 +141,6 @@ end
|
|
|
139
141
|
```bash
|
|
140
142
|
rake rails_contact:reindex
|
|
141
143
|
rake rails_contact:sync_google
|
|
142
|
-
rake rails_contact:import_csv CSV_PATH=/absolute/path/to/eq.csv
|
|
143
144
|
```
|
|
144
145
|
|
|
145
146
|
---
|
|
@@ -10,10 +10,30 @@ module Rails
|
|
|
10
10
|
|
|
11
11
|
def index
|
|
12
12
|
@query = params[:q].to_s.strip
|
|
13
|
-
|
|
13
|
+
result = Search::Query.new(
|
|
14
|
+
@query,
|
|
15
|
+
filters: normalized_filters(filter_params),
|
|
16
|
+
page: params[:page]
|
|
17
|
+
).call
|
|
18
|
+
@contacts = result.records
|
|
19
|
+
@total_count = result.total_count
|
|
20
|
+
@page = result.page
|
|
21
|
+
@per_page = result.per_page
|
|
22
|
+
@total_pages = result.total_pages
|
|
23
|
+
@google_contacts_pending_sync = google_pending_sync_count
|
|
14
24
|
render "rails/contact/index"
|
|
15
25
|
end
|
|
16
26
|
|
|
27
|
+
def google_sync_unsynced
|
|
28
|
+
unless Rails::Contact.configuration.google_sync_enabled
|
|
29
|
+
redirect_to contacts_path, alert: "Google Contacts sync is disabled."
|
|
30
|
+
return
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
GoogleSyncUnsyncedJob.perform_later
|
|
34
|
+
redirect_to contacts_path, notice: "Google sync has been queued for contacts not yet linked."
|
|
35
|
+
end
|
|
36
|
+
|
|
17
37
|
def show
|
|
18
38
|
render "rails/contact/show"
|
|
19
39
|
end
|
|
@@ -99,6 +119,12 @@ module Rails
|
|
|
99
119
|
params.permit(:city, :region, :sync_eligible, :starred)
|
|
100
120
|
end
|
|
101
121
|
|
|
122
|
+
def google_pending_sync_count
|
|
123
|
+
return 0 unless Rails::Contact.configuration.google_sync_enabled
|
|
124
|
+
|
|
125
|
+
Contact.where("google_resource_name IS NULL OR google_resource_name = ?", "").count
|
|
126
|
+
end
|
|
127
|
+
|
|
102
128
|
def enqueue_index(contact_id)
|
|
103
129
|
IndexContactJob.perform_later(contact_id)
|
|
104
130
|
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
<% if defined?(@total_count) && @total_count.to_i > 0 %>
|
|
2
|
+
<div class="flex flex-col gap-3 border-t border-gray-200 bg-gray-50 px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
|
|
3
|
+
<p class="text-sm text-gray-600">
|
|
4
|
+
<% start_i = (@page - 1) * @per_page + 1 %>
|
|
5
|
+
<% end_i = [ @page * @per_page, @total_count ].min %>
|
|
6
|
+
Showing <span class="font-medium text-gray-900"><%= start_i %>–<%= end_i %></span>
|
|
7
|
+
of <span class="font-medium text-gray-900"><%= number_with_delimiter(@total_count) %></span>
|
|
8
|
+
<% if @per_page.present? && @per_page > 0 %>
|
|
9
|
+
<span class="text-gray-500">(<%= @per_page %> per page)</span>
|
|
10
|
+
<% end %>
|
|
11
|
+
</p>
|
|
12
|
+
<% if defined?(@total_pages) && @total_pages > 1 %>
|
|
13
|
+
<div class="flex flex-wrap items-center gap-2">
|
|
14
|
+
<% qp = request.query_parameters.except(:page, :action, :controller) %>
|
|
15
|
+
<% if @page > 1 %>
|
|
16
|
+
<%= link_to "Previous", url_for(qp.merge(page: @page - 1)), class: "inline-flex rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50" %>
|
|
17
|
+
<% end %>
|
|
18
|
+
<span class="text-sm text-gray-600">Page <%= @page %> / <%= @total_pages %></span>
|
|
19
|
+
<% if @page < @total_pages %>
|
|
20
|
+
<%= link_to "Next", url_for(qp.merge(page: @page + 1)), class: "inline-flex rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50" %>
|
|
21
|
+
<% end %>
|
|
22
|
+
</div>
|
|
23
|
+
<% end %>
|
|
24
|
+
</div>
|
|
25
|
+
<% end %>
|
|
@@ -26,6 +26,22 @@
|
|
|
26
26
|
<% end %>
|
|
27
27
|
</div>
|
|
28
28
|
|
|
29
|
+
<% if Rails::Contact.configuration.google_sync_enabled %>
|
|
30
|
+
<div class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm">
|
|
31
|
+
<h2 class="mb-2 text-sm font-semibold text-gray-900">Google Contacts</h2>
|
|
32
|
+
<% if @google_contacts_pending_sync.to_i.positive? %>
|
|
33
|
+
<%= form_with url: google_sync_unsynced_contacts_path, method: :post, local: true, class: "flex flex-wrap items-center gap-3" do %>
|
|
34
|
+
<%= submit_tag "Sync #{@google_contacts_pending_sync} contact#{'s' if @google_contacts_pending_sync != 1} not yet in Google",
|
|
35
|
+
class: "inline-flex rounded-md bg-emerald-600 px-4 py-2 text-sm font-semibold text-white hover:bg-emerald-700",
|
|
36
|
+
data: { confirm: "Queue Google sync for #{@google_contacts_pending_sync} contact(s)? This runs in the background." } %>
|
|
37
|
+
<% end %>
|
|
38
|
+
<p class="mt-2 text-xs text-gray-500">Creates or updates People entries for contacts with no Google link yet. Already-linked contacts are unchanged.</p>
|
|
39
|
+
<% else %>
|
|
40
|
+
<p class="text-sm text-gray-600">Every contact in the database is already linked to Google (or there are no contacts).</p>
|
|
41
|
+
<% end %>
|
|
42
|
+
</div>
|
|
43
|
+
<% end %>
|
|
44
|
+
|
|
29
45
|
<div class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm">
|
|
30
46
|
<%= form_with url: bulk_destroy_contacts_path, method: :post, local: true, class: "inline-flex items-center gap-2" do |f| %>
|
|
31
47
|
<%= hidden_field_tag :ids, "", id: "bulk-ids-field" %>
|
|
@@ -76,6 +92,7 @@
|
|
|
76
92
|
<% end %>
|
|
77
93
|
</tbody>
|
|
78
94
|
</table>
|
|
95
|
+
<%= render "rails/contact/index_pagination" %>
|
|
79
96
|
</div>
|
|
80
97
|
</div>
|
|
81
98
|
|
data/config/routes.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
Rails::Contact::Engine.routes.draw do
|
|
2
2
|
post "/bulk_destroy", to: "contacts#bulk_destroy", as: :bulk_destroy_contacts
|
|
3
3
|
post "/merge", to: "contacts#merge", as: :merge_contacts
|
|
4
|
+
post "/google_sync_unsynced", to: "contacts#google_sync_unsynced", as: :google_sync_unsynced_contacts
|
|
4
5
|
get "/", to: "contacts#index", as: :contacts
|
|
5
6
|
get "/new", to: "contacts#new", as: :new_contact
|
|
6
7
|
post "/", to: "contacts#create"
|
data/docs/parity_matrix.md
CHANGED
|
@@ -46,7 +46,7 @@ This matrix tracks feature parity for `rails-contact` versus Google Contacts.
|
|
|
46
46
|
|
|
47
47
|
| Capability | Google Contacts | rails-contact Target | Priority | Acceptance Criteria |
|
|
48
48
|
|---|---|---|---|---|
|
|
49
|
-
| CSV import | Flexible column import |
|
|
49
|
+
| CSV import | Flexible column import | Host app | P0 | Engine stays format-agnostic; host maps columns to models |
|
|
50
50
|
| CSV export | Download contacts | Same | P1 | Export with stable headers and escaping |
|
|
51
51
|
| Google sync | People API sync | Similar | P0/P1 | Two-way sync for rolling window with conflict policy |
|
|
52
52
|
| Conflict resolution | Last write + ETag semantics | Same policy | P1 | Deterministic merge outcomes and tests |
|
|
@@ -11,6 +11,8 @@ Rails::Contact.configure do |config|
|
|
|
11
11
|
config.google_client_secret = ENV["GOOGLE_CLIENT_SECRET"]
|
|
12
12
|
config.google_redirect_uri = ENV["GOOGLE_REDIRECT_URI"]
|
|
13
13
|
config.google_token_path = ENV.fetch("RAILS_CONTACT_GOOGLE_TOKEN_PATH", "tmp/rails_contact_google_token.json")
|
|
14
|
+
# Optional: suffix appended to family name only in Google sync payloads (e.g. "_by_vendor"). Leave unset to disable.
|
|
15
|
+
# config.google_contact_family_name_suffix = ENV["RAILS_CONTACT_GOOGLE_CONTACT_FAMILY_NAME_SUFFIX"]&.presence
|
|
14
16
|
|
|
15
17
|
config.default_per_page = 25
|
|
16
18
|
end
|
|
@@ -5,7 +5,8 @@ module Rails
|
|
|
5
5
|
:google_sync_enabled, :google_max_contacts, :rolling_window_sort,
|
|
6
6
|
:google_client_id, :google_client_secret, :google_redirect_uri,
|
|
7
7
|
:google_token_path, :reset_index_on_boot, :default_per_page,
|
|
8
|
-
:inherit_host_layout
|
|
8
|
+
:inherit_host_layout,
|
|
9
|
+
:google_contact_family_name_suffix
|
|
9
10
|
|
|
10
11
|
def initialize
|
|
11
12
|
@contact_class_name = "Rails::Contact::Contact"
|
|
@@ -18,6 +19,8 @@ module Rails
|
|
|
18
19
|
@google_client_secret = ENV["GOOGLE_CLIENT_SECRET"]
|
|
19
20
|
@google_redirect_uri = ENV["GOOGLE_REDIRECT_URI"]
|
|
20
21
|
@google_token_path = ENV.fetch("RAILS_CONTACT_GOOGLE_TOKEN_PATH", "tmp/rails_contact_google_token.json")
|
|
22
|
+
# Optional: appended to familyName in Google People payloads only (not stored on Contact). Blank = disabled.
|
|
23
|
+
@google_contact_family_name_suffix = ENV["RAILS_CONTACT_GOOGLE_CONTACT_FAMILY_NAME_SUFFIX"]&.presence
|
|
21
24
|
@reset_index_on_boot = false
|
|
22
25
|
@default_per_page = 25
|
|
23
26
|
# When true (default), engine pages use the host app +layout+ named +application+ so
|
data/lib/rails/contact/engine.rb
CHANGED
|
@@ -13,6 +13,11 @@ module Rails
|
|
|
13
13
|
app.config.filter_parameters += %i[google_access_token google_refresh_token authorization]
|
|
14
14
|
end
|
|
15
15
|
|
|
16
|
+
initializer "rails_contact.assets" do |app|
|
|
17
|
+
app.config.assets.paths << root.join("app/assets/stylesheets")
|
|
18
|
+
app.config.assets.paths << root.join("app/assets/javascripts")
|
|
19
|
+
end
|
|
20
|
+
|
|
16
21
|
rake_tasks do
|
|
17
22
|
load File.expand_path("../../tasks/rails/contact_tasks.rake", __dir__)
|
|
18
23
|
end
|
|
@@ -6,6 +6,9 @@ module Rails
|
|
|
6
6
|
class Client
|
|
7
7
|
PEOPLE_API_BASE = "https://people.googleapis.com/v1".freeze
|
|
8
8
|
|
|
9
|
+
# Fields allowed in updatePersonFields (People API field mask); excludes Person metadata keys.
|
|
10
|
+
UPDATE_MASK_FIELDS = %w[names emailAddresses phoneNumbers addresses biographies].freeze
|
|
11
|
+
|
|
9
12
|
def initialize(access_token:)
|
|
10
13
|
@connection = Faraday.new(url: PEOPLE_API_BASE) do |faraday|
|
|
11
14
|
faraday.request :retry, max: 3, interval: 0.5, backoff_factor: 2
|
|
@@ -19,7 +22,10 @@ module Rails
|
|
|
19
22
|
end
|
|
20
23
|
|
|
21
24
|
def update_contact(resource_name, payload)
|
|
22
|
-
|
|
25
|
+
mask = update_person_fields_mask(payload)
|
|
26
|
+
raise ArgumentError, "updatePersonFields must not be empty" if mask.blank?
|
|
27
|
+
|
|
28
|
+
patch("/#{resource_name}:updateContact", payload, { "updatePersonFields" => mask })
|
|
23
29
|
end
|
|
24
30
|
|
|
25
31
|
def delete_contact(resource_name)
|
|
@@ -35,10 +41,11 @@ module Rails
|
|
|
35
41
|
end)
|
|
36
42
|
end
|
|
37
43
|
|
|
38
|
-
def patch(path, payload)
|
|
44
|
+
def patch(path, payload, params = nil)
|
|
39
45
|
parse(@connection.patch(path) do |request|
|
|
40
46
|
request.headers = headers
|
|
41
47
|
request.body = payload.to_json
|
|
48
|
+
request.params.update(params) if params.present?
|
|
42
49
|
end)
|
|
43
50
|
end
|
|
44
51
|
|
|
@@ -49,6 +56,11 @@ module Rails
|
|
|
49
56
|
def headers
|
|
50
57
|
{ "Authorization" => "Bearer #{@access_token}", "Content-Type" => "application/json" }
|
|
51
58
|
end
|
|
59
|
+
|
|
60
|
+
def update_person_fields_mask(payload)
|
|
61
|
+
h = payload.stringify_keys
|
|
62
|
+
UPDATE_MASK_FIELDS.select { |field| h[field].present? }.join(",")
|
|
63
|
+
end
|
|
52
64
|
end
|
|
53
65
|
end
|
|
54
66
|
end
|
|
@@ -7,18 +7,44 @@ module Rails
|
|
|
7
7
|
end
|
|
8
8
|
|
|
9
9
|
def to_people_payload
|
|
10
|
+
bio_text = [ @contact.biography, @contact.region_name ].compact.join("\n")
|
|
10
11
|
{
|
|
11
|
-
names: [ { givenName: @contact.given_name, familyName:
|
|
12
|
-
emailAddresses: @contact.emails.
|
|
13
|
-
|
|
12
|
+
names: [ { givenName: @contact.given_name, familyName: family_name_for_google } ],
|
|
13
|
+
emailAddresses: @contact.emails.filter_map do |email|
|
|
14
|
+
next if email.value.blank?
|
|
15
|
+
|
|
16
|
+
{ value: email.value, type: (email.label || "other") }
|
|
17
|
+
end,
|
|
18
|
+
phoneNumbers: @contact.phones.filter_map do |phone|
|
|
19
|
+
value = phone.e164.presence || phone.value
|
|
20
|
+
next if value.blank?
|
|
21
|
+
|
|
22
|
+
{ value: value, type: (phone.label || "mobile") }
|
|
23
|
+
end,
|
|
14
24
|
addresses: @contact.addresses.map do |address|
|
|
15
25
|
{
|
|
16
26
|
city: address.city,
|
|
17
27
|
formattedValue: address.formatted_value.presence || "Current city: #{@contact.current_city}\nDeparture city: #{@contact.departure_city}"
|
|
18
28
|
}
|
|
19
29
|
end,
|
|
20
|
-
biographies: [ { value:
|
|
21
|
-
}.compact
|
|
30
|
+
biographies: ([ { value: bio_text, contentType: "TEXT_PLAIN" } ] if bio_text.present?)
|
|
31
|
+
}.compact.tap do |h|
|
|
32
|
+
h.delete(:emailAddresses) if h[:emailAddresses].blank?
|
|
33
|
+
h.delete(:phoneNumbers) if h[:phoneNumbers].blank?
|
|
34
|
+
h.delete(:addresses) if h[:addresses].blank?
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def family_name_for_google
|
|
41
|
+
suffix = Rails::Contact.configuration.google_contact_family_name_suffix
|
|
42
|
+
return @contact.family_name if suffix.blank?
|
|
43
|
+
|
|
44
|
+
base = @contact.family_name.to_s
|
|
45
|
+
return suffix if base.blank?
|
|
46
|
+
|
|
47
|
+
base.end_with?(suffix) ? base : "#{base}#{suffix}"
|
|
22
48
|
end
|
|
23
49
|
end
|
|
24
50
|
end
|
|
@@ -8,15 +8,35 @@ module Rails
|
|
|
8
8
|
end
|
|
9
9
|
|
|
10
10
|
def sync!
|
|
11
|
-
Rails::Contact::Contact.sync_window.each do |contact|
|
|
11
|
+
Rails::Contact::Contact.sync_window.includes(:emails, :phones, :addresses).each do |contact|
|
|
12
12
|
sync_contact(contact)
|
|
13
13
|
end
|
|
14
14
|
end
|
|
15
15
|
|
|
16
|
+
def unsynced_contacts_scope
|
|
17
|
+
Rails::Contact::Contact.where("google_resource_name IS NULL OR google_resource_name = ?", "")
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def sync_unsynced!
|
|
21
|
+
unsynced_contacts_scope.in_batches(of: 100) do |batch|
|
|
22
|
+
batch.preload(:emails, :phones, :addresses).each do |contact|
|
|
23
|
+
sync_contact(contact)
|
|
24
|
+
rescue StandardError => e
|
|
25
|
+
Rails.logger.error(
|
|
26
|
+
"[Rails::Contact] Google sync_unsynced failed for contact #{contact.id}: #{e.class}: #{e.message}"
|
|
27
|
+
)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
16
32
|
def sync_contact(contact)
|
|
17
33
|
payload = PayloadMapper.new(contact).to_people_payload
|
|
18
34
|
response = if contact.google_resource_name.present?
|
|
19
|
-
|
|
35
|
+
body = payload.merge(
|
|
36
|
+
resourceName: contact.google_resource_name,
|
|
37
|
+
etag: contact.google_etag
|
|
38
|
+
).compact
|
|
39
|
+
@client.update_contact(contact.google_resource_name, body)
|
|
20
40
|
else
|
|
21
41
|
@client.create_contact(payload)
|
|
22
42
|
end
|
|
@@ -3,13 +3,19 @@ module Rails
|
|
|
3
3
|
module Search
|
|
4
4
|
module Backends
|
|
5
5
|
class Database
|
|
6
|
-
def search(query, filters)
|
|
6
|
+
def search(query, filters, page:, per_page:)
|
|
7
|
+
offset = (page - 1) * per_page
|
|
7
8
|
scope = Contact.includes(:emails, :phones, :labels).recent_first
|
|
8
9
|
scope = apply_filters(scope, filters)
|
|
9
|
-
|
|
10
|
+
|
|
11
|
+
if query.blank?
|
|
12
|
+
total = scope.count
|
|
13
|
+
records = scope.offset(offset).limit(per_page).to_a
|
|
14
|
+
return Search::Result.new(records: records, total_count: total, page: page, per_page: per_page)
|
|
15
|
+
end
|
|
10
16
|
|
|
11
17
|
wildcard = "%#{query.downcase}%"
|
|
12
|
-
scope.left_joins(:emails, :phones, :labels).where(
|
|
18
|
+
filtered = scope.left_joins(:emails, :phones, :labels).where(
|
|
13
19
|
"LOWER(rails_contact_contacts.given_name) LIKE :q OR "\
|
|
14
20
|
"LOWER(rails_contact_contacts.family_name) LIKE :q OR "\
|
|
15
21
|
"LOWER(COALESCE(rails_contact_contacts.metadata->>'company', '')) LIKE :q OR "\
|
|
@@ -19,7 +25,10 @@ module Rails
|
|
|
19
25
|
"LOWER(rails_contact_labels.name) LIKE :q",
|
|
20
26
|
q: wildcard,
|
|
21
27
|
raw: "%#{query}%"
|
|
22
|
-
).distinct
|
|
28
|
+
).distinct
|
|
29
|
+
total = filtered.count(:id)
|
|
30
|
+
records = filtered.offset(offset).limit(per_page).to_a
|
|
31
|
+
Search::Result.new(records: records, total_count: total, page: page, per_page: per_page)
|
|
23
32
|
end
|
|
24
33
|
|
|
25
34
|
private
|
|
@@ -34,10 +43,6 @@ module Rails
|
|
|
34
43
|
end
|
|
35
44
|
scoped
|
|
36
45
|
end
|
|
37
|
-
|
|
38
|
-
def limit
|
|
39
|
-
Rails::Contact.configuration.default_per_page
|
|
40
|
-
end
|
|
41
46
|
end
|
|
42
47
|
end
|
|
43
48
|
end
|
|
@@ -5,17 +5,34 @@ module Rails
|
|
|
5
5
|
class Elasticsearch
|
|
6
6
|
INDEX = "rails_contact_contacts".freeze
|
|
7
7
|
|
|
8
|
+
class << self
|
|
9
|
+
def default_client
|
|
10
|
+
@default_client ||= ::Elasticsearch::Client.new(url: Rails::Contact.configuration.elasticsearch_url)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def default_client=(client)
|
|
14
|
+
@default_client = client
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def clear_default_client!
|
|
18
|
+
@default_client = nil
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
8
22
|
def initialize(client: nil)
|
|
9
|
-
@client = client ||
|
|
23
|
+
@client = client || self.class.default_client
|
|
10
24
|
end
|
|
11
25
|
|
|
12
|
-
def search(query, filters)
|
|
13
|
-
response = @client.search(index: INDEX, body: search_body(query, filters))
|
|
14
|
-
|
|
26
|
+
def search(query, filters, page:, per_page:)
|
|
27
|
+
response = @client.search(index: INDEX, body: search_body(query, filters, page: page, per_page: per_page))
|
|
28
|
+
hits = response.fetch("hits", {})
|
|
29
|
+
ids = hits.fetch("hits", []).map { |doc| doc["_id"] }
|
|
30
|
+
total_count = extract_total_hits(hits["total"])
|
|
15
31
|
records_by_id = Contact.where(id: ids).index_by { |record| record.id.to_s }
|
|
16
|
-
ids.filter_map { |id| records_by_id[id.to_s] }
|
|
32
|
+
records = ids.filter_map { |id| records_by_id[id.to_s] }
|
|
33
|
+
Search::Result.new(records: records, total_count: total_count, page: page, per_page: per_page)
|
|
17
34
|
rescue StandardError
|
|
18
|
-
Search::Backends::Database.new.search(query, filters)
|
|
35
|
+
Search::Backends::Database.new.search(query, filters, page: page, per_page: per_page)
|
|
19
36
|
end
|
|
20
37
|
|
|
21
38
|
def upsert(contact)
|
|
@@ -67,9 +84,12 @@ module Rails
|
|
|
67
84
|
}
|
|
68
85
|
end
|
|
69
86
|
|
|
70
|
-
def search_body(query, filters)
|
|
87
|
+
def search_body(query, filters, page:, per_page:)
|
|
88
|
+
from = (page - 1) * per_page
|
|
71
89
|
{
|
|
72
|
-
|
|
90
|
+
from: from,
|
|
91
|
+
size: per_page,
|
|
92
|
+
track_total_hits: true,
|
|
73
93
|
sort: [ { updated_at: { order: "desc" } }, { id: { order: "desc" } } ],
|
|
74
94
|
query: {
|
|
75
95
|
bool: {
|
|
@@ -80,6 +100,12 @@ module Rails
|
|
|
80
100
|
}
|
|
81
101
|
end
|
|
82
102
|
|
|
103
|
+
def extract_total_hits(raw)
|
|
104
|
+
return 0 if raw.nil?
|
|
105
|
+
|
|
106
|
+
raw.is_a?(Hash) ? raw.fetch("value", 0).to_i : raw.to_i
|
|
107
|
+
end
|
|
108
|
+
|
|
83
109
|
def search_clause(query)
|
|
84
110
|
return [ { match_all: {} } ] if query.blank?
|
|
85
111
|
|
|
@@ -2,13 +2,17 @@ module Rails
|
|
|
2
2
|
module Contact
|
|
3
3
|
module Search
|
|
4
4
|
class Query
|
|
5
|
-
|
|
5
|
+
MAX_PER_PAGE = 100
|
|
6
|
+
|
|
7
|
+
def initialize(query, filters: {}, page: nil, per_page: nil)
|
|
6
8
|
@query = query
|
|
7
9
|
@filters = filters.to_h.compact_blank
|
|
10
|
+
@page = page.to_i < 1 ? 1 : page.to_i
|
|
11
|
+
@per_page = resolve_per_page(per_page)
|
|
8
12
|
end
|
|
9
13
|
|
|
10
14
|
def call
|
|
11
|
-
backend.search(@query, @filters)
|
|
15
|
+
backend.search(@query, @filters, page: @page, per_page: @per_page)
|
|
12
16
|
end
|
|
13
17
|
|
|
14
18
|
private
|
|
@@ -21,6 +25,12 @@ module Rails
|
|
|
21
25
|
Search::Backends::Database.new
|
|
22
26
|
end
|
|
23
27
|
end
|
|
28
|
+
|
|
29
|
+
def resolve_per_page(per_page)
|
|
30
|
+
p = per_page.to_i
|
|
31
|
+
p = Rails::Contact.configuration.default_per_page if p <= 0
|
|
32
|
+
[ p, MAX_PER_PAGE ].min
|
|
33
|
+
end
|
|
24
34
|
end
|
|
25
35
|
end
|
|
26
36
|
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
module Rails
|
|
2
|
+
module Contact
|
|
3
|
+
module Search
|
|
4
|
+
Result = Struct.new(:records, :total_count, :page, :per_page, keyword_init: true) do
|
|
5
|
+
def total_pages
|
|
6
|
+
return 0 if total_count.zero? || per_page.zero?
|
|
7
|
+
|
|
8
|
+
(total_count.to_f / per_page).ceil
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
data/lib/rails/contact.rb
CHANGED
|
@@ -4,10 +4,10 @@ require "rails/contact/engine"
|
|
|
4
4
|
require "rails/contact/configuration"
|
|
5
5
|
require "rails/contact/orm"
|
|
6
6
|
require "rails/contact/routing"
|
|
7
|
+
require "rails/contact/search/result"
|
|
7
8
|
require "rails/contact/search/query"
|
|
8
9
|
require "rails/contact/search/backends/database"
|
|
9
10
|
require "rails/contact/search/backends/elasticsearch"
|
|
10
|
-
require "rails/contact/csv/import_service"
|
|
11
11
|
require "rails/contact/google/client"
|
|
12
12
|
require "rails/contact/google/token_store"
|
|
13
13
|
require "rails/contact/google/payload_mapper"
|
|
@@ -20,12 +20,4 @@ namespace :rails_contact do
|
|
|
20
20
|
puts "Google sync completed"
|
|
21
21
|
end
|
|
22
22
|
|
|
23
|
-
desc "Import contacts from CSV path (CSV_PATH=/path/file.csv)"
|
|
24
|
-
task import_csv: :environment do
|
|
25
|
-
path = ENV["CSV_PATH"]
|
|
26
|
-
raise "Set CSV_PATH env var" if path.blank?
|
|
27
|
-
|
|
28
|
-
count = Rails::Contact::Csv::ImportService.new(path: path).import!
|
|
29
|
-
puts "Imported #{count} rows"
|
|
30
|
-
end
|
|
31
23
|
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.
|
|
4
|
+
version: 0.1.8
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Kshitiz Sinha
|
|
@@ -93,8 +93,8 @@ dependencies:
|
|
|
93
93
|
- - ">="
|
|
94
94
|
- !ruby/object:Gem::Version
|
|
95
95
|
version: '8.13'
|
|
96
|
-
description: Mountable Rails engine offering contact CRUD,
|
|
97
|
-
|
|
96
|
+
description: Mountable Rails engine offering contact CRUD, Elasticsearch-backed search,
|
|
97
|
+
and optional capped Google Contacts two-way sync.
|
|
98
98
|
email:
|
|
99
99
|
- kshtzkr@gmail.com
|
|
100
100
|
executables: []
|
|
@@ -119,6 +119,7 @@ files:
|
|
|
119
119
|
- app/helpers/rails/contact/application_helper.rb
|
|
120
120
|
- app/jobs/rails/contact/application_job.rb
|
|
121
121
|
- app/jobs/rails/contact/google_sync_job.rb
|
|
122
|
+
- app/jobs/rails/contact/google_sync_unsynced_job.rb
|
|
122
123
|
- app/jobs/rails/contact/index_contact_job.rb
|
|
123
124
|
- app/mailers/rails/contact/application_mailer.rb
|
|
124
125
|
- app/mailers/rails/contact/contact_mailer.rb
|
|
@@ -137,6 +138,7 @@ files:
|
|
|
137
138
|
- app/views/rails/contact/_email_fields.html.erb
|
|
138
139
|
- app/views/rails/contact/_event_fields.html.erb
|
|
139
140
|
- app/views/rails/contact/_form.html.erb
|
|
141
|
+
- app/views/rails/contact/_index_pagination.html.erb
|
|
140
142
|
- app/views/rails/contact/_nested_fields_script.html.erb
|
|
141
143
|
- app/views/rails/contact/_phone_fields.html.erb
|
|
142
144
|
- app/views/rails/contact/_stylesheet.html.erb
|
|
@@ -169,7 +171,6 @@ files:
|
|
|
169
171
|
- lib/generators/rails_contact/rails_contact_generator.rb
|
|
170
172
|
- lib/rails/contact.rb
|
|
171
173
|
- lib/rails/contact/configuration.rb
|
|
172
|
-
- lib/rails/contact/csv/import_service.rb
|
|
173
174
|
- lib/rails/contact/engine.rb
|
|
174
175
|
- lib/rails/contact/google/client.rb
|
|
175
176
|
- lib/rails/contact/google/conflict_resolver.rb
|
|
@@ -182,6 +183,7 @@ files:
|
|
|
182
183
|
- lib/rails/contact/search/backends/database.rb
|
|
183
184
|
- lib/rails/contact/search/backends/elasticsearch.rb
|
|
184
185
|
- lib/rails/contact/search/query.rb
|
|
186
|
+
- lib/rails/contact/search/result.rb
|
|
185
187
|
- lib/rails/contact/version.rb
|
|
186
188
|
- lib/tasks/rails/contact_tasks.rake
|
|
187
189
|
- spec/concerns/filterable_spec.rb
|
|
@@ -1,87 +0,0 @@
|
|
|
1
|
-
require "csv"
|
|
2
|
-
|
|
3
|
-
module Rails
|
|
4
|
-
module Contact
|
|
5
|
-
module Csv
|
|
6
|
-
class ImportService
|
|
7
|
-
HEADER_MAP = {
|
|
8
|
-
"Enquirer First Name" => :given_name,
|
|
9
|
-
"Enquirer Last Name" => :family_name,
|
|
10
|
-
"Current City" => :current_city,
|
|
11
|
-
"Departure City" => :departure_city,
|
|
12
|
-
"Region Name" => :region_name
|
|
13
|
-
}.freeze
|
|
14
|
-
|
|
15
|
-
def initialize(path:, dedupe_key: :email)
|
|
16
|
-
@path = path
|
|
17
|
-
@dedupe_key = dedupe_key
|
|
18
|
-
end
|
|
19
|
-
|
|
20
|
-
def import!
|
|
21
|
-
imported = 0
|
|
22
|
-
CSV.foreach(@path, headers: true) do |row|
|
|
23
|
-
import_row(row.to_h)
|
|
24
|
-
imported += 1
|
|
25
|
-
end
|
|
26
|
-
imported
|
|
27
|
-
end
|
|
28
|
-
|
|
29
|
-
private
|
|
30
|
-
|
|
31
|
-
def import_row(row)
|
|
32
|
-
attrs = build_attributes(row)
|
|
33
|
-
contact = find_or_initialize(row)
|
|
34
|
-
contact.assign_attributes(attrs)
|
|
35
|
-
contact.save!
|
|
36
|
-
upsert_associations(contact, row)
|
|
37
|
-
Rails::Contact::IndexContactJob.perform_later(contact.id)
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
def build_attributes(row)
|
|
41
|
-
HEADER_MAP.each_with_object({}) do |(csv_key, attr), memo|
|
|
42
|
-
value = row[csv_key].to_s.strip
|
|
43
|
-
memo[attr] = (value == "blank" ? nil : value.presence)
|
|
44
|
-
end
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
def find_or_initialize(row)
|
|
48
|
-
email = normalize_email(row["Enquirer Email"])
|
|
49
|
-
return Rails::Contact::Contact.new if email.blank?
|
|
50
|
-
return Rails::Contact::Contact.new if @dedupe_key != :email
|
|
51
|
-
|
|
52
|
-
Rails::Contact::Contact.joins(:emails).where(rails_contact_contact_emails: { value: email }).first || Rails::Contact::Contact.new
|
|
53
|
-
end
|
|
54
|
-
|
|
55
|
-
def upsert_associations(contact, row)
|
|
56
|
-
email = normalize_email(row["Enquirer Email"])
|
|
57
|
-
primary_phone = normalize_phone(row["Enquirer Phone Country Code"], row["Enquirer Phone"])
|
|
58
|
-
alt_phone = normalize_phone(row["Alternate Wa Phone Country Code"], row["Alternate Wa Phone"])
|
|
59
|
-
|
|
60
|
-
contact.emails.find_or_create_by!(value: email, primary: true, label: "work") if email.present?
|
|
61
|
-
if primary_phone.present?
|
|
62
|
-
contact.phones.find_or_create_by!(value: primary_phone, e164: primary_phone, primary: true, label: "mobile")
|
|
63
|
-
end
|
|
64
|
-
if alt_phone.present?
|
|
65
|
-
contact.phones.find_or_create_by!(value: alt_phone, e164: alt_phone, primary: false, label: "whatsapp")
|
|
66
|
-
end
|
|
67
|
-
|
|
68
|
-
formatted_value = "Current city: #{contact.current_city}\nDeparture city: #{contact.departure_city}"
|
|
69
|
-
contact.addresses.find_or_create_by!(city: contact.current_city, departure_city: contact.departure_city, formatted_value: formatted_value)
|
|
70
|
-
end
|
|
71
|
-
|
|
72
|
-
def normalize_email(value)
|
|
73
|
-
clean = value.to_s.strip.downcase
|
|
74
|
-
clean.presence
|
|
75
|
-
end
|
|
76
|
-
|
|
77
|
-
def normalize_phone(country_code, number)
|
|
78
|
-
digits = number.to_s.gsub(/\D+/, "")
|
|
79
|
-
return nil if digits.blank?
|
|
80
|
-
prefix = country_code.to_s.strip
|
|
81
|
-
prefix = "+#{prefix}" unless prefix.start_with?("+")
|
|
82
|
-
"#{prefix}#{digits}"
|
|
83
|
-
end
|
|
84
|
-
end
|
|
85
|
-
end
|
|
86
|
-
end
|
|
87
|
-
end
|