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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a91f250f66bfd11f808193354a723ffb15cf9d8fefc5a52052bb11c7c7ca54e5
4
- data.tar.gz: 18871d517ce8e9a9fca74e1e74ff775d0eab9ca7208d84397f390754f1a6c280
3
+ metadata.gz: 9a7f0cfd6a248a8763b84379dc244b151d7edc7328527586f0416d378f73e5ee
4
+ data.tar.gz: 6be4ad0ae1d604fdf9ae66c3f8649573c4d2688ce3b93000bac1723fd08e616c
5
5
  SHA512:
6
- metadata.gz: 510ec523c32aedbfb1f867f990f06626e07a99a6170fbad9e96ef70cca883a34a097961cd92a4f335737fa77a103d611158e35ccf1a28069daba6e5ced1c4d49
7
- data.tar.gz: 8ebc2b2e8a8b28df33bce4f265e4750fa7282ebc37f1208204e1ad8f23b25a178d8b6bb335bc1c3d2f458d6a7d586feba392efa75e60612484c3b0205f9d8d14
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
- `rails-contact` is a mountable Rails engine for Google-Contacts-style contact management.
4
-
5
- It provides:
6
-
7
- - rich contact profile fields
8
- - multi-value contact methods (emails/phones/addresses/websites/events)
9
- - labels/tags
10
- - dynamic add/remove nested rows
11
- - Elasticsearch-backed search with DB fallback
12
- - Google sync scaffolding
13
- - merge and bulk-delete operations
14
- - Devise-style override generators
15
-
16
- ---
17
-
18
- ## Quickstart
19
-
20
- ### 1) Add gem
3
+ Google-shaped contacts for Rails: contact CRUD, Elasticsearch-backed search, and optional capped Google Contacts sync.
4
+
5
+ [![Gem Version](https://img.shields.io/gem/v/rails-contact.svg)](https://rubygems.org/gems/rails-contact)
6
+ [![CI](https://github.com/kshtzkr/rails-contact/actions/workflows/ci.yml/badge.svg)](https://github.com/kshtzkr/rails-contact/actions/workflows/ci.yml)
7
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](MIT-LICENSE)
8
+ [![Ruby](https://img.shields.io/badge/ruby-%3E%3D%203.1-cc342d.svg)](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", "~> 0.1.4"
63
+ gem "rails-contact"
24
64
  ```
25
65
 
66
+ Install it:
67
+
26
68
  ```bash
27
69
  bundle install
28
70
  ```
29
71
 
30
- ### 2) Install and generate schema
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
- ### 3) Mount engine routes
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
- rails_contact_for :contacts
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
- or explicit mount:
125
+ `given_name` is the only required field. Search the index the UI uses:
45
126
 
46
127
  ```ruby
47
- mount Rails::Contact::Engine => "/contacts", as: "rails_contact"
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
- `rails_contact_for :contact` is auto-normalized to `/contacts`.
134
+ ## Usage
51
135
 
52
- ### 4) Visit UI
136
+ ### Contacts and the UI
53
137
 
54
- - `/contacts`
55
- - `/contacts/new`
56
- - `/contacts/:id`
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
- ### Host layout and importmap
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
- By default the engine uses your host app layout named `application` so contacts pages match the rest of your UI (including Turbo and importmap). Styles for engine markup are included per page via `stylesheet_link_tag`, and nested “add row” / bulk-selection behavior uses small inline scripts so you do **not** need to pin gem JavaScript in the host importmap.
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
- To use the engine’s standalone layout and bundled `javascript_include_tag "rails/contact/application"` instead:
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.inherit_host_layout = false
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
- ## Generators
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
- rails generate rails:contact:install
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
- - `views` copies templates so host apps can customize UI.
82
- - `controllers` copies an override-ready contacts controller.
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
- ## Feature map
252
+ ### Merge and bulk delete
87
253
 
88
- ### Core profile fields
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
- - Prefix, first, middle, last, suffix, nickname
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
- ### Multi-value sections (dynamic)
263
+ ### Metadata filters and sorts
98
264
 
99
- - Emails
100
- - Phones
101
- - Addresses
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
- Rows can be added/removed dynamically in form UI.
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
- ### List/search
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
- - Search by name/email/phone/company/job title/labels
110
- - Filter by city/region/sync/starred
111
- - Sort by recent updates
286
+ ### Host layout and asset injection
112
287
 
113
- ### Actions
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
- - Bulk delete selected contacts
116
- - Merge source contact into target contact
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
- Initializer: `config/initializers/rails_contact.rb`
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 = :elasticsearch
127
- config.elasticsearch_url = ENV.fetch("ELASTICSEARCH_URL", "http://127.0.0.1:9200")
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.rolling_window_sort = :updated_at
131
- # Optional: appended to familyName in Google People API payloads only (blank = unchanged).
132
- # config.google_contact_family_name_suffix = "_by_vendor"
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
- ### Google sync UI: gem default vs host override
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
- Endpoints (used by the default partial):
325
+ ## Generators
147
326
 
148
- - `google_sync_rolling_window_contacts_path` — `GoogleSyncJob` (rolling window re-sync).
149
- - `google_sync_unsynced_contacts_path` `GoogleSyncUnsyncedJob` (contacts with no `google_resource_name`).
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
- `ContactsController#index` sets `@google_contacts_pending_sync` when sync is enabled.
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
- ## Testing and coverage
356
+ Mounting adds these routes under the mount path (`/contacts` by default):
165
357
 
166
- RSpec is the primary framework.
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
- Coverage is enforced with SimpleCov:
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
- - 100% line coverage target
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
- ## Release
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 for push.
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
- ## Extended docs
442
+ ## License
192
443
 
193
- - `docs/parity_matrix.md`
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, :region)
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 csv_import_id are multi-select filters: a <select multiple>
148
- # submits param[] (an array) plus a hidden param[]="" that Rails always
149
- # sends, so the blank must be stripped — otherwise IN ('', 'x') matches
150
- # every blank-valued row. Legacy bookmarks may still send a scalar
151
- # (?region=Europe); coerce those to a one-element array so the search
152
- # backend only ever sees an array (or no key at all).
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
@@ -1,5 +1,5 @@
1
1
  module Rails
2
2
  module Contact
3
- VERSION = "0.1.14"
3
+ VERSION = "0.1.16"
4
4
  end
5
5
  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.14
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.