rails-contact 0.1.14 → 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 +21 -0
- data/README.md +353 -105
- data/app/controllers/rails/contact/contacts_controller.rb +19 -9
- data/docs/CONFIGURATION.md +91 -0
- data/lib/rails/contact/configuration.rb +35 -1
- data/lib/rails/contact/search/backends/database.rb +126 -0
- data/lib/rails/contact/version.rb +1 -1
- data/spec/controllers/contacts_controller_spec.rb +39 -0
- data/spec/search/metadata_filters_spec.rb +140 -0
- metadata +3 -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,26 @@
|
|
|
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
|
+
|
|
3
24
|
## 0.1.14
|
|
4
25
|
|
|
5
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:)`.
|
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,6 +127,14 @@ module Rails
|
|
|
127
127
|
end
|
|
128
128
|
|
|
129
129
|
def filter_params
|
|
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
|
+
|
|
130
138
|
permitted = params.permit(
|
|
131
139
|
:city,
|
|
132
140
|
:sync_eligible,
|
|
@@ -135,21 +143,23 @@ module Rails
|
|
|
135
143
|
:travel_date_end,
|
|
136
144
|
:contact_created_at_start,
|
|
137
145
|
:contact_created_at_end,
|
|
146
|
+
*metadata_scalars,
|
|
138
147
|
region: [],
|
|
139
|
-
csv_import_id: []
|
|
148
|
+
csv_import_id: [],
|
|
149
|
+
**metadata_arrays.index_with { [] }
|
|
140
150
|
)
|
|
141
151
|
|
|
142
|
-
normalize_multi_select!(permitted,
|
|
143
|
-
normalize_multi_select!(permitted, :csv_import_id)
|
|
152
|
+
([ :region, :csv_import_id ] + metadata_arrays).each { |key| normalize_multi_select!(permitted, key) }
|
|
144
153
|
permitted
|
|
145
154
|
end
|
|
146
155
|
|
|
147
|
-
# region and
|
|
148
|
-
# submits param[] (an array) plus a
|
|
149
|
-
# sends, so the blank must be
|
|
150
|
-
#
|
|
151
|
-
# (?region=Europe); coerce those
|
|
152
|
-
# backend only ever sees an array
|
|
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).
|
|
153
163
|
def normalize_multi_select!(permitted, key)
|
|
154
164
|
if permitted[key].blank? && params[key].is_a?(String) && params[key].present?
|
|
155
165
|
permitted[key] = [ params[key] ]
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# Configuration reference
|
|
2
|
+
|
|
3
|
+
Every setting on `Rails::Contact::Configuration`, with its default and what the engine
|
|
4
|
+
does with it. Set them in `config/initializers/rails_contact.rb`:
|
|
5
|
+
|
|
6
|
+
```ruby
|
|
7
|
+
Rails::Contact.configure do |config|
|
|
8
|
+
config.search_backend = :database
|
|
9
|
+
# ...
|
|
10
|
+
end
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
All settings have defaults, so you only need to list the ones you change.
|
|
14
|
+
|
|
15
|
+
## Settings
|
|
16
|
+
|
|
17
|
+
| Setting | Type | Default | Environment variable | Description |
|
|
18
|
+
|---|---|---|---|---|
|
|
19
|
+
| `contact_class_name` | String | `"Rails::Contact::Contact"` | — | Name of the contact model. Informational in the current version — the engine references `Rails::Contact::Contact` directly. |
|
|
20
|
+
| `elasticsearch_url` | String | `"http://127.0.0.1:9200"` | `ELASTICSEARCH_URL` | URL of the Elasticsearch cluster used by the `:elasticsearch` backend. |
|
|
21
|
+
| `search_backend` | Symbol | `:elasticsearch` | — | Search backend. `:elasticsearch` uses Elasticsearch (with a database fallback on error); any other value uses the database backend. |
|
|
22
|
+
| `default_per_page` | Integer | `25` | — | Default page size for search. Capped at 100 per request. |
|
|
23
|
+
| `inherit_host_layout` | Boolean | `true` | — | When `true`, engine pages render inside the host layout named `application`. When `false`, the engine uses its own layout and bundled JavaScript. |
|
|
24
|
+
| `google_sync_enabled` | Boolean | `false` | `RAILS_CONTACT_GOOGLE_SYNC_ENABLED` (in the generated initializer) | Master switch for Google Contacts sync. The sync controller actions and jobs no-op when `false`. |
|
|
25
|
+
| `google_sync_ui_on_index` | Boolean | `true` | — | When `true`, the index renders the gem's Google sync panel partial. Set `false` to supply your own UI. |
|
|
26
|
+
| `google_max_contacts` | Integer | `25_000` | — | Cap on the rolling-window sync. `Contact.sync_window` limits to the newest N contacts by `updated_at`. |
|
|
27
|
+
| `google_token_path` | String | `"tmp/rails_contact_google_token.json"` | `RAILS_CONTACT_GOOGLE_TOKEN_PATH` | Path to the JSON file holding the OAuth access token. `TokenStore` reads the `access_token` key from it. |
|
|
28
|
+
| `google_client_id` | String / nil | `nil` | `GOOGLE_CLIENT_ID` | OAuth client id. Host-facing: a place to keep the value for your own OAuth flow. Not consumed by the engine. |
|
|
29
|
+
| `google_client_secret` | String / nil | `nil` | `GOOGLE_CLIENT_SECRET` | OAuth client secret. Host-facing, not consumed by the engine. |
|
|
30
|
+
| `google_redirect_uri` | String / nil | `nil` | `GOOGLE_REDIRECT_URI` | OAuth redirect URI. Host-facing, not consumed by the engine. |
|
|
31
|
+
| `google_contact_family_name_suffix` | String / nil | `nil` | `RAILS_CONTACT_GOOGLE_CONTACT_FAMILY_NAME_SUFFIX` | Optional suffix appended to `familyName` in Google People payloads only. Not stored on the contact. Blank disables it. |
|
|
32
|
+
| `rolling_window_sort` | Symbol | `:updated_at` | — | Intended sort column for the rolling window. Reserved: the current `sync_window` scope orders by `updated_at, id` and does not read this setting. |
|
|
33
|
+
| `reset_index_on_boot` | Boolean | `false` | — | Reserved: defined but not read by the current engine. |
|
|
34
|
+
| `metadata_filters` | Hash | `{}` | — | Declarative filters over `Contact#metadata`. Database backend only. See below. |
|
|
35
|
+
| `metadata_sorts` | Hash | `{}` | — | Declarative `?sort=` options over numeric metadata. Database backend only. See below. |
|
|
36
|
+
|
|
37
|
+
Settings marked "reserved" or "not consumed by the engine" are defined on the
|
|
38
|
+
configuration object and safe to set, but the current release does not act on them.
|
|
39
|
+
They are documented here for completeness.
|
|
40
|
+
|
|
41
|
+
## Metadata filters and sorts
|
|
42
|
+
|
|
43
|
+
Contacts carry app-specific values in the `metadata` JSON column. `metadata_filters`
|
|
44
|
+
and `metadata_sorts` let the host decide which keys are filterable and sortable. The
|
|
45
|
+
engine then permits the request params, normalizes multi-selects, guards the SQL, and
|
|
46
|
+
applies the filter. These apply to the **database backend only**; the Elasticsearch
|
|
47
|
+
backend ignores them.
|
|
48
|
+
|
|
49
|
+
```ruby
|
|
50
|
+
Rails::Contact.configure do |config|
|
|
51
|
+
config.metadata_filters = {
|
|
52
|
+
# request param name => filter declaration
|
|
53
|
+
"tier" => { key: "quality_tier", type: :values, allowed: %w[hot warm standard] },
|
|
54
|
+
"min_pax" => { key: "pax", type: :min_integer },
|
|
55
|
+
"min_score" => { key: "score", type: :min_numeric },
|
|
56
|
+
"vip" => { key: "tags", type: :tag, tag: "vip" }
|
|
57
|
+
}
|
|
58
|
+
config.metadata_sorts = {
|
|
59
|
+
"score" => { key: "score" } # ?sort=score orders by metadata score, highest first
|
|
60
|
+
}
|
|
61
|
+
end
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
`key:` is the metadata key to read. It is declared in code, never taken from a request
|
|
65
|
+
param, and must be a plain identifier matching `[a-zA-Z0-9_]+` — anything else raises,
|
|
66
|
+
as a guard against interpolating an unsafe fragment into SQL.
|
|
67
|
+
|
|
68
|
+
### Filter types
|
|
69
|
+
|
|
70
|
+
- `:values` — multi-select. Matches when `metadata->>key` equals any selected value.
|
|
71
|
+
An optional `allowed:` whitelist discards anything outside it. A scalar param (a
|
|
72
|
+
legacy bookmark such as `?tier=hot`) is coerced to a one-element selection.
|
|
73
|
+
- `:min_integer` / `:min_numeric` — numeric floor. Stored values that are not numbers
|
|
74
|
+
are filtered out rather than cast, so an imported `"TBD"` cannot raise. `:min_numeric`
|
|
75
|
+
also accepts decimals.
|
|
76
|
+
- `:tag` — checkbox. Matches when the metadata key (a JSON array) contains `tag:`. The
|
|
77
|
+
param value `"1"` switches it on.
|
|
78
|
+
- `:exclude` — hides rows whose key equals `value:`. Add `default: :on` to apply the
|
|
79
|
+
filter even when the request param is absent (first page load, bookmarks); an
|
|
80
|
+
explicit `"0"` shows everything — built for default-on "hide test data" checkboxes.
|
|
81
|
+
Rows missing the key always pass, so unclassified data is never hidden.
|
|
82
|
+
|
|
83
|
+
### Sorts
|
|
84
|
+
|
|
85
|
+
A metadata sort orders descending, puts non-numeric or missing values last, and breaks
|
|
86
|
+
ties by recency. Only sorts declared in `metadata_sorts` apply — an unknown `?sort=`
|
|
87
|
+
value is ignored.
|
|
88
|
+
|
|
89
|
+
A metadata sort is dropped while a free-text `q` search is active. The search branch
|
|
90
|
+
runs `SELECT DISTINCT`, and PostgreSQL rejects ordering by an expression that is not in
|
|
91
|
+
the select list, so search results keep recency order.
|
|
@@ -6,7 +6,8 @@ module Rails
|
|
|
6
6
|
:google_client_id, :google_client_secret, :google_redirect_uri,
|
|
7
7
|
:google_token_path, :reset_index_on_boot, :default_per_page,
|
|
8
8
|
:inherit_host_layout,
|
|
9
|
-
:google_contact_family_name_suffix
|
|
9
|
+
:google_contact_family_name_suffix,
|
|
10
|
+
:metadata_filters, :metadata_sorts
|
|
10
11
|
|
|
11
12
|
def initialize
|
|
12
13
|
@contact_class_name = "Rails::Contact::Contact"
|
|
@@ -29,6 +30,39 @@ module Rails
|
|
|
29
30
|
# importmap/Turbo match the rest of the app. Engine CSS and nested-field JS are still
|
|
30
31
|
# injected from gem templates so behavior does not depend on the engine layout asset tags.
|
|
31
32
|
@inherit_host_layout = true
|
|
33
|
+
# Declarative filters over Contact#metadata, keyed by the request param
|
|
34
|
+
# name. The host app decides which metadata keys are filterable; the
|
|
35
|
+
# engine permits the params, guards the SQL, and applies the filter
|
|
36
|
+
# (database backend only). Example:
|
|
37
|
+
#
|
|
38
|
+
# config.metadata_filters = {
|
|
39
|
+
# "tier" => { key: "quality_tier", type: :values, allowed: %w[hot warm standard] },
|
|
40
|
+
# "min_pax" => { key: "pax", type: :min_integer },
|
|
41
|
+
# "min_score" => { key: "score", type: :min_numeric },
|
|
42
|
+
# "vip" => { key: "tags", type: :tag, tag: "vip" }
|
|
43
|
+
# }
|
|
44
|
+
#
|
|
45
|
+
# :values — multi-select; matches metadata->>key IN (...). Optional
|
|
46
|
+
# :allowed whitelist discards anything else.
|
|
47
|
+
# :min_integer — numeric floor over an integer-ish metadata string;
|
|
48
|
+
# non-numeric stored values are filtered out, never cast.
|
|
49
|
+
# :min_numeric — same, but accepts decimals.
|
|
50
|
+
# :tag — checkbox; matches when the metadata key (a JSON array)
|
|
51
|
+
# contains :tag. Param value "1" switches it on.
|
|
52
|
+
# :exclude — hides rows whose key equals :value. Add default: :on
|
|
53
|
+
# to apply it even when the param is absent; an
|
|
54
|
+
# explicit "0" shows everything. Rows missing the key
|
|
55
|
+
# always pass.
|
|
56
|
+
@metadata_filters = {}
|
|
57
|
+
# Sort options over numeric metadata, keyed by the ?sort= param value:
|
|
58
|
+
#
|
|
59
|
+
# config.metadata_sorts = { "score" => { key: "score" } }
|
|
60
|
+
#
|
|
61
|
+
# Sorts descending, non-numeric values last, ties broken by recency.
|
|
62
|
+
# Ignored while a free-text q search is active: the search branch runs
|
|
63
|
+
# SELECT DISTINCT and PostgreSQL rejects ordering by an expression that
|
|
64
|
+
# is not in the select list, so search results keep recency order.
|
|
65
|
+
@metadata_sorts = {}
|
|
32
66
|
end
|
|
33
67
|
end
|
|
34
68
|
end
|
|
@@ -7,8 +7,18 @@ module Rails
|
|
|
7
7
|
# any realistic name/email/phone substring.
|
|
8
8
|
MAX_QUERY_LENGTH = 200
|
|
9
9
|
|
|
10
|
+
# Configured metadata keys are interpolated into SQL fragments, so
|
|
11
|
+
# they must be plain identifiers. This is a foot-gun guard for host
|
|
12
|
+
# developers, not a user-input path — params never reach the key.
|
|
13
|
+
METADATA_KEY_FORMAT = /\A[a-zA-Z0-9_]+\z/
|
|
14
|
+
|
|
10
15
|
def search(query, filters, page:, per_page:)
|
|
11
16
|
query = sanitize_query(query)
|
|
17
|
+
# Metadata sort cannot combine with free-text search: the search
|
|
18
|
+
# branch below runs SELECT DISTINCT, and PostgreSQL rejects ORDER BY
|
|
19
|
+
# expressions that are not in the select list. Searching means
|
|
20
|
+
# hunting a specific contact anyway, so q results keep recency order.
|
|
21
|
+
filters = filters.except("sort") if query.present?
|
|
12
22
|
offset = (page - 1) * per_page
|
|
13
23
|
scope = Contact.includes(:emails, :phones, :labels).recent_first
|
|
14
24
|
scope = apply_filters(scope, filters)
|
|
@@ -82,8 +92,124 @@ module Rails
|
|
|
82
92
|
scoped = scoped.where("metadata->>'csv_import_id' IN (?)", ids) if ids.any?
|
|
83
93
|
end
|
|
84
94
|
|
|
95
|
+
scoped = apply_metadata_filters(scoped, filters)
|
|
96
|
+
apply_metadata_sort(scoped, filters["sort"])
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Host-configured filters over Contact#metadata — see
|
|
100
|
+
# Configuration#metadata_filters for the declaration format. Every
|
|
101
|
+
# stored value came from user data (CSV imports, forms), so numeric
|
|
102
|
+
# comparisons never cast blindly: non-numeric values are filtered
|
|
103
|
+
# out by a guard instead of raising.
|
|
104
|
+
def apply_metadata_filters(scoped, filters)
|
|
105
|
+
Rails::Contact.configuration.metadata_filters.each do |param, config|
|
|
106
|
+
value = filters[param.to_s]
|
|
107
|
+
# A blank param means "not filtering" — except for default-on
|
|
108
|
+
# filters (config default: :on), which apply until the user
|
|
109
|
+
# explicitly switches them off with a false-y value ("0").
|
|
110
|
+
next if value.blank? && config[:default] != :on
|
|
111
|
+
|
|
112
|
+
key = metadata_key!(config.fetch(:key))
|
|
113
|
+
scoped = case config.fetch(:type)
|
|
114
|
+
when :values then apply_values_filter(scoped, key, value, config[:allowed])
|
|
115
|
+
when :min_integer then apply_min_filter(scoped, key, value, decimals: false)
|
|
116
|
+
when :min_numeric then apply_min_filter(scoped, key, value, decimals: true)
|
|
117
|
+
when :tag then apply_tag_filter(scoped, key, value, config.fetch(:tag))
|
|
118
|
+
when :exclude then apply_exclude_filter(scoped, key, value, config.fetch(:value))
|
|
119
|
+
else
|
|
120
|
+
raise ArgumentError, "unknown metadata filter type #{config[:type].inspect} for #{param.inspect}"
|
|
121
|
+
end
|
|
122
|
+
end
|
|
85
123
|
scoped
|
|
86
124
|
end
|
|
125
|
+
|
|
126
|
+
def apply_values_filter(scoped, key, value, allowed)
|
|
127
|
+
values = Array(value).map(&:to_s).reject(&:blank?)
|
|
128
|
+
values &= allowed.map(&:to_s) if allowed
|
|
129
|
+
return scoped if values.empty?
|
|
130
|
+
|
|
131
|
+
scoped.where("metadata->>'#{key}' IN (?)", values)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def apply_min_filter(scoped, key, value, decimals:)
|
|
135
|
+
floor = decimals ? value.to_f : value.to_i
|
|
136
|
+
return scoped unless floor.positive?
|
|
137
|
+
|
|
138
|
+
if postgres?(scoped)
|
|
139
|
+
# {0,1} instead of the ? quantifier: Rails' bind sanitizer counts
|
|
140
|
+
# every literal ? in the fragment as a placeholder, even inside a
|
|
141
|
+
# quoted regex.
|
|
142
|
+
pattern = decimals ? "^[0-9]+(\\.[0-9]+){0,1}$" : "^[0-9]+$"
|
|
143
|
+
cast = decimals ? "numeric" : "int"
|
|
144
|
+
scoped.where("metadata->>'#{key}' ~ '#{pattern}' AND (metadata->>'#{key}')::#{cast} >= ?", floor)
|
|
145
|
+
else
|
|
146
|
+
# SQLite (test harness): CAST never raises — junk casts to 0,
|
|
147
|
+
# which a positive floor excludes on its own.
|
|
148
|
+
scoped.where("CAST(metadata->>'#{key}' AS REAL) >= ?", floor)
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def apply_tag_filter(scoped, key, value, tag)
|
|
153
|
+
return scoped unless ActiveModel::Type::Boolean.new.cast(value)
|
|
154
|
+
|
|
155
|
+
if postgres?(scoped)
|
|
156
|
+
scoped.where("metadata->'#{key}' @> ?", [ tag ].to_json)
|
|
157
|
+
else
|
|
158
|
+
scoped.where(
|
|
159
|
+
"EXISTS (SELECT 1 FROM json_each(rails_contact_contacts.metadata, '$.#{key}') WHERE json_each.value = ?)",
|
|
160
|
+
tag
|
|
161
|
+
)
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Hide rows whose metadata key equals the configured value. Built
|
|
166
|
+
# for default-on filters (\"hide test data\"): a nil param arrives
|
|
167
|
+
# here only when the config says default: :on, and nil casts to
|
|
168
|
+
# nil (not false), so the exclusion applies; an explicit "0"
|
|
169
|
+
# switches it off. Rows MISSING the key must pass — unclassified
|
|
170
|
+
# legacy data is not test data.
|
|
171
|
+
def apply_exclude_filter(scoped, key, value, excluded)
|
|
172
|
+
return scoped if value.present? && ActiveModel::Type::Boolean.new.cast(value) == false
|
|
173
|
+
|
|
174
|
+
if postgres?(scoped)
|
|
175
|
+
scoped.where("metadata->>'#{key}' IS DISTINCT FROM ?", excluded.to_s)
|
|
176
|
+
else
|
|
177
|
+
# SQLite (test harness): IS DISTINCT FROM needs 3.39+.
|
|
178
|
+
scoped.where("COALESCE(metadata->>'#{key}', '') <> ?", excluded.to_s)
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Descending sort over a numeric metadata key; rows with a
|
|
183
|
+
# non-numeric or missing value sink to the bottom, recency breaks
|
|
184
|
+
# ties. Only sorts declared in Configuration#metadata_sorts apply —
|
|
185
|
+
# an unknown ?sort= value is ignored.
|
|
186
|
+
def apply_metadata_sort(scoped, sort_param)
|
|
187
|
+
sort = Rails::Contact.configuration.metadata_sorts[sort_param.to_s]
|
|
188
|
+
return scoped unless sort
|
|
189
|
+
|
|
190
|
+
key = metadata_key!(sort.fetch(:key))
|
|
191
|
+
order = if postgres?(scoped)
|
|
192
|
+
"CASE WHEN metadata->>'#{key}' ~ '^[0-9]+(\\.[0-9]+){0,1}$' " \
|
|
193
|
+
"THEN (metadata->>'#{key}')::numeric ELSE -1 END DESC, " \
|
|
194
|
+
"rails_contact_contacts.created_at DESC"
|
|
195
|
+
else
|
|
196
|
+
"CAST(metadata->>'#{key}' AS REAL) DESC, rails_contact_contacts.created_at DESC"
|
|
197
|
+
end
|
|
198
|
+
scoped.reorder(Arel.sql(order))
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def metadata_key!(key)
|
|
202
|
+
key = key.to_s
|
|
203
|
+
unless METADATA_KEY_FORMAT.match?(key)
|
|
204
|
+
raise ArgumentError, "metadata filter key #{key.inspect} must match #{METADATA_KEY_FORMAT.inspect}"
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
key
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def postgres?(scoped)
|
|
211
|
+
scoped.klass.connection.adapter_name.match?(/postgres/i)
|
|
212
|
+
end
|
|
87
213
|
end
|
|
88
214
|
end
|
|
89
215
|
end
|
|
@@ -29,6 +29,45 @@ RSpec.describe Rails::Contact::ContactsController do
|
|
|
29
29
|
end
|
|
30
30
|
end
|
|
31
31
|
|
|
32
|
+
describe "configured metadata filter params" do
|
|
33
|
+
around do |example|
|
|
34
|
+
config = Rails::Contact.configuration
|
|
35
|
+
original_filters = config.metadata_filters
|
|
36
|
+
original_sorts = config.metadata_sorts
|
|
37
|
+
config.metadata_filters = {
|
|
38
|
+
"tier" => { key: "quality_tier", type: :values, allowed: %w[hot warm] },
|
|
39
|
+
"min_pax" => { key: "pax", type: :min_integer }
|
|
40
|
+
}
|
|
41
|
+
config.metadata_sorts = { "score" => { key: "score" } }
|
|
42
|
+
example.run
|
|
43
|
+
ensure
|
|
44
|
+
config.metadata_filters = original_filters
|
|
45
|
+
config.metadata_sorts = original_sorts
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
it "permits configured array, scalar and sort params" do
|
|
49
|
+
controller.params = ActionController::Parameters.new(
|
|
50
|
+
tier: [ "hot" ], min_pax: "4", sort: "score", unrelated: "x"
|
|
51
|
+
)
|
|
52
|
+
permitted = controller.send(:filter_params)
|
|
53
|
+
expect(permitted.to_h).to eq({ "tier" => [ "hot" ], "min_pax" => "4", "sort" => "score" })
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
it "normalizes configured multi-selects like region (scalar coercion + blank strip)" do
|
|
57
|
+
controller.params = ActionController::Parameters.new(tier: "warm")
|
|
58
|
+
expect(controller.send(:filter_params).to_h).to eq({ "tier" => [ "warm" ] })
|
|
59
|
+
|
|
60
|
+
controller.params = ActionController::Parameters.new(tier: [ "" ])
|
|
61
|
+
expect(controller.send(:filter_params).to_h).to eq({})
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
it "does not permit sort when no metadata sorts are configured" do
|
|
65
|
+
Rails::Contact.configuration.metadata_sorts = {}
|
|
66
|
+
controller.params = ActionController::Parameters.new(sort: "score")
|
|
67
|
+
expect(controller.send(:filter_params).to_h).to eq({})
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
32
71
|
describe "association defaults" do
|
|
33
72
|
it "builds default nested associations" do
|
|
34
73
|
contact = Rails::Contact::Contact.new(given_name: "X")
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
require "rails_helper"
|
|
2
|
+
|
|
3
|
+
# Host-configured metadata filters + sorts (Configuration#metadata_filters /
|
|
4
|
+
# #metadata_sorts) applied by the database backend. The suite runs on SQLite;
|
|
5
|
+
# the PostgreSQL branches of the SQL are exercised by host applications.
|
|
6
|
+
RSpec.describe Rails::Contact::Search::Backends::Database do
|
|
7
|
+
around do |example|
|
|
8
|
+
config = Rails::Contact.configuration
|
|
9
|
+
original_filters = config.metadata_filters
|
|
10
|
+
original_sorts = config.metadata_sorts
|
|
11
|
+
config.metadata_filters = {
|
|
12
|
+
"tier" => { key: "quality_tier", type: :values, allowed: %w[hot warm standard] },
|
|
13
|
+
"window" => { key: "booking_window", type: :values },
|
|
14
|
+
"min_pax" => { key: "pax", type: :min_integer },
|
|
15
|
+
"min_score" => { key: "score", type: :min_numeric },
|
|
16
|
+
"vip" => { key: "tags", type: :tag, tag: "vip" },
|
|
17
|
+
"hide_junk" => { key: "authenticity", type: :exclude, value: "junk", default: :on }
|
|
18
|
+
}
|
|
19
|
+
config.metadata_sorts = { "score" => { key: "score" } }
|
|
20
|
+
example.run
|
|
21
|
+
ensure
|
|
22
|
+
config.metadata_filters = original_filters
|
|
23
|
+
config.metadata_sorts = original_sorts
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def make(name, meta)
|
|
27
|
+
create(:rails_contact_contact, given_name: name, metadata: meta.to_json)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
let!(:hot) do
|
|
31
|
+
make("Hot", "quality_tier" => "hot", "pax" => "12", "score" => "18.6",
|
|
32
|
+
"booking_window" => "This week", "tags" => [ "vip" ])
|
|
33
|
+
end
|
|
34
|
+
let!(:warm) { make("Warm", "quality_tier" => "warm", "pax" => "2", "score" => "9.1") }
|
|
35
|
+
let!(:junk) { make("Junk", "quality_tier" => "standard", "pax" => "TBD", "score" => "n/a") }
|
|
36
|
+
|
|
37
|
+
def records(filters)
|
|
38
|
+
described_class.new.search("", filters, page: 1, per_page: 25).records
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
describe ":values filters" do
|
|
42
|
+
it "matches any of the selected values" do
|
|
43
|
+
expect(records("tier" => [ "hot", "warm" ])).to match_array([ hot, warm ])
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
it "coerces a scalar like the other multi-selects" do
|
|
47
|
+
expect(records("tier" => "hot")).to eq([ hot ])
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
it "discards values outside the whitelist" do
|
|
51
|
+
expect(records("tier" => [ "hot", "'; DROP TABLE--" ])).to eq([ hot ])
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
it "applies no constraint when only junk was submitted" do
|
|
55
|
+
expect(records("tier" => [ "'; DROP TABLE--" ])).to match_array([ hot, warm, junk ])
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
it "matches free values when no whitelist is configured" do
|
|
59
|
+
expect(records("window" => [ "This week" ])).to eq([ hot ])
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
describe ":min_integer / :min_numeric filters" do
|
|
64
|
+
it "keeps rows at or above the floor and drops non-numeric values" do
|
|
65
|
+
expect(records("min_pax" => "3")).to eq([ hot ])
|
|
66
|
+
expect(records("min_pax" => "2")).to match_array([ hot, warm ])
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
it "accepts decimal floors" do
|
|
70
|
+
expect(records("min_score" => "9.1")).to match_array([ hot, warm ])
|
|
71
|
+
expect(records("min_score" => "10")).to eq([ hot ])
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
it "ignores a non-positive floor" do
|
|
75
|
+
expect(records("min_pax" => "0")).to match_array([ hot, warm, junk ])
|
|
76
|
+
expect(records("min_pax" => "abc")).to match_array([ hot, warm, junk ])
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
describe ":tag filter" do
|
|
81
|
+
it "matches contacts whose tag array contains the configured tag" do
|
|
82
|
+
expect(records("vip" => "1")).to eq([ hot ])
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
it "is inert when the checkbox is off" do
|
|
86
|
+
expect(records("vip" => "0")).to match_array([ hot, warm, junk ])
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
describe ":exclude filter (default on)" do
|
|
91
|
+
let!(:junk) { make("Junk2", "authenticity" => "junk") }
|
|
92
|
+
|
|
93
|
+
it "hides the excluded value when the param is ABSENT (default on)" do
|
|
94
|
+
expect(records({})).not_to include(junk)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
it "hides when the param is affirmative" do
|
|
98
|
+
expect(records("hide_junk" => "1")).not_to include(junk)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
it "shows everything when explicitly disabled with 0" do
|
|
102
|
+
expect(records("hide_junk" => "0")).to include(junk)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
it "never hides rows that simply lack the key (unclassified data)" do
|
|
106
|
+
expect(records({})).to include(hot, warm)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
describe "metadata sort" do
|
|
111
|
+
it "orders by the metadata number descending, junk last" do
|
|
112
|
+
expect(records("sort" => "score")).to eq([ hot, warm, junk ])
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
it "ignores an unknown sort value" do
|
|
116
|
+
expect { records("sort" => "bogus") }.not_to raise_error
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
it "drops the sort under a free-text query instead of erroring (DISTINCT branch)" do
|
|
120
|
+
result = described_class.new.search("Stone", { "sort" => "score" }, page: 1, per_page: 25)
|
|
121
|
+
expect(result.records).to match_array([ hot, warm, junk ]) # factory family_name
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
describe "key validation" do
|
|
126
|
+
it "rejects a config key that is not a plain identifier" do
|
|
127
|
+
Rails::Contact.configuration.metadata_filters = {
|
|
128
|
+
"bad" => { key: "x'; DROP", type: :values }
|
|
129
|
+
}
|
|
130
|
+
expect { records("bad" => [ "1" ]) }.to raise_error(ArgumentError, /must match/)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
it "rejects an unknown filter type" do
|
|
134
|
+
Rails::Contact.configuration.metadata_filters = {
|
|
135
|
+
"bad" => { key: "x", type: :wat }
|
|
136
|
+
}
|
|
137
|
+
expect { records("bad" => "1") }.to raise_error(ArgumentError, /unknown metadata filter type/)
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
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.16
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Kshitiz Sinha
|
|
@@ -106,7 +106,6 @@ files:
|
|
|
106
106
|
- ".rspec"
|
|
107
107
|
- CHANGELOG.md
|
|
108
108
|
- Gemfile
|
|
109
|
-
- LICENSE.txt
|
|
110
109
|
- MIT-LICENSE
|
|
111
110
|
- README.md
|
|
112
111
|
- Rakefile
|
|
@@ -151,6 +150,7 @@ files:
|
|
|
151
150
|
- bin/rails
|
|
152
151
|
- bin/rubocop
|
|
153
152
|
- config/routes.rb
|
|
153
|
+
- docs/CONFIGURATION.md
|
|
154
154
|
- docs/migration_guide.md
|
|
155
155
|
- docs/parity_matrix.md
|
|
156
156
|
- docs/product_decisions.md
|
|
@@ -195,6 +195,7 @@ files:
|
|
|
195
195
|
- spec/rails_helper.rb
|
|
196
196
|
- spec/search/database_backend_spec.rb
|
|
197
197
|
- spec/search/elasticsearch_backend_spec.rb
|
|
198
|
+
- spec/search/metadata_filters_spec.rb
|
|
198
199
|
- spec/services/merge_contacts_service_spec.rb
|
|
199
200
|
- spec/spec_helper.rb
|
|
200
201
|
- spec/views/contact_templates_spec.rb
|
data/LICENSE.txt
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
MIT License
|
|
2
|
-
|
|
3
|
-
Copyright (c) 2026 Kshitiz Sinha
|
|
4
|
-
|
|
5
|
-
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
-
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
-
in the Software without restriction, including without limitation the rights
|
|
8
|
-
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
-
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
-
furnished to do so, subject to the following conditions:
|
|
11
|
-
|
|
12
|
-
The above copyright notice and this permission notice shall be included in all
|
|
13
|
-
copies or substantial portions of the Software.
|
|
14
|
-
|
|
15
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
-
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
-
SOFTWARE.
|