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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f08d1e4fe3f704acfbf960d33948277264f3ebb56ae72999d02a281526eba72b
4
- data.tar.gz: 746e6e1bc998fc23ef6407eda47f04483c227c8df8ffa7f96fab4fc24d53f755
3
+ metadata.gz: 11aea6cb5a1f51337bf1c48e9cb0e3d34ada33d16af8fc5a8476a687d78eb5ba
4
+ data.tar.gz: 63ce98be235f82c0dfe65a22abe308566d44a72e423e0a2351d8e8d04a5ef0f4
5
5
  SHA512:
6
- metadata.gz: 34ad0a97ca7e0eeb7b566e278db0d83b2102fb6e6c3fc67fe3880a33c6fc0cea060c6282f4614834980ff7ed04d4ccec00c8863f09f6c01a92e01b82bc96e05a
7
- data.tar.gz: 3a8f05a97f69d001580847192a4eebe98284b997cccb230c21e97556d4da504ac4b682e12a8c616cc43d0734e73ef851f450fc3e9736f9bf148979bc4ed626cd
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
- - CSV import and Google sync scaffolding
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
- @contacts = Search::Query.new(@query, filters: normalized_filters(filter_params)).call
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,13 @@
1
+ module Rails
2
+ module Contact
3
+ class GoogleSyncUnsyncedJob < ApplicationJob
4
+ queue_as :default
5
+
6
+ def perform
7
+ return unless Rails::Contact.configuration.google_sync_enabled
8
+
9
+ Google::SyncService.new.sync_unsynced!
10
+ end
11
+ end
12
+ end
13
+ 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"
@@ -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 | Same | P0 | Mapping profile handles eq.csv fields |
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
@@ -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
- patch("/#{resource_name}:updateContact", payload)
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: @contact.family_name } ],
12
- emailAddresses: @contact.emails.map { |email| { value: email.value, type: (email.label || "other") } },
13
- phoneNumbers: @contact.phones.map { |phone| { value: phone.e164.presence || phone.value, type: (phone.label || "mobile") } },
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: [ @contact.biography, @contact.region_name ].compact.join("\n") } ]
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
- @client.update_contact(contact.google_resource_name, payload)
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
- return scope.limit(limit) if query.blank?
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.limit(limit)
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 || ::Elasticsearch::Client.new(url: Rails::Contact.configuration.elasticsearch_url)
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
- ids = response.fetch("hits", {}).fetch("hits", []).map { |doc| doc["_id"] }
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
- size: Rails::Contact.configuration.default_per_page,
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
- def initialize(query, filters: {})
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
@@ -1,5 +1,5 @@
1
1
  module Rails
2
2
  module Contact
3
- VERSION = "0.1.5"
3
+ VERSION = "0.1.8"
4
4
  end
5
5
  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.5
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, CSV import, Elasticsearch-backed
97
- search, and optional capped Google Contacts two-way sync.
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