rails-contact 0.1.13 → 0.1.16
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 +26 -0
- data/README.md +353 -105
- data/app/controllers/rails/contact/contacts_controller.rb +41 -1
- data/docs/CONFIGURATION.md +91 -0
- data/lib/rails/contact/configuration.rb +35 -1
- data/lib/rails/contact/search/backends/database.rb +147 -1
- data/lib/rails/contact/search/backends/elasticsearch.rb +8 -2
- data/lib/rails/contact/version.rb +1 -1
- data/lib/tasks/rails/contact_tasks.rake +0 -1
- data/spec/controllers/contacts_controller_spec.rb +59 -2
- data/spec/search/database_backend_spec.rb +49 -0
- data/spec/search/elasticsearch_backend_spec.rb +33 -0
- data/spec/search/metadata_filters_spec.rb +140 -0
- metadata +5 -2
- data/LICENSE.txt +0 -21
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9a7f0cfd6a248a8763b84379dc244b151d7edc7328527586f0416d378f73e5ee
|
|
4
|
+
data.tar.gz: 6be4ad0ae1d604fdf9ae66c3f8649573c4d2688ce3b93000bac1723fd08e616c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c5b66c49e8c489d4565177a34ae71f210c1ccd8c4c36260aed0ef302e2a00ee766ac0c546f943c0f96bc2a843b3ba9c0a05b92518001487cae5a6f14de058714
|
|
7
|
+
data.tar.gz: e133e1d5768a619f4a33c7a5d5033351358a5a0490d1f387c52730406e1e5b30eead92ed25f48928f01beba7262ab476300dac959571cc5263d1d0ea7e29762f
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,31 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.16
|
|
4
|
+
|
|
5
|
+
- **New metadata filter type `:exclude`** — hides rows whose metadata key equals a configured value, e.g. `{ key: "authenticity", type: :exclude, value: "test", default: :on }`. With `default: :on` the filter applies even when the request param is absent (first page load, bookmarks) and an explicit false-y value (`"0"`) switches it off — built for default-on "hide test data" checkboxes. Rows missing the key always pass, so unclassified legacy data is never hidden.
|
|
6
|
+
|
|
7
|
+
## [Unreleased]
|
|
8
|
+
|
|
9
|
+
### Documentation
|
|
10
|
+
|
|
11
|
+
- Rewrote the README with a badge row, table of contents, requirements, a
|
|
12
|
+
gem-name/require/namespace note, and usage sections for search, Google sync,
|
|
13
|
+
merge, and metadata filters.
|
|
14
|
+
- Added `docs/CONFIGURATION.md` with the full settings reference (type, default,
|
|
15
|
+
environment variable, and behavior for every configuration option).
|
|
16
|
+
- Added `CONTRIBUTING.md`, `CODE_OF_CONDUCT.md`, and `SECURITY.md`.
|
|
17
|
+
- Removed the duplicate `LICENSE.txt` (identical to `MIT-LICENSE`) and dropped it
|
|
18
|
+
from the gemspec file list; removed committed `.gem` build artifacts.
|
|
19
|
+
|
|
20
|
+
## 0.1.15
|
|
21
|
+
|
|
22
|
+
- **Host-configurable metadata filters and sorts.** `config.metadata_filters` declares filters over `Contact#metadata` by param name — `:values` (multi-select with optional whitelist), `:min_integer` / `:min_numeric` (numeric floors that filter out non-numeric stored values instead of casting them), and `:tag` (JSON-array containment checkbox). `config.metadata_sorts` declares `?sort=` options over numeric metadata (descending, non-numeric last, recency tiebreak). `filter_params` permits the configured params automatically, multi-selects get the same blank-strip/scalar-coercion normalization as `region`, and a metadata sort is dropped while a free-text `q` search is active (the search branch runs `SELECT DISTINCT`, which PostgreSQL cannot order by an out-of-select expression). Database backend only. Configured keys must be plain identifiers; anything else raises.
|
|
23
|
+
|
|
24
|
+
## 0.1.14
|
|
25
|
+
|
|
26
|
+
- 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:)`.
|
|
27
|
+
- 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.
|
|
28
|
+
|
|
3
29
|
## 0.1.12
|
|
4
30
|
|
|
5
31
|
- `_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.
|
data/README.md
CHANGED
|
@@ -1,196 +1,444 @@
|
|
|
1
1
|
# rails-contact
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
##
|
|
19
|
-
|
|
20
|
-
|
|
3
|
+
Google-shaped contacts for Rails: contact CRUD, Elasticsearch-backed search, and optional capped Google Contacts sync.
|
|
4
|
+
|
|
5
|
+
[](https://rubygems.org/gems/rails-contact)
|
|
6
|
+
[](https://github.com/kshtzkr/rails-contact/actions/workflows/ci.yml)
|
|
7
|
+
[](MIT-LICENSE)
|
|
8
|
+
[](https://www.ruby-lang.org)
|
|
9
|
+
|
|
10
|
+
`rails-contact` is a mountable Rails engine that adds a contacts section to a host
|
|
11
|
+
application. It models contacts the way Google Contacts does: rich name fields plus
|
|
12
|
+
many emails, phones, addresses, websites, and events per person, with labels and a
|
|
13
|
+
free-form `metadata` JSON column for host-specific data. Search runs through
|
|
14
|
+
Elasticsearch and falls back to a plain SQL query when Elasticsearch is unavailable.
|
|
15
|
+
An optional feature pushes contacts to Google Contacts through the People API, bounded
|
|
16
|
+
by a configurable cap so a large table never syncs unbounded.
|
|
17
|
+
|
|
18
|
+
## Contents
|
|
19
|
+
|
|
20
|
+
- [Requirements](#requirements)
|
|
21
|
+
- [Installation](#installation)
|
|
22
|
+
- [Names and namespaces](#names-and-namespaces)
|
|
23
|
+
- [Quick start](#quick-start)
|
|
24
|
+
- [Usage](#usage)
|
|
25
|
+
- [Contacts and the UI](#contacts-and-the-ui)
|
|
26
|
+
- [Search](#search)
|
|
27
|
+
- [Google Contacts sync](#google-contacts-sync)
|
|
28
|
+
- [Merge and bulk delete](#merge-and-bulk-delete)
|
|
29
|
+
- [Metadata filters and sorts](#metadata-filters-and-sorts)
|
|
30
|
+
- [Host layout and asset injection](#host-layout-and-asset-injection)
|
|
31
|
+
- [Configuration](#configuration)
|
|
32
|
+
- [Generators](#generators)
|
|
33
|
+
- [Rake tasks](#rake-tasks)
|
|
34
|
+
- [Routes](#routes)
|
|
35
|
+
- [Testing](#testing)
|
|
36
|
+
- [Development](#development)
|
|
37
|
+
- [Releasing](#releasing)
|
|
38
|
+
- [Contributing](#contributing)
|
|
39
|
+
- [Security](#security)
|
|
40
|
+
- [Versioning and changelog](#versioning-and-changelog)
|
|
41
|
+
- [Related docs](#related-docs)
|
|
42
|
+
- [License](#license)
|
|
43
|
+
|
|
44
|
+
## Requirements
|
|
45
|
+
|
|
46
|
+
- Ruby >= 3.1
|
|
47
|
+
- Rails >= 7.1 (this is a mountable engine; it runs inside a host Rails app)
|
|
48
|
+
- PostgreSQL for the host database. The database search backend uses PostgreSQL
|
|
49
|
+
JSON operators (`metadata->>'key'`) and a regex cast for numeric metadata; the
|
|
50
|
+
test suite runs on SQLite, which the backend also handles.
|
|
51
|
+
- Elasticsearch 8.13+ if you use the default `:elasticsearch` search backend. It
|
|
52
|
+
is optional: set `search_backend = :database` to skip it, and even with the
|
|
53
|
+
Elasticsearch backend selected, search falls back to SQL when the cluster is
|
|
54
|
+
unreachable.
|
|
55
|
+
- A Google OAuth access token with People API access if you enable Google sync
|
|
56
|
+
(see [Google Contacts sync](#google-contacts-sync)).
|
|
57
|
+
|
|
58
|
+
## Installation
|
|
59
|
+
|
|
60
|
+
Add the gem to the host application's `Gemfile`:
|
|
21
61
|
|
|
22
62
|
```ruby
|
|
23
|
-
gem "rails-contact"
|
|
63
|
+
gem "rails-contact"
|
|
24
64
|
```
|
|
25
65
|
|
|
66
|
+
Install it:
|
|
67
|
+
|
|
26
68
|
```bash
|
|
27
69
|
bundle install
|
|
28
70
|
```
|
|
29
71
|
|
|
30
|
-
|
|
72
|
+
Generate the initializer, migrations, and route mount, then migrate:
|
|
31
73
|
|
|
32
74
|
```bash
|
|
33
|
-
rails generate rails:contact:install
|
|
34
75
|
rails generate rails:contact:contact Contact
|
|
35
76
|
rails db:migrate
|
|
36
77
|
```
|
|
37
78
|
|
|
38
|
-
|
|
79
|
+
`rails:contact:contact` invokes the install generator (which writes
|
|
80
|
+
`config/initializers/rails_contact.rb` and mounts the engine) and copies the eight
|
|
81
|
+
migrations for contacts and their associated tables. See [Generators](#generators)
|
|
82
|
+
for the individual generators if you want finer control.
|
|
83
|
+
|
|
84
|
+
## Names and namespaces
|
|
85
|
+
|
|
86
|
+
The gem name and the code namespace differ, which trips up most first installs:
|
|
87
|
+
|
|
88
|
+
| What | Value |
|
|
89
|
+
|---|---|
|
|
90
|
+
| Gem name (Gemfile, RubyGems) | `rails-contact` |
|
|
91
|
+
| Require path | `rails/contact` (`require "rails/contact"`) |
|
|
92
|
+
| Ruby namespace | `Rails::Contact` |
|
|
93
|
+
| Engine class | `Rails::Contact::Engine` |
|
|
94
|
+
| Generator namespace | `rails:contact:*` |
|
|
95
|
+
|
|
96
|
+
Bundler auto-requires the gem, so a plain `gem "rails-contact"` is enough — you do
|
|
97
|
+
not normally call `require` yourself. If you do, the path is `rails/contact`, not
|
|
98
|
+
`rails-contact` or `rails_contact`. Configuration and models live under the
|
|
99
|
+
`Rails::Contact` namespace.
|
|
100
|
+
|
|
101
|
+
## Quick start
|
|
102
|
+
|
|
103
|
+
After installing (above), the engine is mounted at `/contacts`. Start the app and
|
|
104
|
+
visit:
|
|
105
|
+
|
|
106
|
+
- `/contacts` — searchable, paginated list
|
|
107
|
+
- `/contacts/new` — create form with dynamic add/remove rows
|
|
108
|
+
- `/contacts/:id` — contact detail
|
|
109
|
+
|
|
110
|
+
Create a contact in code:
|
|
39
111
|
|
|
40
112
|
```ruby
|
|
41
|
-
|
|
113
|
+
contact = Rails::Contact::Contact.create!(
|
|
114
|
+
given_name: "Ada",
|
|
115
|
+
family_name: "Lovelace",
|
|
116
|
+
emails_attributes: [{ value: "ada@example.com", label: "work", primary: true }],
|
|
117
|
+
phones_attributes: [{ value: "+1 555 0100", label: "mobile" }]
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
contact.full_name # => "Ada Lovelace"
|
|
121
|
+
contact.primary_email # => the primary ContactEmail
|
|
122
|
+
contact.set_labels_from_csv!("vip, investor")
|
|
42
123
|
```
|
|
43
124
|
|
|
44
|
-
|
|
125
|
+
`given_name` is the only required field. Search the index the UI uses:
|
|
45
126
|
|
|
46
127
|
```ruby
|
|
47
|
-
|
|
128
|
+
result = Rails::Contact::Search::Query.new("ada", page: 1).call
|
|
129
|
+
result.records # => [contact, ...]
|
|
130
|
+
result.total_count # => 1
|
|
131
|
+
result.total_pages # => 1
|
|
48
132
|
```
|
|
49
133
|
|
|
50
|
-
|
|
134
|
+
## Usage
|
|
51
135
|
|
|
52
|
-
###
|
|
136
|
+
### Contacts and the UI
|
|
53
137
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
138
|
+
`Rails::Contact::Contact` has the scalar fields `given_name`, `family_name`,
|
|
139
|
+
`current_city`, `departure_city`, `region_name`, `biography`, `starred`,
|
|
140
|
+
`sync_eligible`, `photo_url`, and a `metadata` JSON column. It has many `emails`,
|
|
141
|
+
`phones`, `addresses`, `websites`, `events`, and `labels`. Nested attributes are
|
|
142
|
+
accepted for the first five, with `allow_destroy: true`, so the generated form can
|
|
143
|
+
add and remove rows client-side.
|
|
57
144
|
|
|
58
|
-
|
|
145
|
+
The mounted controller (`Rails::Contact::ContactsController`) serves the standard
|
|
146
|
+
seven actions plus `bulk_destroy`, `merge`, `google_sync_unsynced`, and
|
|
147
|
+
`google_sync_rolling_window`. Creating, updating, or deleting a contact enqueues
|
|
148
|
+
`Rails::Contact::IndexContactJob` to keep the Elasticsearch index current.
|
|
59
149
|
|
|
60
|
-
|
|
150
|
+
Only a fixed set of `metadata` keys is accepted through the form:
|
|
151
|
+
`prefix`, `middle_name`, `suffix`, `nickname`, `company`, `job_title`, `department`,
|
|
152
|
+
`website`, `birthday`, `notes`. Read them back with `contact.meta(:company)`.
|
|
61
153
|
|
|
62
|
-
|
|
154
|
+
### Search
|
|
155
|
+
|
|
156
|
+
`Rails::Contact::Search::Query` is the entry point the index action uses. It picks a
|
|
157
|
+
backend from `config.search_backend`, runs the query, and returns a
|
|
158
|
+
`Rails::Contact::Search::Result` (a struct of `records`, `total_count`, `page`,
|
|
159
|
+
`per_page`, and a derived `total_pages`).
|
|
160
|
+
|
|
161
|
+
```ruby
|
|
162
|
+
Rails::Contact::Search::Query.new(
|
|
163
|
+
"lovelace",
|
|
164
|
+
filters: { "city" => "London", "starred" => true },
|
|
165
|
+
page: 1,
|
|
166
|
+
per_page: 25
|
|
167
|
+
).call
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
`per_page` defaults to `config.default_per_page` and is capped at 100.
|
|
171
|
+
|
|
172
|
+
Two backends implement the same `search(query, filters, page:, per_page:)` contract:
|
|
173
|
+
|
|
174
|
+
- **Elasticsearch** (`config.search_backend = :elasticsearch`, the default). Queries
|
|
175
|
+
the `rails_contact_contacts` index with a fuzzy `multi_match` over name, emails,
|
|
176
|
+
phone (E.164), company, job title, and labels. If any Elasticsearch call raises,
|
|
177
|
+
it falls back to the database backend for that request. Supported filters: `city`,
|
|
178
|
+
`region`, `starred`, `sync_eligible` (city and region accept an array for
|
|
179
|
+
multi-select). Metadata filters and sorts are ignored by this backend.
|
|
180
|
+
- **Database** (`config.search_backend = :database`, or any other value). Runs a
|
|
181
|
+
`LIKE` query over name, `metadata` company/job title, email value, phone E.164, and
|
|
182
|
+
label name. The query string is sanitized internally — `LIKE` metacharacters
|
|
183
|
+
(`% _ \`) are escaped and input is capped at 200 characters — so a user typing `%`
|
|
184
|
+
cannot widen the match to every row. This backend also supports the date-range,
|
|
185
|
+
`csv_import_id`, and configured metadata filters and sorts (see below).
|
|
186
|
+
|
|
187
|
+
Keep the Elasticsearch index in sync with the rake task:
|
|
188
|
+
|
|
189
|
+
```bash
|
|
190
|
+
rake rails_contact:reindex
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
It creates the index if needed and reindexes every contact.
|
|
194
|
+
|
|
195
|
+
### Google Contacts sync
|
|
196
|
+
|
|
197
|
+
Sync is off by default. It pushes local contacts to Google Contacts through the
|
|
198
|
+
People API (`https://people.googleapis.com/v1`): new contacts are created, contacts
|
|
199
|
+
already linked to a Google record are updated, and Google's `resourceName` and `etag`
|
|
200
|
+
are stored back on the contact so later updates target the right remote record.
|
|
201
|
+
|
|
202
|
+
Enable it and provide credentials in `config/initializers/rails_contact.rb`:
|
|
63
203
|
|
|
64
204
|
```ruby
|
|
65
205
|
Rails::Contact.configure do |config|
|
|
66
|
-
config.
|
|
206
|
+
config.google_sync_enabled = true
|
|
207
|
+
config.google_max_contacts = 25_000
|
|
208
|
+
config.google_token_path = "tmp/rails_contact_google_token.json"
|
|
67
209
|
end
|
|
68
210
|
```
|
|
69
211
|
|
|
70
|
-
|
|
212
|
+
Authentication uses an OAuth access token, not the client credentials directly. The
|
|
213
|
+
engine reads the token from a JSON file at `google_token_path`:
|
|
71
214
|
|
|
72
|
-
|
|
215
|
+
```json
|
|
216
|
+
{ "access_token": "ya29...." }
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
Your host application is responsible for the OAuth flow (request a scope such as
|
|
220
|
+
`https://www.googleapis.com/auth/contacts`) and for writing that file. The
|
|
221
|
+
`google_client_id`, `google_client_secret`, and `google_redirect_uri` settings are
|
|
222
|
+
provided as a place to keep those values for your own flow; the engine itself does
|
|
223
|
+
not consume them. `Rails::Contact::Google::TokenStore#write!(payload)` is available
|
|
224
|
+
to persist the token JSON once you have it. The engine adds
|
|
225
|
+
`google_access_token`, `google_refresh_token`, and `authorization` to Rails'
|
|
226
|
+
filtered parameters so tokens do not appear in logs.
|
|
227
|
+
|
|
228
|
+
**The cap.** `google_max_contacts` (default 25,000) bounds a rolling-window sync.
|
|
229
|
+
`Contact.sync_window` is `recent_first.limit(google_max_contacts)`, ordered by
|
|
230
|
+
`updated_at` then `id` descending, so a rolling sync only ever pushes the most
|
|
231
|
+
recently updated N contacts. Contacts that fall outside that window are not touched
|
|
232
|
+
by the rolling job.
|
|
233
|
+
|
|
234
|
+
Two operations exist:
|
|
235
|
+
|
|
236
|
+
| Operation | Job | Scope |
|
|
237
|
+
|---|---|---|
|
|
238
|
+
| Rolling window | `GoogleSyncJob` (`SyncService#sync!`) | the newest `google_max_contacts` contacts (create + update) |
|
|
239
|
+
| Unsynced only | `GoogleSyncUnsyncedJob` (`SyncService#sync_unsynced!`) | contacts with no `google_resource_name`, in batches of 100, uncapped |
|
|
240
|
+
|
|
241
|
+
Trigger them from the UI (the buttons on the index, when sync is enabled), by
|
|
242
|
+
enqueuing the jobs, or through rake:
|
|
73
243
|
|
|
74
244
|
```bash
|
|
75
|
-
|
|
76
|
-
rails generate rails:contact:contact Contact
|
|
77
|
-
rails generate rails:contact:views
|
|
78
|
-
rails generate rails:contact:controllers
|
|
245
|
+
rake rails_contact:sync_google # runs GoogleSyncJob.perform_now
|
|
79
246
|
```
|
|
80
247
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
---
|
|
248
|
+
The index action posts to `google_sync_rolling_window_contacts_path` and
|
|
249
|
+
`google_sync_unsynced_contacts_path`, which enqueue the two jobs. Both the controller
|
|
250
|
+
actions and the jobs no-op unless `google_sync_enabled` is true.
|
|
85
251
|
|
|
86
|
-
|
|
252
|
+
### Merge and bulk delete
|
|
87
253
|
|
|
88
|
-
|
|
254
|
+
`POST /merge` with `source_id` and `target_id` runs
|
|
255
|
+
`Rails::Contact::MergeContactsService`, which folds the source contact into the
|
|
256
|
+
target inside a transaction: scalar fields fill blanks on the target, nested records
|
|
257
|
+
(emails, phones, addresses, websites, events) are copied when not already present,
|
|
258
|
+
labels are unioned, metadata is merged (target wins on key conflicts), and the source
|
|
259
|
+
is destroyed. Merging a contact into itself raises.
|
|
89
260
|
|
|
90
|
-
-
|
|
91
|
-
- Company, job title, department
|
|
92
|
-
- Labels (comma-separated input)
|
|
93
|
-
- Notes and metadata
|
|
94
|
-
- Starred flag
|
|
95
|
-
- Photo URL
|
|
261
|
+
`POST /bulk_destroy` with a comma-separated `ids` param deletes the selected contacts.
|
|
96
262
|
|
|
97
|
-
###
|
|
263
|
+
### Metadata filters and sorts
|
|
98
264
|
|
|
99
|
-
-
|
|
100
|
-
|
|
101
|
-
-
|
|
102
|
-
- Websites
|
|
103
|
-
- Events (birthday/custom)
|
|
265
|
+
Contacts carry app-specific values in the `metadata` JSON column. You declare which
|
|
266
|
+
keys are filterable and sortable, and the engine permits the params, normalizes
|
|
267
|
+
multi-selects, guards the SQL, and applies the filter (database backend only):
|
|
104
268
|
|
|
105
|
-
|
|
269
|
+
```ruby
|
|
270
|
+
Rails::Contact.configure do |config|
|
|
271
|
+
config.metadata_filters = {
|
|
272
|
+
"tier" => { key: "quality_tier", type: :values, allowed: %w[hot warm standard] },
|
|
273
|
+
"min_pax" => { key: "pax", type: :min_integer },
|
|
274
|
+
"min_score" => { key: "score", type: :min_numeric },
|
|
275
|
+
"vip" => { key: "tags", type: :tag, tag: "vip" }
|
|
276
|
+
}
|
|
277
|
+
config.metadata_sorts = {
|
|
278
|
+
"score" => { key: "score" } # ?sort=score orders by metadata score, highest first
|
|
279
|
+
}
|
|
280
|
+
end
|
|
281
|
+
```
|
|
106
282
|
|
|
107
|
-
|
|
283
|
+
The filter types and the rules that guard them are described in full in
|
|
284
|
+
[docs/CONFIGURATION.md](docs/CONFIGURATION.md#metadata-filters-and-sorts).
|
|
108
285
|
|
|
109
|
-
|
|
110
|
-
- Filter by city/region/sync/starred
|
|
111
|
-
- Sort by recent updates
|
|
286
|
+
### Host layout and asset injection
|
|
112
287
|
|
|
113
|
-
|
|
288
|
+
By default the engine renders its pages inside the host layout named `application`, so
|
|
289
|
+
contacts pages match the rest of the app (Turbo and importmap included). Engine CSS is
|
|
290
|
+
pulled into each view with `stylesheet_link_tag`, and the add-row and bulk-selection
|
|
291
|
+
behavior runs from small inline scripts, so you do not have to pin gem JavaScript in
|
|
292
|
+
the host importmap.
|
|
114
293
|
|
|
115
|
-
|
|
116
|
-
|
|
294
|
+
To use the engine's own layout and its bundled
|
|
295
|
+
`javascript_include_tag "rails/contact/application"` instead:
|
|
117
296
|
|
|
118
|
-
|
|
297
|
+
```ruby
|
|
298
|
+
Rails::Contact.configure do |config|
|
|
299
|
+
config.inherit_host_layout = false
|
|
300
|
+
end
|
|
301
|
+
```
|
|
119
302
|
|
|
120
303
|
## Configuration
|
|
121
304
|
|
|
122
|
-
|
|
305
|
+
The install generator writes `config/initializers/rails_contact.rb`. Every setting has
|
|
306
|
+
a default, so the initializer only needs the ones you change. The commonly used
|
|
307
|
+
settings:
|
|
123
308
|
|
|
124
309
|
```ruby
|
|
125
310
|
Rails::Contact.configure do |config|
|
|
126
|
-
config.search_backend
|
|
127
|
-
config.elasticsearch_url
|
|
311
|
+
config.search_backend = :elasticsearch
|
|
312
|
+
config.elasticsearch_url = ENV.fetch("ELASTICSEARCH_URL", "http://127.0.0.1:9200")
|
|
313
|
+
config.default_per_page = 25
|
|
314
|
+
config.inherit_host_layout = true
|
|
315
|
+
|
|
128
316
|
config.google_sync_enabled = false
|
|
129
317
|
config.google_max_contacts = 25_000
|
|
130
|
-
config.
|
|
131
|
-
# Optional: appended to familyName in Google People API payloads only (blank = unchanged).
|
|
132
|
-
# config.google_contact_family_name_suffix = "_by_vendor"
|
|
133
|
-
config.default_per_page = 25
|
|
318
|
+
config.google_token_path = "tmp/rails_contact_google_token.json"
|
|
134
319
|
end
|
|
135
320
|
```
|
|
136
321
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
**Recommendation:** keep the **default panel in the gem** so every app gets working buttons when `google_sync_enabled` is true. You avoid copy-paste and stay aligned with new endpoints.
|
|
140
|
-
|
|
141
|
-
- The index template renders `rails/contact/_google_sync_panel` when `google_sync_ui_on_index` is **true** (default).
|
|
142
|
-
- **Customize without forking the engine:** add `app/views/rails/contact/_google_sync_panel.html.erb` in the host app; Rails resolves that file instead of the gem’s partial.
|
|
143
|
-
- **Hide the default panel:** set `config.google_sync_ui_on_index = false` and render your own UI anywhere (same `POST` targets below).
|
|
144
|
-
- **Custom index only:** override `rails/contact/index` and `<%= render "rails/contact/google_sync_panel" %>` wherever it fits (e.g. after a CSV upload section).
|
|
322
|
+
The full reference — every setting, its type, default, backing environment variable,
|
|
323
|
+
and what it does — is in [docs/CONFIGURATION.md](docs/CONFIGURATION.md).
|
|
145
324
|
|
|
146
|
-
|
|
325
|
+
## Generators
|
|
147
326
|
|
|
148
|
-
|
|
149
|
-
|
|
327
|
+
```bash
|
|
328
|
+
rails generate rails:contact:install # initializer + engine mount
|
|
329
|
+
rails generate rails:contact:contact NAME # invokes install, then copies migrations
|
|
330
|
+
rails generate rails:contact:controllers # copy an override-ready contacts controller
|
|
331
|
+
```
|
|
150
332
|
|
|
151
|
-
`
|
|
333
|
+
- `install` writes `config/initializers/rails_contact.rb` and adds
|
|
334
|
+
`mount Rails::Contact::Engine => '/contacts', as: 'rails_contact'` to your routes.
|
|
335
|
+
- `contact` runs `install` and copies the eight migrations (contacts, emails, phones,
|
|
336
|
+
addresses, websites, events, labels, contact_labels).
|
|
337
|
+
- `controllers` copies an override-ready contacts controller into the host app so you
|
|
338
|
+
can customize it, Devise-style.
|
|
152
339
|
|
|
153
|
-
|
|
340
|
+
A `rails:contact:views` generator is also defined, but it currently copies from
|
|
341
|
+
`app/views/rails/contact/contacts`, a directory the engine no longer uses (its views
|
|
342
|
+
live directly under `app/views/rails/contact`), so it does not copy the current
|
|
343
|
+
templates. To customize views today, copy the files from
|
|
344
|
+
`app/views/rails/contact/` in the gem into the same path in your app.
|
|
154
345
|
|
|
155
346
|
## Rake tasks
|
|
156
347
|
|
|
157
348
|
```bash
|
|
158
|
-
rake rails_contact:reindex
|
|
159
|
-
rake rails_contact:sync_google
|
|
349
|
+
rake rails_contact:reindex # create the Elasticsearch index and reindex all contacts
|
|
350
|
+
rake rails_contact:sync_google # run GoogleSyncJob.perform_now (rolling-window sync)
|
|
351
|
+
rake rails_contact:install # prints the install generator command
|
|
160
352
|
```
|
|
161
353
|
|
|
162
|
-
|
|
354
|
+
## Routes
|
|
163
355
|
|
|
164
|
-
|
|
356
|
+
Mounting adds these routes under the mount path (`/contacts` by default):
|
|
165
357
|
|
|
166
|
-
|
|
358
|
+
| Method | Path | Action |
|
|
359
|
+
|---|---|---|
|
|
360
|
+
| GET | `/` | `index` |
|
|
361
|
+
| GET | `/new` | `new` |
|
|
362
|
+
| POST | `/` | `create` |
|
|
363
|
+
| GET | `/:id` | `show` |
|
|
364
|
+
| GET | `/:id/edit` | `edit` |
|
|
365
|
+
| PATCH/PUT | `/:id` | `update` |
|
|
366
|
+
| DELETE | `/:id` | `destroy` |
|
|
367
|
+
| POST | `/bulk_destroy` | `bulk_destroy` |
|
|
368
|
+
| POST | `/merge` | `merge` |
|
|
369
|
+
| POST | `/google_sync_unsynced` | `google_sync_unsynced` |
|
|
370
|
+
| POST | `/google_sync_rolling_window` | `google_sync_rolling_window` |
|
|
371
|
+
|
|
372
|
+
You can mount explicitly or use the routing helper, which normalizes a singular or
|
|
373
|
+
plural resource name to the plural path:
|
|
374
|
+
|
|
375
|
+
```ruby
|
|
376
|
+
# config/routes.rb
|
|
377
|
+
mount Rails::Contact::Engine => "/contacts", as: "rails_contact"
|
|
378
|
+
# or, equivalently:
|
|
379
|
+
rails_contact_for :contacts # rails_contact_for :contact also mounts at /contacts
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
`rails_contact_for` accepts an `at:` path and an `as:` name if you want to override
|
|
383
|
+
the defaults.
|
|
384
|
+
|
|
385
|
+
## Testing
|
|
386
|
+
|
|
387
|
+
The suite is RSpec, with SimpleCov enforcing line and branch coverage in CI:
|
|
167
388
|
|
|
168
389
|
```bash
|
|
169
390
|
bundle exec rspec
|
|
170
391
|
```
|
|
171
392
|
|
|
172
|
-
|
|
393
|
+
CI (`.github/workflows/ci.yml`) runs RuboCop and the specs against Ruby 3.2 and 3.3
|
|
394
|
+
with an Elasticsearch 8.13 service. A smaller Minitest suite under `test/` covers the
|
|
395
|
+
Google client and sync service.
|
|
173
396
|
|
|
174
|
-
|
|
175
|
-
- 100% branch coverage target
|
|
176
|
-
- CI fails below thresholds
|
|
397
|
+
## Development
|
|
177
398
|
|
|
178
|
-
|
|
399
|
+
```bash
|
|
400
|
+
git clone https://github.com/kshtzkr/rails-contact.git
|
|
401
|
+
cd rails-contact
|
|
402
|
+
bundle install
|
|
403
|
+
bundle exec rspec # run the tests
|
|
404
|
+
bin/rubocop # lint
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
## Releasing
|
|
179
408
|
|
|
180
|
-
|
|
409
|
+
Releases go to RubyGems with the standard Bundler gem tasks:
|
|
181
410
|
|
|
182
411
|
```bash
|
|
183
412
|
bundle exec rake build
|
|
184
413
|
bundle exec rake release
|
|
185
414
|
```
|
|
186
415
|
|
|
187
|
-
RubyGems MFA is required
|
|
416
|
+
RubyGems MFA is required to push (`rubygems_mfa_required` is set in the gemspec).
|
|
417
|
+
|
|
418
|
+
## Contributing
|
|
419
|
+
|
|
420
|
+
Bug reports and pull requests are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) for
|
|
421
|
+
setup, the branch and PR flow, and the commit-message convention. This project follows
|
|
422
|
+
the [Contributor Covenant](CODE_OF_CONDUCT.md) code of conduct.
|
|
423
|
+
|
|
424
|
+
## Security
|
|
425
|
+
|
|
426
|
+
Please report security issues privately rather than opening a public issue. See
|
|
427
|
+
[SECURITY.md](SECURITY.md).
|
|
428
|
+
|
|
429
|
+
## Versioning and changelog
|
|
430
|
+
|
|
431
|
+
`rails-contact` follows [Semantic Versioning](https://semver.org). Notable changes are
|
|
432
|
+
recorded in [CHANGELOG.md](CHANGELOG.md).
|
|
433
|
+
|
|
434
|
+
## Related docs
|
|
188
435
|
|
|
189
|
-
|
|
436
|
+
- [docs/CONFIGURATION.md](docs/CONFIGURATION.md) — full configuration reference
|
|
437
|
+
- [docs/migration_guide.md](docs/migration_guide.md) — upgrading across the breaking rewrite
|
|
438
|
+
- [docs/parity_matrix.md](docs/parity_matrix.md) — feature parity against Google Contacts
|
|
439
|
+
- [docs/product_decisions.md](docs/product_decisions.md) — design decisions
|
|
440
|
+
- [docs/roadmap.md](docs/roadmap.md) — planned work
|
|
190
441
|
|
|
191
|
-
##
|
|
442
|
+
## License
|
|
192
443
|
|
|
193
|
-
-
|
|
194
|
-
- `docs/product_decisions.md`
|
|
195
|
-
- `docs/roadmap.md`
|
|
196
|
-
- `docs/migration_guide.md`
|
|
444
|
+
Released under the [MIT License](MIT-LICENSE).
|
|
@@ -127,7 +127,47 @@ module Rails
|
|
|
127
127
|
end
|
|
128
128
|
|
|
129
129
|
def filter_params
|
|
130
|
-
|
|
130
|
+
config = Rails::Contact.configuration
|
|
131
|
+
# Host-configured metadata filters: :values filters arrive as
|
|
132
|
+
# multi-selects (arrays), every other type as a scalar. ?sort= is only
|
|
133
|
+
# a filter param when the host declared at least one metadata sort.
|
|
134
|
+
metadata_arrays = config.metadata_filters.select { |_, f| f[:type] == :values }.keys.map(&:to_sym)
|
|
135
|
+
metadata_scalars = config.metadata_filters.reject { |_, f| f[:type] == :values }.keys.map(&:to_sym)
|
|
136
|
+
metadata_scalars << :sort if config.metadata_sorts.any?
|
|
137
|
+
|
|
138
|
+
permitted = params.permit(
|
|
139
|
+
:city,
|
|
140
|
+
:sync_eligible,
|
|
141
|
+
:starred,
|
|
142
|
+
:travel_date_start,
|
|
143
|
+
:travel_date_end,
|
|
144
|
+
:contact_created_at_start,
|
|
145
|
+
:contact_created_at_end,
|
|
146
|
+
*metadata_scalars,
|
|
147
|
+
region: [],
|
|
148
|
+
csv_import_id: [],
|
|
149
|
+
**metadata_arrays.index_with { [] }
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
([ :region, :csv_import_id ] + metadata_arrays).each { |key| normalize_multi_select!(permitted, key) }
|
|
153
|
+
permitted
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# region, csv_import_id and every configured :values metadata filter are
|
|
157
|
+
# multi-selects: a <select multiple> submits param[] (an array) plus a
|
|
158
|
+
# hidden param[]="" that Rails always sends, so the blank must be
|
|
159
|
+
# stripped — otherwise IN ('', 'x') matches every blank-valued row.
|
|
160
|
+
# Legacy bookmarks may still send a scalar (?region=Europe); coerce those
|
|
161
|
+
# to a one-element array so the search backend only ever sees an array
|
|
162
|
+
# (or no key at all).
|
|
163
|
+
def normalize_multi_select!(permitted, key)
|
|
164
|
+
if permitted[key].blank? && params[key].is_a?(String) && params[key].present?
|
|
165
|
+
permitted[key] = [ params[key] ]
|
|
166
|
+
end
|
|
167
|
+
return unless permitted[key].is_a?(Array)
|
|
168
|
+
|
|
169
|
+
permitted[key] = permitted[key].reject(&:blank?)
|
|
170
|
+
permitted.delete(key) if permitted[key].empty?
|
|
131
171
|
end
|
|
132
172
|
|
|
133
173
|
def google_pending_sync_count
|