rails-contact 0.1.0
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 +7 -0
- data/.github/dependabot.yml +12 -0
- data/.github/workflows/ci.yml +65 -0
- data/CHANGELOG.md +10 -0
- data/Gemfile +19 -0
- data/LICENSE.txt +21 -0
- data/MIT-LICENSE +21 -0
- data/README.md +71 -0
- data/Rakefile +3 -0
- data/app/assets/stylesheets/rails/contact/application.css +15 -0
- data/app/controllers/rails/contact/application_controller.rb +13 -0
- data/app/controllers/rails/contact/contacts_controller.rb +77 -0
- data/app/helpers/rails/contact/application_helper.rb +6 -0
- data/app/jobs/rails/contact/application_job.rb +6 -0
- data/app/jobs/rails/contact/google_sync_job.rb +13 -0
- data/app/jobs/rails/contact/index_contact_job.rb +18 -0
- data/app/mailers/rails/contact/application_mailer.rb +8 -0
- data/app/mailers/rails/contact/contact_mailer.rb +32 -0
- data/app/models/rails/contact/application_record.rb +7 -0
- data/app/models/rails/contact/contact.rb +32 -0
- data/app/models/rails/contact/contact_address.rb +9 -0
- data/app/models/rails/contact/contact_email.rb +24 -0
- data/app/models/rails/contact/contact_phone.rb +28 -0
- data/app/views/layouts/rails/contact/application.html.erb +17 -0
- data/app/views/rails/contact/contacts/_form.html.erb +61 -0
- data/app/views/rails/contact/contacts/edit.html.erb +3 -0
- data/app/views/rails/contact/contacts/index.html.erb +38 -0
- data/app/views/rails/contact/contacts/new.html.erb +3 -0
- data/app/views/rails/contact/contacts/show.html.erb +13 -0
- data/bin/rails +25 -0
- data/bin/rubocop +8 -0
- data/config/routes.rb +4 -0
- data/lib/generators/rails/contact/contact_generator.rb +29 -0
- data/lib/generators/rails/contact/install/install_generator.rb +19 -0
- data/lib/generators/rails/contact/install/templates/rails_contact.rb.tt +16 -0
- data/lib/generators/rails/contact/templates/create_rails_contact_contact_addresses.rb.tt +13 -0
- data/lib/generators/rails/contact/templates/create_rails_contact_contact_emails.rb.tt +15 -0
- data/lib/generators/rails/contact/templates/create_rails_contact_contact_phones.rb.tt +16 -0
- data/lib/generators/rails/contact/templates/create_rails_contact_contacts.rb.tt +24 -0
- data/lib/generators/rails/contact/views_generator.rb +15 -0
- data/lib/generators/rails_contact/rails_contact_generator.rb +7 -0
- data/lib/rails/contact/configuration.rb +25 -0
- data/lib/rails/contact/csv/import_service.rb +87 -0
- data/lib/rails/contact/engine.rb +21 -0
- data/lib/rails/contact/google/client.rb +55 -0
- data/lib/rails/contact/google/conflict_resolver.rb +20 -0
- data/lib/rails/contact/google/payload_mapper.rb +26 -0
- data/lib/rails/contact/google/sync_service.rb +34 -0
- data/lib/rails/contact/google/token_store.rb +31 -0
- data/lib/rails/contact/orm.rb +19 -0
- data/lib/rails/contact/routing.rb +11 -0
- data/lib/rails/contact/search/backends/database.rb +41 -0
- data/lib/rails/contact/search/backends/elasticsearch.rb +119 -0
- data/lib/rails/contact/search/query.rb +27 -0
- data/lib/rails/contact/version.rb +5 -0
- data/lib/rails/contact.rb +32 -0
- data/lib/tasks/rails/contact_tasks.rake +31 -0
- data/test/csv_import_service_test.rb +25 -0
- data/test/google_sync_service_test.rb +47 -0
- data/test/payload_mapper_test.rb +19 -0
- data/test/search_fallback_test.rb +25 -0
- data/test/test_helper.rb +79 -0
- metadata +192 -0
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<h1><%= @contact.full_name %></h1>
|
|
2
|
+
<p>Email: <%= @contact.primary_email&.value %></p>
|
|
3
|
+
<p>Phone: <%= @contact.primary_phone&.e164 %></p>
|
|
4
|
+
<p>Current city: <%= @contact.current_city %></p>
|
|
5
|
+
<p>Departure city: <%= @contact.departure_city %></p>
|
|
6
|
+
<p>Region: <%= @contact.region_name %></p>
|
|
7
|
+
<p>Biography: <%= @contact.biography %></p>
|
|
8
|
+
|
|
9
|
+
<p>
|
|
10
|
+
<%= link_to "Edit", edit_contact_path(@contact) %> |
|
|
11
|
+
<%= link_to "Delete", contact_path(@contact), data: {turbo_method: :delete, turbo_confirm: "Delete contact?"} %> |
|
|
12
|
+
<%= link_to "Back", contacts_path %>
|
|
13
|
+
</p>
|
data/bin/rails
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# This command will automatically be run when you run "rails" with Rails gems
|
|
3
|
+
# installed from the root of your application.
|
|
4
|
+
|
|
5
|
+
ENGINE_ROOT = File.expand_path("..", __dir__)
|
|
6
|
+
ENGINE_PATH = File.expand_path("../lib/rails/contact/engine", __dir__)
|
|
7
|
+
|
|
8
|
+
# Set up gems listed in the Gemfile.
|
|
9
|
+
ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__)
|
|
10
|
+
require "bundler/setup" if File.exist?(ENV["BUNDLE_GEMFILE"])
|
|
11
|
+
|
|
12
|
+
require "rails"
|
|
13
|
+
# Pick the frameworks you want:
|
|
14
|
+
require "active_model/railtie"
|
|
15
|
+
require "active_job/railtie"
|
|
16
|
+
require "active_record/railtie"
|
|
17
|
+
# require "active_storage/engine"
|
|
18
|
+
require "action_controller/railtie"
|
|
19
|
+
require "action_mailer/railtie"
|
|
20
|
+
# require "action_mailbox/engine"
|
|
21
|
+
# require "action_text/engine"
|
|
22
|
+
require "action_view/railtie"
|
|
23
|
+
require "action_cable/engine"
|
|
24
|
+
# require "rails/test_unit/railtie"
|
|
25
|
+
require "rails/engine/commands"
|
data/bin/rubocop
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
require "rubygems"
|
|
3
|
+
require "bundler/setup"
|
|
4
|
+
|
|
5
|
+
# explicit rubocop config increases performance slightly while avoiding config confusion.
|
|
6
|
+
ARGV.unshift("--config", File.expand_path("../.rubocop.yml", __dir__))
|
|
7
|
+
|
|
8
|
+
load Gem.bin_path("rubocop", "rubocop")
|
data/config/routes.rb
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
require "rails/generators"
|
|
2
|
+
require "rails/generators/active_record"
|
|
3
|
+
|
|
4
|
+
module Rails
|
|
5
|
+
module Contact
|
|
6
|
+
module Generators
|
|
7
|
+
class ContactGenerator < ::Rails::Generators::NamedBase
|
|
8
|
+
include ::Rails::Generators::Migration
|
|
9
|
+
|
|
10
|
+
source_root File.expand_path("templates", __dir__)
|
|
11
|
+
|
|
12
|
+
def self.next_migration_number(dirname)
|
|
13
|
+
ActiveRecord::Generators::Base.next_migration_number(dirname)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def create_initializer
|
|
17
|
+
invoke "rails:contact:install"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def copy_migrations
|
|
21
|
+
migration_template "create_rails_contact_contacts.rb.tt", "db/migrate/create_rails_contact_contacts.rb"
|
|
22
|
+
migration_template "create_rails_contact_contact_emails.rb.tt", "db/migrate/create_rails_contact_contact_emails.rb"
|
|
23
|
+
migration_template "create_rails_contact_contact_phones.rb.tt", "db/migrate/create_rails_contact_contact_phones.rb"
|
|
24
|
+
migration_template "create_rails_contact_contact_addresses.rb.tt", "db/migrate/create_rails_contact_contact_addresses.rb"
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
require "rails/generators"
|
|
2
|
+
|
|
3
|
+
module Rails
|
|
4
|
+
module Contact
|
|
5
|
+
module Generators
|
|
6
|
+
class InstallGenerator < ::Rails::Generators::Base
|
|
7
|
+
source_root File.expand_path("templates", __dir__)
|
|
8
|
+
|
|
9
|
+
def create_initializer
|
|
10
|
+
template "rails_contact.rb.tt", "config/initializers/rails_contact.rb"
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def mount_engine_route
|
|
14
|
+
route "mount Rails::Contact::Engine => '/contacts', as: 'rails_contact'"
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
Rails::Contact.configure do |config|
|
|
2
|
+
config.contact_class_name = "Rails::Contact::Contact"
|
|
3
|
+
config.search_backend = :elasticsearch
|
|
4
|
+
config.elasticsearch_url = ENV.fetch("ELASTICSEARCH_URL", "http://127.0.0.1:9200")
|
|
5
|
+
|
|
6
|
+
config.google_sync_enabled = ENV.fetch("RAILS_CONTACT_GOOGLE_SYNC_ENABLED", "false") == "true"
|
|
7
|
+
config.google_max_contacts = 25_000
|
|
8
|
+
config.rolling_window_sort = :updated_at
|
|
9
|
+
|
|
10
|
+
config.google_client_id = ENV["GOOGLE_CLIENT_ID"]
|
|
11
|
+
config.google_client_secret = ENV["GOOGLE_CLIENT_SECRET"]
|
|
12
|
+
config.google_redirect_uri = ENV["GOOGLE_REDIRECT_URI"]
|
|
13
|
+
config.google_token_path = ENV.fetch("RAILS_CONTACT_GOOGLE_TOKEN_PATH", "tmp/rails_contact_google_token.json")
|
|
14
|
+
|
|
15
|
+
config.default_per_page = 25
|
|
16
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
class CreateRailsContactContactAddresses < ActiveRecord::Migration[7.1]
|
|
2
|
+
def change
|
|
3
|
+
create_table :rails_contact_contact_addresses do |t|
|
|
4
|
+
t.references :contact, null: false, foreign_key: {to_table: :rails_contact_contacts}
|
|
5
|
+
t.string :city
|
|
6
|
+
t.string :departure_city
|
|
7
|
+
t.text :formatted_value
|
|
8
|
+
t.string :label
|
|
9
|
+
|
|
10
|
+
t.timestamps
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
class CreateRailsContactContactEmails < ActiveRecord::Migration[7.1]
|
|
2
|
+
def change
|
|
3
|
+
create_table :rails_contact_contact_emails do |t|
|
|
4
|
+
t.references :contact, null: false, foreign_key: {to_table: :rails_contact_contacts}
|
|
5
|
+
t.string :value, null: false
|
|
6
|
+
t.string :label
|
|
7
|
+
t.boolean :primary, null: false, default: false
|
|
8
|
+
|
|
9
|
+
t.timestamps
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
add_index :rails_contact_contact_emails, :value
|
|
13
|
+
add_index :rails_contact_contact_emails, [:contact_id, :primary]
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
class CreateRailsContactContactPhones < ActiveRecord::Migration[7.1]
|
|
2
|
+
def change
|
|
3
|
+
create_table :rails_contact_contact_phones do |t|
|
|
4
|
+
t.references :contact, null: false, foreign_key: {to_table: :rails_contact_contacts}
|
|
5
|
+
t.string :value, null: false
|
|
6
|
+
t.string :e164
|
|
7
|
+
t.string :label
|
|
8
|
+
t.boolean :primary, null: false, default: false
|
|
9
|
+
|
|
10
|
+
t.timestamps
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
add_index :rails_contact_contact_phones, :e164
|
|
14
|
+
add_index :rails_contact_contact_phones, [:contact_id, :primary]
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
class CreateRailsContactContacts < ActiveRecord::Migration[7.1]
|
|
2
|
+
def change
|
|
3
|
+
create_table :rails_contact_contacts do |t|
|
|
4
|
+
t.string :given_name, null: false
|
|
5
|
+
t.string :family_name
|
|
6
|
+
t.string :region_name
|
|
7
|
+
t.string :departure_city
|
|
8
|
+
t.string :current_city
|
|
9
|
+
t.string :google_resource_name
|
|
10
|
+
t.string :google_etag
|
|
11
|
+
t.datetime :google_last_modified_at
|
|
12
|
+
t.datetime :last_synced_at
|
|
13
|
+
t.boolean :sync_eligible, null: false, default: true
|
|
14
|
+
t.text :biography
|
|
15
|
+
t.jsonb :metadata, null: false, default: {}
|
|
16
|
+
|
|
17
|
+
t.timestamps
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
add_index :rails_contact_contacts, :updated_at
|
|
21
|
+
add_index :rails_contact_contacts, :sync_eligible
|
|
22
|
+
add_index :rails_contact_contacts, :google_resource_name, unique: true
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
require "rails/generators/base"
|
|
2
|
+
|
|
3
|
+
module Rails
|
|
4
|
+
module Contact
|
|
5
|
+
module Generators
|
|
6
|
+
class ViewsGenerator < ::Rails::Generators::Base
|
|
7
|
+
source_root File.expand_path("../../../../app/views", __dir__)
|
|
8
|
+
|
|
9
|
+
def copy_views
|
|
10
|
+
directory "rails/contact/contacts", "app/views/rails_contact/contacts"
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
module Rails
|
|
2
|
+
module Contact
|
|
3
|
+
class Configuration
|
|
4
|
+
attr_accessor :contact_class_name, :elasticsearch_url, :search_backend,
|
|
5
|
+
:google_sync_enabled, :google_max_contacts, :rolling_window_sort,
|
|
6
|
+
:google_client_id, :google_client_secret, :google_redirect_uri,
|
|
7
|
+
:google_token_path, :reset_index_on_boot, :default_per_page
|
|
8
|
+
|
|
9
|
+
def initialize
|
|
10
|
+
@contact_class_name = "Rails::Contact::Contact"
|
|
11
|
+
@elasticsearch_url = ENV.fetch("ELASTICSEARCH_URL", "http://127.0.0.1:9200")
|
|
12
|
+
@search_backend = :elasticsearch
|
|
13
|
+
@google_sync_enabled = false
|
|
14
|
+
@google_max_contacts = 25_000
|
|
15
|
+
@rolling_window_sort = :updated_at
|
|
16
|
+
@google_client_id = ENV["GOOGLE_CLIENT_ID"]
|
|
17
|
+
@google_client_secret = ENV["GOOGLE_CLIENT_SECRET"]
|
|
18
|
+
@google_redirect_uri = ENV["GOOGLE_REDIRECT_URI"]
|
|
19
|
+
@google_token_path = ENV.fetch("RAILS_CONTACT_GOOGLE_TOKEN_PATH", "tmp/rails_contact_google_token.json")
|
|
20
|
+
@reset_index_on_boot = false
|
|
21
|
+
@default_per_page = 25
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
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
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
module Rails
|
|
2
|
+
module Contact
|
|
3
|
+
class Engine < ::Rails::Engine
|
|
4
|
+
isolate_namespace Rails::Contact
|
|
5
|
+
|
|
6
|
+
config.generators.test_framework :minitest
|
|
7
|
+
|
|
8
|
+
initializer "rails_contact.configure_defaults" do
|
|
9
|
+
Rails::Contact.configuration
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
initializer "rails_contact.filter_sensitive_params" do |app|
|
|
13
|
+
app.config.filter_parameters += %i[google_access_token google_refresh_token authorization]
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
rake_tasks do
|
|
17
|
+
load File.expand_path("../../tasks/rails/contact_tasks.rake", __dir__)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
module Rails
|
|
4
|
+
module Contact
|
|
5
|
+
module Google
|
|
6
|
+
class Client
|
|
7
|
+
PEOPLE_API_BASE = "https://people.googleapis.com/v1".freeze
|
|
8
|
+
|
|
9
|
+
def initialize(access_token:)
|
|
10
|
+
@connection = Faraday.new(url: PEOPLE_API_BASE) do |faraday|
|
|
11
|
+
faraday.request :retry, max: 3, interval: 0.5, backoff_factor: 2
|
|
12
|
+
faraday.response :raise_error
|
|
13
|
+
end
|
|
14
|
+
@access_token = access_token
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def create_contact(payload)
|
|
18
|
+
post("/people:createContact", payload)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def update_contact(resource_name, payload)
|
|
22
|
+
patch("/#{resource_name}:updateContact", payload)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def delete_contact(resource_name)
|
|
26
|
+
@connection.delete("/#{resource_name}") { |request| request.headers = headers }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def post(path, payload)
|
|
32
|
+
parse(@connection.post(path) do |request|
|
|
33
|
+
request.headers = headers
|
|
34
|
+
request.body = payload.to_json
|
|
35
|
+
end)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def patch(path, payload)
|
|
39
|
+
parse(@connection.patch(path) do |request|
|
|
40
|
+
request.headers = headers
|
|
41
|
+
request.body = payload.to_json
|
|
42
|
+
end)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def parse(response)
|
|
46
|
+
JSON.parse(response.body)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def headers
|
|
50
|
+
{ "Authorization" => "Bearer #{@access_token}", "Content-Type" => "application/json" }
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
module Rails
|
|
2
|
+
module Contact
|
|
3
|
+
module Google
|
|
4
|
+
class ConflictResolver
|
|
5
|
+
def initialize(local_contact, remote_modified_at:)
|
|
6
|
+
@local_contact = local_contact
|
|
7
|
+
@remote_modified_at = remote_modified_at
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
# v1 policy: last-write-wins by modified timestamp.
|
|
11
|
+
def prefer_remote?
|
|
12
|
+
return false if @remote_modified_at.blank?
|
|
13
|
+
return true if @local_contact.updated_at.blank?
|
|
14
|
+
|
|
15
|
+
@remote_modified_at > @local_contact.updated_at
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
module Rails
|
|
2
|
+
module Contact
|
|
3
|
+
module Google
|
|
4
|
+
class PayloadMapper
|
|
5
|
+
def initialize(contact)
|
|
6
|
+
@contact = contact
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def to_people_payload
|
|
10
|
+
{
|
|
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") } },
|
|
14
|
+
addresses: @contact.addresses.map do |address|
|
|
15
|
+
{
|
|
16
|
+
city: address.city,
|
|
17
|
+
formattedValue: address.formatted_value.presence || "Current city: #{@contact.current_city}\nDeparture city: #{@contact.departure_city}"
|
|
18
|
+
}
|
|
19
|
+
end,
|
|
20
|
+
biographies: [ { value: [ @contact.biography, @contact.region_name ].compact.join("\n") } ]
|
|
21
|
+
}.compact
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
module Rails
|
|
2
|
+
module Contact
|
|
3
|
+
module Google
|
|
4
|
+
class SyncService
|
|
5
|
+
def initialize(token_store: TokenStore.new, client: nil)
|
|
6
|
+
@token_store = token_store
|
|
7
|
+
@client = client || Client.new(access_token: @token_store.access_token)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def sync!
|
|
11
|
+
Rails::Contact::Contact.sync_window.each do |contact|
|
|
12
|
+
sync_contact(contact)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def sync_contact(contact)
|
|
17
|
+
payload = PayloadMapper.new(contact).to_people_payload
|
|
18
|
+
response = if contact.google_resource_name.present?
|
|
19
|
+
@client.update_contact(contact.google_resource_name, payload)
|
|
20
|
+
else
|
|
21
|
+
@client.create_contact(payload)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
contact.update!(
|
|
25
|
+
google_resource_name: response["resourceName"] || contact.google_resource_name,
|
|
26
|
+
google_etag: response["etag"] || contact.google_etag,
|
|
27
|
+
google_last_modified_at: Time.current,
|
|
28
|
+
last_synced_at: Time.current
|
|
29
|
+
)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "fileutils"
|
|
3
|
+
|
|
4
|
+
module Rails
|
|
5
|
+
module Contact
|
|
6
|
+
module Google
|
|
7
|
+
class TokenStore
|
|
8
|
+
def initialize(path: Rails::Contact.configuration.google_token_path)
|
|
9
|
+
@path = path
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def access_token
|
|
13
|
+
data.fetch("access_token")
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def write!(payload)
|
|
17
|
+
FileUtils.mkdir_p(File.dirname(@path))
|
|
18
|
+
File.write(@path, payload.to_json)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def data
|
|
24
|
+
@data ||= JSON.parse(File.read(@path))
|
|
25
|
+
rescue Errno::ENOENT
|
|
26
|
+
raise Rails::Contact::Error, "Google token file missing at #{@path}"
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
module Rails
|
|
2
|
+
module Contact
|
|
3
|
+
module ORM
|
|
4
|
+
module_function
|
|
5
|
+
|
|
6
|
+
def adapter
|
|
7
|
+
ENV.fetch("DEVISE_ORM", "active_record")
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def active_record?
|
|
11
|
+
adapter == "active_record"
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def mongoid?
|
|
15
|
+
adapter == "mongoid"
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
module Rails
|
|
2
|
+
module Contact
|
|
3
|
+
module Search
|
|
4
|
+
module Backends
|
|
5
|
+
class Database
|
|
6
|
+
def search(query, filters)
|
|
7
|
+
scope = Contact.includes(:emails, :phones).recent_first
|
|
8
|
+
scope = apply_filters(scope, filters)
|
|
9
|
+
return scope.limit(limit) if query.blank?
|
|
10
|
+
|
|
11
|
+
wildcard = "%#{query.downcase}%"
|
|
12
|
+
scope.left_joins(:emails, :phones).where(
|
|
13
|
+
"LOWER(rails_contact_contacts.given_name) LIKE :q OR "\
|
|
14
|
+
"LOWER(rails_contact_contacts.family_name) LIKE :q OR "\
|
|
15
|
+
"LOWER(rails_contact_contact_emails.value) LIKE :q OR "\
|
|
16
|
+
"rails_contact_contact_phones.e164 LIKE :raw",
|
|
17
|
+
q: wildcard,
|
|
18
|
+
raw: "%#{query}%"
|
|
19
|
+
).distinct.limit(limit)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def apply_filters(scope, filters)
|
|
25
|
+
scoped = scope
|
|
26
|
+
scoped = scoped.where(current_city: filters["city"]) if filters["city"].present?
|
|
27
|
+
scoped = scoped.where(region_name: filters["region"]) if filters["region"].present?
|
|
28
|
+
if filters["sync_eligible"].present?
|
|
29
|
+
scoped = scoped.where(sync_eligible: ActiveModel::Type::Boolean.new.cast(filters["sync_eligible"]))
|
|
30
|
+
end
|
|
31
|
+
scoped
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def limit
|
|
35
|
+
Rails::Contact.configuration.default_per_page
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|