rails-contact 0.1.13 → 0.1.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8f494c5d67c2e057d1e12c490eadf40a64137f3fa801a5654b405c642b13e70e
4
- data.tar.gz: 5543c60122860c18d71c90428f0d74eabee77d64e8b95270367b707b9ca0d26d
3
+ metadata.gz: 9a7f0cfd6a248a8763b84379dc244b151d7edc7328527586f0416d378f73e5ee
4
+ data.tar.gz: 6be4ad0ae1d604fdf9ae66c3f8649573c4d2688ce3b93000bac1723fd08e616c
5
5
  SHA512:
6
- metadata.gz: 9da8786e7cb36fff8eaa83399d41dd8ed517892dd9ecc2859ab3ef7d964f27f643798dc7bc39ee33f5b73ceef7b72354a5cf1ab5f65d75f9e6bb8edbbfb6e64f
7
- data.tar.gz: afa7e5ea14872cd7cd16229d60ada9baac2f2d638c79a16bd18a81831f307d2f3d24a68b25fb4c640fb7f607659d25e8bba727e13d1fddb6f1c69203d9b0fa5e
6
+ metadata.gz: c5b66c49e8c489d4565177a34ae71f210c1ccd8c4c36260aed0ef302e2a00ee766ac0c546f943c0f96bc2a843b3ba9c0a05b92518001487cae5a6f14de058714
7
+ data.tar.gz: e133e1d5768a619f4a33c7a5d5033351358a5a0490d1f387c52730406e1e5b30eead92ed25f48928f01beba7262ab476300dac959571cc5263d1d0ea7e29762f
data/CHANGELOG.md CHANGED
@@ -1,5 +1,31 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.16
4
+
5
+ - **New metadata filter type `:exclude`** — hides rows whose metadata key equals a configured value, e.g. `{ key: "authenticity", type: :exclude, value: "test", default: :on }`. With `default: :on` the filter applies even when the request param is absent (first page load, bookmarks) and an explicit false-y value (`"0"`) switches it off — built for default-on "hide test data" checkboxes. Rows missing the key always pass, so unclassified legacy data is never hidden.
6
+
7
+ ## [Unreleased]
8
+
9
+ ### Documentation
10
+
11
+ - Rewrote the README with a badge row, table of contents, requirements, a
12
+ gem-name/require/namespace note, and usage sections for search, Google sync,
13
+ merge, and metadata filters.
14
+ - Added `docs/CONFIGURATION.md` with the full settings reference (type, default,
15
+ environment variable, and behavior for every configuration option).
16
+ - Added `CONTRIBUTING.md`, `CODE_OF_CONDUCT.md`, and `SECURITY.md`.
17
+ - Removed the duplicate `LICENSE.txt` (identical to `MIT-LICENSE`) and dropped it
18
+ from the gemspec file list; removed committed `.gem` build artifacts.
19
+
20
+ ## 0.1.15
21
+
22
+ - **Host-configurable metadata filters and sorts.** `config.metadata_filters` declares filters over `Contact#metadata` by param name — `:values` (multi-select with optional whitelist), `:min_integer` / `:min_numeric` (numeric floors that filter out non-numeric stored values instead of casting them), and `:tag` (JSON-array containment checkbox). `config.metadata_sorts` declares `?sort=` options over numeric metadata (descending, non-numeric last, recency tiebreak). `filter_params` permits the configured params automatically, multi-selects get the same blank-strip/scalar-coercion normalization as `region`, and a metadata sort is dropped while a free-text `q` search is active (the search branch runs `SELECT DISTINCT`, which PostgreSQL cannot order by an out-of-select expression). Database backend only. Configured keys must be plain identifiers; anything else raises.
23
+
24
+ ## 0.1.14
25
+
26
+ - Contacts index filters: **`region` and `csv_import_id` are now multi-select**. `filter_params` permits `region: []` / `csv_import_id: []`, strips the hidden blank a `<select multiple>` submits, and coerces a legacy scalar (`?region=Europe`) to a one-element array. The database backend matches `metadata->>'csv_import_id' IN (...)` across the selected imports (a single id behaves exactly as before); `region` was already array-safe via `where(region_name:)`.
27
+ - Database backend: **search query is now sanitized internally** — LIKE metacharacters (`% _ \`) are escaped and input is capped at 200 chars, so a user typing `%` can no longer widen the match to every row. Host apps no longer need to wrap `search` to be safe.
28
+
3
29
  ## 0.1.12
4
30
 
5
31
  - `_google_sync_panel`: render the card whenever `google_sync_ui_on_index`; if `google_sync_enabled` is false, show short instructions instead of hiding the section entirely.
data/README.md CHANGED
@@ -1,196 +1,444 @@
1
1
  # rails-contact
2
2
 
3
- `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,7 +127,47 @@ module Rails
127
127
  end
128
128
 
129
129
  def filter_params
130
- params.permit(:city, :region, :sync_eligible, :starred, :travel_date_start, :travel_date_end, :contact_created_at_start, :contact_created_at_end, :csv_import_id)
130
+ config = Rails::Contact.configuration
131
+ # Host-configured metadata filters: :values filters arrive as
132
+ # multi-selects (arrays), every other type as a scalar. ?sort= is only
133
+ # a filter param when the host declared at least one metadata sort.
134
+ metadata_arrays = config.metadata_filters.select { |_, f| f[:type] == :values }.keys.map(&:to_sym)
135
+ metadata_scalars = config.metadata_filters.reject { |_, f| f[:type] == :values }.keys.map(&:to_sym)
136
+ metadata_scalars << :sort if config.metadata_sorts.any?
137
+
138
+ permitted = params.permit(
139
+ :city,
140
+ :sync_eligible,
141
+ :starred,
142
+ :travel_date_start,
143
+ :travel_date_end,
144
+ :contact_created_at_start,
145
+ :contact_created_at_end,
146
+ *metadata_scalars,
147
+ region: [],
148
+ csv_import_id: [],
149
+ **metadata_arrays.index_with { [] }
150
+ )
151
+
152
+ ([ :region, :csv_import_id ] + metadata_arrays).each { |key| normalize_multi_select!(permitted, key) }
153
+ permitted
154
+ end
155
+
156
+ # region, csv_import_id and every configured :values metadata filter are
157
+ # multi-selects: a <select multiple> submits param[] (an array) plus a
158
+ # hidden param[]="" that Rails always sends, so the blank must be
159
+ # stripped — otherwise IN ('', 'x') matches every blank-valued row.
160
+ # Legacy bookmarks may still send a scalar (?region=Europe); coerce those
161
+ # to a one-element array so the search backend only ever sees an array
162
+ # (or no key at all).
163
+ def normalize_multi_select!(permitted, key)
164
+ if permitted[key].blank? && params[key].is_a?(String) && params[key].present?
165
+ permitted[key] = [ params[key] ]
166
+ end
167
+ return unless permitted[key].is_a?(Array)
168
+
169
+ permitted[key] = permitted[key].reject(&:blank?)
170
+ permitted.delete(key) if permitted[key].empty?
131
171
  end
132
172
 
133
173
  def google_pending_sync_count