# CM Admin (Catalyst)
> Ruby on Rails admin panel gem. These docs also ship inside the gem: run `bundle show cm-admin` and read `docs-site/docs/` for docs matching the installed version.
This file contains all documentation content in a single document following the llmstxt.org standard.
## CM Admin
An admin gem for Ruby on Rails application. Get your admin panel setup running quickly.
### Installation
Add this line to your application's Gemfile:
```ruby
gem 'cm-admin'
```
And then execute:
`bundle install`
Or install it yourself as:
`gem install cm-admin`
### Usage
Install the gem
`rails g cm_admin:install`
### Demo
For demo check [here](http://cm-admin.labs.commutatus.com)
For demo repo check [here](https://github.com/commutatus/cm-admin-panel-demo)
### Development
For local development setup and connecting `cm-admin` to other local Rails apps, check the [Catalyst Setup Guide](/docs/catalyst-setup).
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
To install this gem onto your local machine, run `bundle exec rake install`.
Any markdown inside `docs-site/docs/` folder will be auto added in CM Docs.
### Deployment
To release a new version of the Gem you can use Github Actions.
Go to Actions tab in your repository and click on `Bump Gem` workflow.
You will see `Run workflow` button click on it and choose `Bump Type`, then click `Run Workflow` and it will bump the version of the gem and push the changes to the repository.
### Docs for AI Coding Agents
These docs are available in two agent-friendly forms:
- **Bundled with the gem.** The Markdown source of these docs ships inside `cm-admin`, so they always match the installed version. Find them with:
```bash
echo "$(bundle show cm-admin)/docs-site/docs"
```
- **On the website.** [`/llms.txt`](https://docs.cm-admin.commutatus.com/llms.txt) indexes every page, [`/llms-full.txt`](https://docs.cm-admin.commutatus.com/llms-full.txt) contains all docs in one file, and each page has a Markdown version at its URL plus `.md` (for example, [`/docs/Views/IndexDsl.md`](https://docs.cm-admin.commutatus.com/docs/Views/IndexDsl.md)).
Prefer the bundled docs: they need no network access and match the version in your `Gemfile.lock`.
### Contributing
Bug reports and pull requests are welcome on [GitHub](https://github.com/commutatus/cm-admin).
### License
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
---
## 2. Views
Basic views explained here
---
## DestroyDsl
### `cm_destroy`
Create a form for the destroy action.
#### Parameters
| Name | Type | Description |
| ------------------- | ------------------ | ------------------------------------------------------ |
| `display_name` | `String` | The display/button name of the page. |
| `page_title` | `String \| Symbol` | The title of the page, symbol will use a model method. |
| `page_description` | `String` | Description of the page. |
| `confirmation_text` | `String` | Confirmation text to show in the modal. |
| `partial` | `String` | Partial path of the page. |
| `redirect_to` | `Proc, nil` | Lambda to redirect after destroy. |
| `display_if` | `Proc` | Lambda to conditionally display the form. |
#### Example — Destroying page with redirect
```ruby
cm_destroy(display_name: 'Delete Post', page_title: "Delete Post", page_description: 'Are you sure you want to delete this Post?', confirmation_text: 'Delete', redirect_to: ->(current_object) { "/pages" })
```
#### Method Signature
```ruby
cm_destroy(display_name: 'Delete', page_title: nil, page_description: nil, partial: nil, redirect_to: nil, confirmation_text: 'Delete', display_if: ->(_arg) { true })
```
---
---
## Module: `DSL Methods`
## Module: `DSL Methods`
Defined in `lib/cm_admin/models/dsl_method.rb`
### Instance Method Summary
| Method | Description |
| ----------------------------------------------------- | ------------------------------------------------------------- |
| [`action_group`](#action_group) | Create a new group for actions. |
| [`alert_box`](#alert_box) | Adds a new alert to the current section. |
| [`all_db_columns`](#all_db_columns) | Get all DB columns for index layout automatically. |
| [`bulk_action`](#bulk_action) | Define a bulk action for selected records. |
| [`cm_edit`](#cm_edit) | Create the edit form page. |
| [`cm_index`](#cm_index) | Create index/list page with pagination. |
| [`cm_new`](#cm_new) | Create the “new/create” form page. |
| [`cm_destroy`](#cm_destroy) | Create the “destroy” form page. |
| [`cm_section`](#cm_section) | Define a layout section for forms/pages. |
| [`cm_show`](#cm_show) | Create the detail/show page. |
| [`cm_show_section`](#cm_show_section-deprecated) | **⚠️ Deprecated** — use `cm_section` instead. |
| [`cm_dashboard`](#cm_dashboard) | Create the dashboard page with widgets, filters, and widgets. |
| [`column`](#column) | Create a new column for index layout. |
| [`custom_action`](#custom_action) | Define a custom action beyond CRUD. |
| [`eager_load_associations`](#eager_load_associations) | Preload associations to avoid N+1 queries. |
| [`relationship_type`](#tab) | Specify `has_many` or `has_one` relationship for a tab. |
| [`field`](#field) | Add a field to the show page. |
| [`filter`](#filter) | Add filters to index/list page. |
| [`form_field`](#form_field) | Add a field to the new or edit form. |
| [`title_icon`](#title_icon) | Add an icon button with link and tooltip next to the title. |
| [`kanban_view`](#kanban_view) | Show index page as Kanban board view. |
| [`page_description`](#page_description) | Set page description. |
| [`page_title`](#page_title) | Set page title. |
| [`row`](#row) | Add a row wrapper for layout. |
| [`scope_list`](#scope_list) | Add scopes for index action. |
| [`sort`](#sort) | Configure sortable columns (preferred alternative). |
| [`tab`](#tab) | Add tabs on show page, usually for associations. |
### `cm_index`
Create a table view for the index page with pagination.
#### Parameters
| Name | Type | Description |
| ------------------ | -------- | ---------------------------------------------------------------------------------------- |
| `display_name` | `String` | The display name of the page. |
| `page_title` | `String` | The title of the page. If not provided, defaults to the formatted model name pluralized. |
| `page_description` | `String` | The description of the page. |
| `partial` | `String` | The partial path to render for the page. |
| `view_type` | `Symbol` | The type of index view. Allowed values: `:table`, `:card`, `:kanban`. Default: `:table`. |
---
#### Example — Basic index page
```ruby
cm_index do
page_title 'Post'
column :title
column :created_at, field_type: :date, format: '%d %b, %Y'
column :updated_at, field_type: :date, format: '%d %b, %Y', header: 'Last Updated At'
end
```
---
#### Display Types Supported
| View Type | Usage |
| --------- | ------------------------------------------------ |
| `:table` | Default table layout with pagination. |
| `:card` | Card-style layout (good for media-rich models). |
| `:kanban` | Kanban-style board grouped by a status or field. |
---
#### Notes
- `cm_index` must wrap all columns and filters meant for the index view.
- If no `page_title` is given, it automatically becomes the pluralized resource name.
- To build a Kanban index, use `view_type: :kanban` together with a `kanban_view` declaration.
---
#### Method Signature
```ruby
cm_index(display_name: nil, page_title: nil, page_description: nil, partial: nil, view_type: :table)
```
### `cm_show`
Create a view for the show page.
#### Parameters
| Name | Type | Description |
| ------------------ | ------------------ | ------------------------------------------------------------------------------------ |
| `display_name` | `String` | The display name of the page. |
| `page_title` | `String \| Symbol` | The title of the page. If a symbol is passed, it will be a method name on the model. |
| `page_description` | `String` | The description of the page. |
| `partial` | `String` | The partial path to render for the page. |
#### Example — Showing page
```ruby
cm_show page_title: :title do
tab :profile, '' do
cm_section 'Post Details' do
field :title
field :body, field_type: :rich_text
field :is_featured
field :status, field_type: :badge, badge_class: STATUS_TAG_COLOR
end
end
end
```
#### Method Signature
```ruby
cm_show(display_name: nil, page_title: nil, page_description: nil, partial: nil)
```
---
### `cm_edit`
Create a form for the edit action.
#### Parameters
| Name | Type | Description |
| ------------------ | ------------------ | ------------------------------------------------------ |
| `display_name` | `String` | The display/button name of the page. |
| `page_title` | `String \| Symbol` | The title of the page, symbol will use a model method. |
| `page_description` | `String` | Description of the page. |
| `partial` | `String` | Partial path of the page. |
| `redirect_to` | `Proc, nil` | Lambda to redirect after update. |
| `display_if` | `Proc` | Lambda to conditionally display the form. |
#### Example — Editing page with redirect
```ruby
cm_edit(display_name: 'Edit Post', page_title: "Edit Post", page_description: 'Enter all details to edit Post', redirect_to: ->(current_object) { "/pages/#{current_object.id}" }) do
cm_section 'Details' do
form_field :title, input_type: :string
form_field :body, input_type: :rich_text
end
end
```
#### Method Signature
```ruby
cm_edit(display_name: 'Edit', page_title: nil, page_description: nil, partial: nil, redirect_to: nil, display_if: ->(_arg) { true })
```
---
### `cm_new`
Create a form for the new action.
#### Parameters
| Name | Type | Description |
| --------------------- | ----------- | -------------------------------------------------- |
| `display_name` | `String` | The display/button name of the page. |
| `page_title` | `String` | The title of the page. |
| `page_description` | `String` | Description of the page. |
| `partial` | `String` | Partial path of the page. |
| `redirect_to` | `Proc, nil` | Lambda to redirect after create. |
| `allow_create_action` | `Proc` | Lambda to control whether the Add button is shown. |
#### Example — Creating a new page with redirect
```ruby
cm_new(display_name: 'Add Post', page_title: "Add Post", page_description: 'Enter all details to add Post', redirect_to: ->(current_object) { "/pages/#{current_object.id}" }) do
cm_section 'Details' do
form_field :title, input_type: :string
form_field :body, input_type: :rich_text
end
end
```
#### Method Signature
```ruby
cm_new(display_name: 'Add', page_title: nil, page_description: nil, partial: nil, redirect_to: nil, allow_create_action: -> { true })
```
---
### `cm_destroy`
Create a form for the destroy action.
#### Parameters
| Name | Type | Description |
| ------------------- | ------------------ | ------------------------------------------------------ |
| `display_name` | `String` | The display/button name of the page. |
| `page_title` | `String \| Symbol` | The title of the page, symbol will use a model method. |
| `page_description` | `String` | Description of the page. |
| `confirmation_text` | `String` | Confirmation text to show in the modal. |
| `partial` | `String` | Partial path of the page. |
| `redirect_to` | `Proc, nil` | Lambda to redirect after destroy. |
| `display_if` | `Proc` | Lambda to conditionally display the form. |
#### Example — Destroying page with redirect
```ruby
cm_destroy(display_name: 'Delete Post', page_title: "Delete Post", page_description: 'Are you sure you want to delete this Post?', confirmation_text: 'Delete', redirect_to: ->(current_object) { "/pages" })
```
#### Method Signature
```ruby
cm_destroy(display_name: 'Delete', page_title: nil, page_description: nil, partial: nil, redirect_to: nil, confirmation_text: 'Delete', display_if: ->(_arg) { true })
```
---
### `page_title`
Set page title for the current action.
| Name | Type | Description |
| ------- | -------- | ------------------ |
| `title` | `String` | Title of the page. |
#### Method Signature
```ruby
page_title(title)
```
---
### `page_description`
Set page description for the current action. Rendered below the page title in the header on every action that displays the top navbar.
The description supports rich text (HTML) — text formatting, lists and hyperlinks are allowed. Media elements (images, videos, iframes) are stripped by sanitization. Links open in a new tab automatically. Long descriptions are truncated to two lines with a **View more / View less** toggle.
| Name | Type | Description |
| ------------- | -------- | ------------------------ |
| `description` | `String` | Description of the page (HTML allowed). |
#### Method Signature
```ruby
page_description(description)
```
#### Example
```ruby
cm_index do
page_description '
This is an optional rich text field and its content is truncated after two lines to keep the layout clean and concise.
'
)
```
##### With Custom From Email
```ruby
CmAdmin.send_email(
from: 'info@commutatus.com',
to: ['anbu@commutatus.com'],
subject: "[DMSv2] New Prospect Created!",
partial_file_path: '/cm_admin/mailers/prospect_admin_notification',
partial_locals: { user: User.last }
)
```
##### With Link Button
```ruby
CmAdmin.send_email(
to: ['info@commutatus.com'],
subject: "New User Created!",
partial_file_path: '/cm_admin/mailers/user_admin_notification',
partial_locals: { user: User.last },
button_text: 'View user',
button_link: 'http://domain.com/user/1'
)
```
##### Simple HTML Content
```ruby
CmAdmin.send_email(
to: ['anbu@commutatus.com'],
subject: "[DMSv2] New Prospect Created!",
body: '
Simple HTML content to be added here.
'
)
```
> **Note:** `button_text` and `button_link` are optional parameters.
##### Email with Blank Template
```ruby
CmAdmin.send_email(
to: ['anbu@commutatus.com'],
subject: "[DMSv2] New Prospect Created!",
template: :blank,
body: '
Simple HTML content to be added here.
'
)
```
##### Email with Attachments
```ruby
CmAdmin.send_email(
to: ['anbu@commutatus.com'],
subject: "[DMSv2] New Prospect Created!",
body: '
Simple HTML content to be added here.
',
attachments: [
{
filename: 'sample.pdf',
file_content: '...'
},
{
filename: 'sample.pdf',
url_path: '...'
}
]
)
```
### Configuration
#### Email Logging
By default, email logging is disabled. To enable it, add the following to your initializer:
```ruby
CmAdmin.config.enable_email_logging = true
```
When enabled, all emails sent via `CmAdmin.send_email` will be logged in the `cm_email_logs` table with delivery status, failure reasons, and recipient tracking.
To set up the required table, run the generator:
```bash
rails g cm_admin:add_email_logs
rails db:migrate
```
### Important Notes
#### Attachments
- Attachments should be an array of hashes
- Each hash should have exactly two keys:
1. `filename` - Name of the file
2. `file_content` OR `url_path` - Either base64 encoded content or URL path
#### File Content Encoding
The `file_content` should be a base64 encoded string of the file content/blob. You can use:
1. **From file path:** `Base64.encode64(File.open(file_path).read)`
2. **From URL:** `Base64.encode64(URI.open(file_url).read)`
---
## Webhook Logging in CM Admin
### Overview
CM Admin provides a base `WebhooksController` that handles incoming webhook requests with async processing and a debug UI. Vendor-specific webhook controllers inherit from this base class and override the methods they need.
### Configuration
#### Enabling Webhook Logging
By default, webhook logging is disabled. To enable it, add the following to your initializer:
```ruby
CmAdmin.config.enable_webhook_processing = true
```
When enabled, webhook events are stored as `CmWebhookEvent` records and processed asynchronously via `ProcessWebhookEventJob`. The **Webhook Events** admin panel shows all logged events with their service, event type, payload, status, and error information.
When disabled, webhooks are still processed — but synchronously via an overridden `process_webhook` (no `CmWebhookEvent` record is created). This separates **logging** from **processing**: the flag controls whether events are persisted, not whether they're handled.
#### Postmark Webhook Credentials
The built-in Postmark webhook integration uses HTTP Basic Auth. Credentials are stored in Rails encrypted credentials. Add the following to your credentials file (`rails credentials:edit`):
```yaml
postmark:
webhook_username: your_username
webhook_password: your_password
```
The controller reads these via `Rails.application.credentials.dig(:postmark, :webhook_username)` and `Rails.application.credentials.dig(:postmark, :webhook_password)`.
#### Setting Up the Database Table
Run the generator to create the migration:
```bash
rails g cm_admin:add_webhook_events
rails db:migrate
```
> **Existing installs:** to link webhook events to recipients and capture clicked links (required for per-recipient open/click tracking and the Events tab), also run:
>
> ```bash
> rails g cm_admin:add_webhook_event_recipient
> rails db:migrate
> ```
>
> The generated migration is idempotent — it skips columns that already exist (`recipient_type`/`recipient_id` + index, and `original_link`).
> **Note:** Webhook payloads are stored as ActiveStorage attachments (`raw_payload`). Ensure ActiveStorage is installed and configured in your host app (`rails active_storage:install`).
#### Host App Services
The gem defines `cm_service` for gem-managed services (e.g. `postmark`). Host apps can define their own `host_service` enum for their own webhook sources:
```ruby
# config/initializers/cm_admin.rb
Rails.application.reloader.to_prepare do
CmWebhookEvent.class_eval do
enum :host_service, { chatgpt: 0, gemini: 1, gemini_batch: 2 }
end
end
```
Integer values are scoped per-column — `cm_service` and `host_service` are independent and may use overlapping integers (e.g. `cm_service: 0 = postmark`, `host_service: 0 = chatgpt`).
### Usage
#### Base Controller
`CmAdmin::WebhooksController` provides the shared flow for all webhook endpoints:
**When `enable_webhook_processing` is enabled (default flow):**
1. **Authenticates** the request via the `authenticate_webhook` before_action (override in subclass — unauthenticated requests never create `CmWebhookEvent` records)
2. **Finds** the existing webhook event via `find_existing_webhook_event` (override in subclass)
3. **Creates** one via `create_webhook_event` when no pre-created event exists (override in subclass — e.g. Postmark)
4. **Attaches** the incoming payload via `attach_webhook_payload` (skipped when `raw_payload` is already attached)
5. **Updates** the event status to `received` (only if it isn't already `received` or `processed`)
6. **Enqueues** `ProcessWebhookEventJob` for non-log-only events
7. Returns `200 OK` immediately
**When `enable_webhook_processing` is disabled:**
1. No `CmWebhookEvent` record is created
2. `process_webhook` is called — the default implementation is a no-op, so override it in the subclass to process `webhook_payload` synchronously (e.g. Postmark calls `PostmarkWebhookService.process(webhook_payload)`)
3. Returns `200 OK`
Host apps should pre-create webhook events before calling external services (e.g. embedding event.id in the callback URL); the controller then finds the existing event via `find_existing_webhook_event`. For gem-managed services like Postmark where no pre-creation is possible, the subclass implements `create_webhook_event` to create the record when the webhook arrives.
The job calls `event.process!` which:
- Returns early if the event is log-only (no service set)
- Calls `perform_cm_processing` (dispatches to `process_#{cm_service}_event`)
- Calls `perform_host_processing` (empty by default — override in host app via class_eval)
- Updates status to `processed` on success (with `error_message` set from `processing_note` when provided) or `failed` with the raised error message on failure
##### Methods to Override
| Method | Required | Description |
| --- | --- | --- |
| `authenticate_webhook` | Recommended | Verifies the request before anything is logged or processed. Registered as a `before_action` on the base controller — override with your auth logic (HTTP Basic, HMAC signature, etc.) and render/abort to reject. Defaults to no-op. |
| `find_existing_webhook_event` | Yes | Returns the pre-created `CmWebhookEvent` or `nil`. Defaults to `nil`. |
| `create_webhook_event` | When not pre-created | Creates a `CmWebhookEvent` when `find_existing_webhook_event` returns `nil`. Used by gem-managed services that cannot pre-create (e.g. Postmark). Defaults to `nil`. |
| `attach_webhook_payload` | Recommended | Attaches the incoming webhook payload to `raw_payload`. Called after the event is found or created. Default implementation attaches `webhook_payload.to_json` unless `raw_payload` is already attached. Override if you need custom attachment logic. |
| `process_webhook` | When logging disabled | Processes the webhook synchronously when `enable_webhook_processing` is `false`. The default implementation enqueues `ProcessWebhookEventJob` for logged events — call `super` to keep async processing. |
| `webhook_event_type` | Yes | Returns the event type string. Defaults to `params[:event_type]`. |
| `webhook_payload` | Yes | Returns the webhook payload as a hash. Must be implemented by every subclass to explicitly permit safe fields. Stored as a JSON attachment via `raw_payload`. |
| `webhook_event_id` | Optional | Returns the external ID for deduplication. Defaults to `nil`. |
| `webhook_eventable` | Optional | Returns the associated record (polymorphic). Defaults to `nil`. |
#### Example: Custom Webhook Controller
```ruby
module CmAdmin
class StripeWebhooksController < WebhooksController
private
# authenticate_webhook is already wired as a before_action by the base
# controller — just override the method with your auth logic.
def authenticate_webhook
# Your authentication logic here
end
def find_existing_webhook_event
::CmWebhookEvent.find_by(host_service: :stripe, event_id: webhook_event_id)
end
def webhook_event_type
params[:type]
end
def webhook_event_id
request.headers['Stripe-Idempotency-Key']
end
end
end
```
#### Pre-create Pattern (Host App)
Host apps pre-create webhook events before calling external services, embedding the event ID in the callback URL. When the webhook arrives, the controller finds the existing event and processes it:
```ruby
# Before calling the external service:
event = CmWebhookEvent.create!(
host_service: :chatgpt,
event_type: 'BrightData',
event_id: 'evt_123',
status: :created
)
event.raw_payload.attach(
io: StringIO.new('{}'),
filename: 'payload.json',
content_type: 'application/json'
)
# In the webhook controller:
module CmAdmin
class BrightDataWebhooksController < WebhooksController
private
def find_existing_webhook_event
::CmWebhookEvent.find_by(event_id: params[:event_id])
end
def webhook_event_type
'BrightData'
end
end
end
```
#### Routing
Webhook endpoints are namespaced under `webhooks`:
```ruby
# config/routes.rb (inside CmAdmin::Engine.routes.draw)
scope :webhooks do
post ':service/:event_id', to: CmAdmin::WebhookRouter, as: :webhook_event
post ':service', to: CmAdmin::WebhookRouter, as: :webhook
end
```
`CmAdmin::WebhookRouter` resolves `:service` to a controller by convention — `service=postmark` dispatches to `CmAdmin::PostmarkWebhooksController#receive`, `service=bright_data` to `CmAdmin::BrightDataWebhooksController#receive` — and responds with `404` when no matching `WebhooksController` subclass exists. The optional `:event_id` segment is exposed as `params[:event_id]`, which pre-create flows use to look up the event.
Any `CmAdmin::XxxWebhooksController < CmAdmin::WebhooksController` is therefore automatically reachable at `POST /cm_admin/webhooks/xxx` (and `POST /cm_admin/webhooks/xxx/:event_id`) — no host-app route needed. Add a custom route only when you need a non-conventional path:
```ruby
# config/routes.rb (host app)
post '/webhook/bright_data/:event_id', to: 'cm_admin/bright_data_webhooks#receive'
```
#### Built-in Postmark Integration
CM Admin includes a `PostmarkWebhooksController` that inherits from `WebhooksController`. It:
- Authenticates using HTTP Basic Auth with credentials from Rails encrypted credentials
- Validates the presence of `RecordType` parameter
- Creates the webhook event with `cm_service: :postmark` and `status: :created` (Postmark sends separate webhooks per recipient for the same `MessageID`, so events are not deduped by `MessageID` alone; identical redeliveries — same message, type, recipient and event timestamp — resolve to the original event via `find_existing_webhook_event`, so replayed webhooks don't double-count opens and clicks)
- Stores `MessageID` as the `event_id`
- Links to the associated `CmEmailLog` via polymorphic `eventable` (found by `MessageID`)
- Links the **recipient** the event belongs to: the log's `record` is used when it responds to `email` and matches the payload's `Recipient`/`Email` — events for CC/BCC recipients of the same message stay unlinked (`recipient: nil`) so they're never attributed to the To recipient
- Stores the clicked URL in `original_link` for Click events
- Permits the full payload for storage: message/delivery/bounce fields, `FirstOpen`, `OriginalLink`, `ClickLocation`, `UserAgent`, `Platform`, and the nested `Client`/`OS`/`Geo` objects
- When logging is disabled, processes inline via `PostmarkWebhookService.process(webhook_payload)`
The route is available at `POST /cm_admin/webhooks/postmark` (via the generic webhook router).
### CmWebhookEvent Model
The `CmWebhookEvent` model stores the following fields:
| Field | Type | Description |
| --- | --- | --- |
| `cm_service` | integer (enum) | Gem-managed service (e.g. `postmark: 0`) |
| `host_service` | integer (enum) | Host-app service — defined by host app |
| `event_type` | string | Event type from the webhook payload (required) |
| `raw_payload` | ActiveStorage attachment | Full webhook payload stored as a file attachment |
| `status` | integer (enum) | `created` (0), `received` (1), `processed` (2), or `failed` (3) |
| `event_id` | string | External ID for deduplication |
| `uuid` | string | Stable identifier assigned on creation (`SecureRandom`, backed by a unique index) |
| `error_message` | text | Error message if processing failed |
| `processed_at` | datetime | When the webhook was processed |
| `eventable` | polymorphic | Optional associated record (e.g. the `CmEmailLog` the webhook arrived for) |
| `recipient` | polymorphic | Optional recipient the event belongs to (e.g. `EmailRecipient`) — populated at receipt by matching the payload's recipient email against the log's record |
| `original_link` | string | For Click events, the clicked URL (`OriginalLink`), denormalized at receipt so clicks can be grouped and counted in SQL |
The `payload` method reads and parses the attachment on demand (cached per instance):
```ruby
event.payload # => { "MessageID" => "...", "RecordType" => "Delivery", ... }
```
There are no unique constraints on incoming webhook events (the only unique index is on `uuid`) — each incoming webhook is logged as a separate record. Processing idempotency (e.g. updating the same `CmEmailLog` recipient status for duplicate Postmark webhooks) must be handled by the service-specific processing logic (`PostmarkWebhookService` → `update_recipient_delivery_status`).
#### Processing Dispatch
When `process!` is called (via `ProcessWebhookEventJob`):
1. Returns early if the event is log-only (neither `cm_service` nor `host_service` set)
2. **perform_cm_processing** — if `cm_service` is set, calls `process_#{cm_service}_event` (e.g. `process_postmark_event`)
3. **perform_host_processing** — empty by default; host apps override via class_eval to add app-specific processing
4. Updates status to `processed` on success or `failed` on error
#### Log-only Events
Events with neither `cm_service` nor `host_service` set are treated as log-only — they record the webhook receipt and payload without any processing. The `process!` method returns early and the status stays at `received`. This is useful for host apps that handle processing separately and just want the audit trail in the admin UI.
### Admin UI
When webhook logging is enabled, a **Webhook Events** section appears in the CM Admin sidebar with:
- **Index view** — filterable by search (ID, UUID, event type, event ID, error), status, service, event type, and created date
- **Show view** — detailed view with UUID, payload, error information, and processing timestamps
- **Status badges** — color-coded: gray (created), blue (received), green (processed), red (failed)
---
## CmAdmin Date & Date-Time Display Format Configuration
Configure how dates and date-times are displayed across the **cm-admin** interface.
---
### 1. Configuration
In `cm_admin.rb` (initializer):
```ruby
CmAdmin.configure do |config|
config.display_date_format = 'd-m-Y' # Default: 'd-m-Y'
config.display_datetime_format = 'd-m-Y H:i' # Default: 'd-m-Y H:i'
end
```
Use any format supported by [flatpickr](https://flatpickr.js.org/formatting/).
---
### 2. How It Works
* Formats are exposed as JavaScript variables:
```js
window.cmAdminConfig.displayDateFormat
window.cmAdminConfig.displayDateTimeFormat
```
* Used by flatpickr to render pickers consistently.
---
### 3. Example
To use format `Y/m/d` for date and `Y/m/d H:i` for date-time:
```ruby
CmAdmin.configure do |config|
config.display_date_format = 'Y/m/d'
config.display_datetime_format = 'Y/m/d H:i'
end
```
---
### 4. Notes
* If not set, defaults will apply. (See above)
---
### 5. Year Select Dropdown
By default, date pickers include a year-select dropdown in the month navigation, making it easy to jump to years far in the past or future.
To use flatpickr's **default** year navigation for a specific field instead, set the `data-default-year-selector` attribute on the input:
```ruby
form_field :birth_date, input_type: :date, html_attrs: { data: { default_year_selector: true } }
```
The default year navigation lets users type any year in the month header. Unlike the year-select dropdown, it is not limited to a ±50-year window, so set `data-min-date` / `data-max-date` (ISO `YYYY-MM-DD`) if the field needs an explicit range.
---
### 6. References
- [flatpickr Date Formatting Docs](https://flatpickr.js.org/formatting/)
- [Ruby Date and Time Formats](https://ruby-doc.org/3.4/Time.html#method-i-strftime)
---
---
## cmSelect Integration 🎯
This module provides a wrapper around TomSelect for creating dynamic select dropdowns with Fetch support.
:::warning Important
**With cmSelect, `ajax_url` is changed to `options_url`**
:::
### Overview
`window.cmSelect` is a global function that initializes TomSelect on a given element with optional fetch configuration for fetching options dynamically.
### Basic Usage
#### Manual Initialization
1. Pass element to `window.cmSelect`, like `window.cmSelect(document.querySelector("#my-select"), ...);`
2. Pass a selector to `window.cmSelect`, like `window.cmSelect("#my-select", ...);`
3. It works on a single select element at a time.
4. Default search parameters is `search`
5. To set the value of the select manually from JavaScript, use `element.tomselect.setValue(value)`
```javascript
window.cmSelect(document.querySelector("#my-select"));
window.cmSelect("#my-select");
```
### Fetch Configuration
#### Basic Fetch Setup
```javascript
window.cmSelect(element, {
url: "/cm_admin/users/options"
});
```
#### Full Options
```javascript
window.cmSelect(element, {
url: "/cm_admin/users/options",
queryData: {
some_id: 3,
some_val: 5
},
onSuccess: function (data) {
console.log("Data loaded:", data);
},
onError: function (error) {
console.error("Failed to load:", error);
}
});
```
### Options Reference
| Option | Type | Description |
| -------------------- | ---------- | --------------------------------------------------------------------------------------------------- |
| `url` | `String` | The endpoint URL to fetch options from. When provided, enables Fetch mode. |
| `queryData` | `Object` | Additional query parameters to append to the URL. |
| `onSuccess` | `Function` | Callback function called with the response data on successful fetch. |
| `onError` | `Function` | Callback function called with the error on fetch failure. |
| `reinitializeSelect` | `Boolean` | Default: `true`. Whether to reinitialize the TomSelect instance (destroy current and reinitialize). |
### Response Convention
The server endpoint **must** return an array of hashes with `id` and `title` keys:
| Key | Type | Description |
| ------- | ----------------- | ------------------------------------ |
| `id` | `String`/`Number` | The value to be stored on selection. |
| `title` | `String` | The label displayed in the dropdown. |
**Example Response:**
```json
[
{ "id": 1, "title": "User One" },
{ "id": 2, "title": "User Two" },
{ "id": 3, "title": "User Three" }
]
```
**Ruby Example:**
```ruby
users.map { |user| { id: user.id, title: user.name } }
```
💡 **Note**: If a TomSelect instance already exists on the element, it will be destroyed and re-initialized with the new configuration.
---
## Comment and Mention 💬
### Overview
The Comment and Mention feature allows users to add comments on any model's show page and mention users using the `@` trigger. Powered by **Lexxy**, a rich text editor with @-mention autocomplete support.
---
### Quick Start
#### 1. Generate Migrations
```bash
rails g cm_admin:add_comments_with_mentions
rails db:migrate
```
Creates two tables:
- `cm_comments` - Stores comments
- `cm_user_mentions` - Tracks mentions
#### 2. Set Up Commentable Model
Add the comments association to any model:
```ruby
class Order < ApplicationRecord
has_many :comments, as: :commentable, dependent: :destroy, class_name: 'CmComment'
end
```
#### 3. Configure Comments Tab
```ruby
cm_admin do
comments_tab mentions_model: ::User
cm_index do
column :id
# other columns
end
end
```
---
### Configuration
#### `comments_tab` Options
| Option | Type | Required | Default | Description |
| ---------------- | --------- | -------- | ------- | ---------------------------------------------- |
| `mentions_model` | `Class` | **Yes** | - | The model class for @mentions (e.g., `::User`) |
| `position` | `Integer` | No | `1` | Tab position on the show page |
| `default_scopes` | `Array` | No | `[]` | Scopes to apply on mentions_model query |
#### Example with All Options
```ruby
comments_tab mentions_model: ::User, position: 2, default_scopes: %i[active verified]
```
---
### Display Name
This is used to display the name of the user in the comment and mentionable dropdown.
The system looks for `cm_display_name` on the mentionable model. Falls back to `full_name` if not defined.
```ruby
class User < ApplicationRecord
def cm_display_name
"#{first_name} #{last_name}"
end
end
```
---
### Customization
#### Custom Mention Partial (Optional)
This is used to customize how mentions render in comments.
The `CmActionTextAttachable` concern is automatically included for mention functionality. To customize how mentions render in comments, override `to_attachable_partial_path`:
```ruby
class User < ApplicationRecord
include CmActionTextAttachable
def to_attachable_partial_path
"users/mentioned" # renders app/views/users/_mentioned.html.slim
end
end
```
**NOTES:** Default partial path: `cm_admin/main/mentioned`
#### Overriding Email Notifications
1. `MentionNotifiable` is a concern that is included in the `CmUserMention` model. It provides the `send_email_to_mentioned_user` method.
2. When a user is mentioned, `MentionNotifiable#send_email_to_mentioned_user` is called automatically. This sends an email to the mentioned user with:
- **Subject:** "💬 New Comment on Model-Name"
- **Button:** Link to view the comment
3. To customize, create a concern with same name `MentionNotifiable` on application level and override the `send_email_to_mentioned_user` method.
```ruby
module MentionNotifiable
extend ActiveSupport::Concern
included do
def send_email_to_mentioned_user
subject = "💬 New Comment on Task [#{mentionable.commentable.formatted_id}]"
CmAdmin.send_email(
to: [mentioned.email, mentionable.commentable.assignees.map(&:email)].flatten.uniq,
subject: subject,
partial_file_path: 'cm_admin/mailers/comments/mention_notification',
partial_locals: { comment: mentionable },
button_text: 'View Comments',
button_link: mentionable.cm_link
)
end
end
end
```
To disable email notifications entirely:
```ruby
module MentionNotifiable
extend ActiveSupport::Concern
included do
def send_email_to_mentioned_user
end
end
end
```
#### Custom Email Template
Override the default partial at on application level:
`app/views/cm_admin/mailers/comments/mention_notification.html.slim`
---
### How Lexxy Works
Lexxy provides the @-mention functionality:
| Feature | Description |
| -------------------- | ----------------------------------------------- |
| **Trigger** | Type `@` to open autocomplete dropdown |
| **Remote Filtering** | Searches mentions_model from server as you type |
| **Space Support** | Handles names with spaces (e.g., "John Doe") |
When a user is mentioned:
1. Mention renders as a clickable link in the comment
2. `CmUserMention` record is created
3. Email notification sent (if not overridden)
---
### Models Reference
#### CmComment
| Association | Type | Description |
| ------------- | ----------- | ---------------------------- |
| `commenter` | Polymorphic | User who created the comment |
| `commentable` | Polymorphic | Model being commented on |
| `content` | Rich Text | Comment content (ActionText) |
#### CmUserMention
| Association | Type | Description |
| ------------- | ----------- | ------------------------------ |
| `mentioned` | Polymorphic | User who was mentioned |
| `mentionable` | Polymorphic | Comment containing the mention |
---
### Requirements
- **ActionText** must be installed
- **Current.user** must be set (used as commenter)
- **paper_trail** gem (both models include `has_paper_trail`)
---
## 🕒 **Cron Job Setup Documentation**
### 📌 Overview
The **CM Cron Job** feature allows you to manage scheduled tasks directly within the `cm-admin` interface.
You can **create**, **edit**, **enable**, **disable**, **execute**, and **view logs** for each cron job from a user-friendly dashboard.
---
### ✨ Features
* 🔍 **Filters**: Search cron jobs by:
* Name
* Status
* Last run time
* Last run status
* 📊 **Sortable Columns**:
* Name
* Updated time
* Created time
* ⚙️ **Custom Actions**:
* ✅ Enable or ❌ Disable a cron job
* ▶️ Manually execute a cron job
* 🔎 **Detailed Views**:
* Job information: name, cron string, command, status, last run time, output
* Access logs for each run
---
### 🛠️ Setup Instructions
#### 1. 📦 Run the CMAdmin Scheduler Generator
Generate the necessary files:
```bash
rails g cm_admin:add_scheduler
```
---
#### 2. 🧱 Ensure Required Models Exist
Your application must include the following models:
* `CmCronJob`
* `CmCronJobLog`
---
#### 3. 🔗 Integrate Sidekiq
1. Run database migrations:
```bash
rails db:migrate
```
2. Start **Sidekiq** to enable background processing.
> 💡 **Note**:
>
> * All `cm_cron` jobs are **removed from Sidekiq** when Sidekiq is shut down.
> * When Sidekiq is started, all **enabled** cron jobs will be **automatically re-added** to the queue.
---
#### 4. 🧩 Use Custom Actions
Available via the `CmAdmin::CmCronJob` concern:
* 🚫 **Disable Job** – Deactivates an active job.
* 🟢 **Enable Job** – Activates a disabled job.
* 🔄 **Execute Job** – Runs a job immediately.
* 🗑️ **Delete Job** – Removes the job permanently.
> ⚠️ **Note**: Newly created jobs are disabled by default. Use the **Enable** button to activate them.
---
#### 5. 📄 View Cron Job Logs
Each job’s **Logs** tab includes:
* 🕑 Start and completion timestamps
* 📈 Execution status
* 🔍 Links to detailed log output
---
### 🧪 Example Usage
#### ➕ Add a New Cron Job
1. Navigate to the **Add Cron Job** page.
2. Provide the following details:
* 🏷️ **Name**: A unique identifier for the job.
* 🕰️ **Cron String**: Set the schedule using a valid cron expression. Common presets:
* `@hourly` – Every hour
* `@daily` – Every day
* `@weekly` – Every week
* `@monthly` – Every month
* `@yearly` – Every year
* 💻 **Command**: The shell command to be executed.
3. Click **Save** ✅ to create the job.
---
#### ✏️ Edit an Existing Cron Job
1. Open the **Edit Cron Job** page for the desired job.
2. Modify the necessary fields.
3. Click **Save** 💾 to update the job.
---
#### 📜 View Job Logs
1. Visit the **Logs** tab on a cron job’s detail page.
2. Review:
* ⏱️ Start and end times
* 📊 Status of execution
* 🔗 Link to output logs
---
### ✅ Conclusion
The **CM Cron Job** feature in `cm-admin` empowers you to manage scheduled tasks with confidence. With detailed controls, logging, and Sidekiq support, you can automate background operations efficiently and reliably. 🚀
---
## CSS Bundler
### Overview
CM Admin includes a CSS bundling system that compiles Sass/SCSS stylesheets. The bundler handles both the core CM Admin stylesheets and your application's custom styles
The system is built around a shared helper module `CmAdminCssBundler` that provides common functionality for running npm-based CSS build processes.
### How It Works
The CSS bundler follows these steps:
1. **Install dependencies** — Runs `npm install` in the CM Admin engine directory
2. **Execute build script** — Runs the specified npm script (e.g., `build:css`, `watch:css`)
3. **Compile stylesheets** — Uses Sass to compile SCSS files to CSS with optimized flags
### Application Setup
#### Step 1 — Ensure application stylesheet exists
Create or update your main application stylesheet:
```scss
// app/assets/stylesheets/application.scss
```
**NOTES**:
1. File should be scss
2. No File on application should have, remove from `app/assets/stylesheets/application.scss` and `cm_admin/app/assets/stylesheets/cm_admin/custom.css`
```css
*= require_tree .
*= require_self
```
3. you can include any other stylesheets using `@import` directive in application.scss, it is the entry point for all your stylesheets.
#### Step 2 — Remove sassc-rails gem
Remove the `sassc-rails` gem from your Gemfile since CM Admin's CSS bundler handles Sass compilation:
```ruby
# Gemfile
# Remove this line
gem 'sassc-rails'
```
Then run `bundle install` to update your dependencies.
#### Step 3 — Remove stylesheet inclusion from manifest.js
```javascript
// app/assets/config/manifest.js
//= link_directory ../stylesheets .css
```
#### Step 4 — Add CSS build to manifest.js
```javascript
// app/assets/config/manifest.js
//= link_tree ../builds
```
#### Step 5 - Add .keep file to builds directory
```bash
# Create .keep file to ensure directory is tracked by git
touch app/assets/builds/.keep
```
#### Step 6 - Update gitignore
```bash
/app/assets/builds/*
!/app/assets/builds/.keep
```
### Custom Layout CSS Files
If your application uses multiple layouts (e.g., a separate layout for public-facing pages, landing pages, or embedded views), you can register additional SCSS files to be compiled alongside the default CM Admin stylesheets.
#### Configuration
Add the `layout_css_files` option in your CM Admin initializer:
```ruby
# config/initializers/cm_admin.rb
CmAdmin.configure do |config|
config.layout = 'admin'
config.layout_css_files = ['public.scss', 'landing.scss']
end
```
Each entry should be a path **relative to** `app/assets/stylesheets/`. For example, `'public.scss'` maps to `app/assets/stylesheets/public.scss`.
#### How It Works
During the CSS build, each file listed in `layout_css_files` is compiled from its source location to `app/assets/builds/` with the same base name:
| Source | Output |
| ------------------------------------- | ------------------------------- |
| `app/assets/stylesheets/public.scss` | `app/assets/builds/public.css` |
| `app/assets/stylesheets/landing.scss` | `app/assets/builds/landing.css` |
These files are compiled using the same Sass flags and load paths as the core CM Admin stylesheets, so you can use `@import` directives referencing CM Admin or node module dependencies.
#### Subdirectory Support
You can also point to files inside subdirectories:
```ruby
config.layout_css_files = ['layouts/public.scss', 'layouts/embedded.scss']
```
| Source | Output |
| ---------------------------------------------- | -------------------------------- |
| `app/assets/stylesheets/layouts/public.scss` | `app/assets/builds/public.css` |
| `app/assets/stylesheets/layouts/embedded.scss` | `app/assets/builds/embedded.css` |
> **Note:** The output file name is derived from the base name only, regardless of subdirectory depth.
### Stylesheet Import Paths
If your application depends on stylesheets provided by Ruby gems (e.g., `bootstrap`, `select2-rails`, `intl-tel-input-rails`), you can register their stylesheet directories so the cm-admin Sass build can resolve `@import`/`@use` directives.
#### Configuration
Add the `stylesheet_import_paths` option in your CM Admin initializer:
```ruby
# config/initializers/cm_admin.rb
CmAdmin.configure do |config|
config.stylesheet_import_paths = [
"#{Gem.loaded_specs['bootstrap'].full_gem_path}/assets/stylesheets",
"#{Gem.loaded_specs['select2-rails'].full_gem_path}/vendor/assets/stylesheets",
"#{Gem.loaded_specs['intl-tel-input-rails'].full_gem_path}/app/assets/stylesheets"
]
end
```
#### How It Works
Each entry is a directory path. During the Sass build, each directory is passed to the compiler as a `--load-path=` flag, so any `@import` in `application.scss` or `layout_css_files` can resolve partials located in those directories.
> **Note:** These are paths, not full flags. For example, `assets/stylesheets` not `--load-path=assets/stylesheets`.
### Rake Commands
#### Build Commands
```bash
# Build all CSS files for production (already enhanced in assets:precompile rake task)
rake cm_admin:scss:build
```
These commands:
- Compile `cm_admin.css.scss` → `cm_admin.css`
- Compile `cm_session.css.scss` → `cm_session.css`
- Compile `application.scss` → `application.css`
- Place outputs in `app/assets/builds/`
#### Watch Commands
```bash
# Watch for changes and auto-recompile during development
rake cm_admin:scss:watch
```
These commands start the Sass compiler in watch mode, automatically recompiling when source files change.
#### Integration with Assets Precompile
The CSS build automatically runs during `assets:precompile`:
```bash
rake assets:precompile
```
This ensures your CSS is always up-to-date when deploying to production.
### File Structure
```
your-app/
├── app/assets/
│ ├── builds/ # Compiled CSS output (generated)
│ │ ├── cm_admin/
│ │ │ ├── cm_admin.css
│ │ │ └── cm_session.css
│ │ └── application.css
```
### Troubleshooting
#### Common Issues
**"npm install failed"**
- Ensure Node.js and npm are installed
- Check that the CM Admin engine directory exists and is accessible
**Compiled CSS not updating**
- Run `rake cm_admin:scss:build` manually to force rebuild
- Check file permissions in `app/assets/builds/`
---
## Custom Action
### Overview
Custom actions let you add your own buttons, links, modals, and pages to a model beyond the default `index`, `show`, `new`, `edit`, and `destroy` actions. They are defined with the `custom_action` DSL method inside a model's cm-admin configuration and can run arbitrary Ruby code against a record (or the whole collection).
Typical use cases:
- **State transitions** — approve, archive, publish, or soft-delete a record.
- **Quick edits** — update a subset of fields through a modal or dedicated page.
- **Exports / bulk fetches** — trigger a collection-level operation.
- **External links** — send the user to another URL related to the record.
### Syntax
```ruby
custom_action name: 'action_name', route_type: 'member', verb: 'patch',
path: ':id/action_name', display_type: :button do
record = ::YourModel.find(params[:id])
# perform the operation
record
end
```
The block receives access to `params` and must return the affected record (or collection). The returned object is used to render the response and to evaluate `success_message` / `error_message`.
### Options
| Option | Type | Description |
| --- | --- | --- |
| `name` | String | Unique identifier for the action. Used to build the route and controller method. |
| `display_name` | String | Human-friendly label shown on the button/link. Falls back to `name.titleize`. |
| `verb` | String | HTTP verb: `get`, `post`, `put`, `patch`, or `delete`. |
| `path` | String | URL path. Use `:id` for member routes (e.g. `':id/approve'`). |
| `route_type` | String | `member` (operates on a single record) or `collection` (operates on many). |
| `display_type` | Symbol | How the action is rendered. See [Display types](#display-types). |
| `display_if` | Proc | Lambda receiving the record; return `true` to show the action. |
| `icon_name` | String | Font Awesome icon class, e.g. `'fa-regular fa-circle-check'`. |
| `icon_style` | String | Inline icon style, e.g. `'--fa-primary-color: #ff6b6b;'`. |
| `modal_configuration` | Hash | Modal `title`, `description`, and `confirmation_text` for modal display types. |
| `page_title` | String | Title shown for `:page` / `:form_page` display types. |
| `page_description` | String | Description shown for `:page` / `:form_page` display types. |
| `group_name` | String | Groups actions together in the dropdown (see [Group Action](./GroupAction.md)). |
| `show_on_index` | Boolean | Whether the action appears in the index row dropdown. Defaults to `true`. |
| `redirect_to` | Proc | Lambda returning a URL to redirect to (used by `:link`). |
| `success_message` | Proc | Lambda receiving the record; returns an alert Hash `{ header:, body:, icon: }`. |
| `error_message` | Proc | Lambda receiving the record and its errors; returns an alert Hash `{ header:, body:, icon: }`. |
| `alert_type` | Symbol | Success alert style: `:flash` (default) or `:banner`. |
### Display types
The `display_type` option controls how the action is presented:
- **`:button`** — A button that submits directly and runs the block. Best for one-click state changes.
- **`:modal`** — Shows a confirmation modal before submitting. Configure copy via `modal_configuration`.
- **`:form_modal`** — Opens a modal containing a form. Requires a `form` block and an `on_submit` block.
- **`:page`** — Renders a dedicated page. Use `page_title` / `page_description`.
- **`:form_page`** — A full-page form. Requires a `form` block and an `on_submit` block.
- **`:link`** — Renders a link to an external/related URL via `redirect_to`.
- **`:icon_only`** — Renders only the icon with no label.
### Examples
#### Button action
Runs immediately when clicked. Here `display_if` hides the action unless the record is a draft.
```ruby
custom_action name: 'activate', display_name: 'Activate', route_type: 'member', verb: 'post',
icon_name: 'fa-regular fa-power-off', path: ':id/activate',
display_type: :button, display_if: ->(df) { !df.is_active? } do
demo_form = ::DemoForm.find(params[:id])
demo_form.update!(is_active: true)
demo_form
end
```
#### Modal action
Prompts the user to confirm before running the block.
```ruby
custom_action name: 'approve', display_name: 'Approve', route_type: 'member', verb: 'patch',
icon_name: 'fa-regular fa-circle-check', path: ':id/approve',
display_type: :modal, display_if: lambda(&:draft?),
modal_configuration: { title: 'Approve Demo Form',
description: 'Are you sure you want to approve this demo form?',
confirmation_text: 'Approve' } do
demo_form = ::DemoForm.find(params[:id])
demo_form.approved!
demo_form
end
```
#### Form modal action
Collects input through a form rendered in a modal. Requires both a `form` block (defining the fields) and an `on_submit` block (handling the update).
```ruby
custom_action name: 'assign_user', display_name: 'Assign User', verb: 'post', route_type: 'member',
icon_name: 'fa-regular fa-user', display_type: :form_modal,
path: ':id/assign_user',
modal_configuration: { title: 'Select User', confirmation_text: 'Assign' } do
form do
cm_section '' do
alert_box body: 'This will assign a new user to the demo form.', type: :info
form_field :user_id, input_type: :radio_button_group, helper_method: :select_options_for_user, label: 'User'
end
end
on_submit do
@demo_form = ::DemoForm.find(params[:id])
@demo_form.update!(user_id: params.dig(:demo_form, :user_id))
@demo_form
end
end
```
> **Note:** For `:form_modal` and `:form_page`, the `form` block is mandatory. Submitted params are nested under the model name, e.g. `params[:demo_form][:user_id]`.
#### Collection action
Use `route_type: 'collection'` for actions that operate on the whole collection rather than a single record. These paths do not include `:id`.
```ruby
custom_action name: 'export_all', display_name: 'Export All', route_type: 'collection', verb: 'get',
icon_name: 'fa-regular fa-download', path: 'export_all',
display_type: :button do
::DemoForm.all
end
```
#### Styled action
Customize the icon appearance with `icon_style`.
```ruby
custom_action name: 'styled_action', display_name: 'Styled Action', route_type: 'member', verb: 'post',
icon_name: 'fa-regular fa-palette',
icon_style: '--fa-primary-color: #ff6b6b; --fa-secondary-color: #4ecdc4;',
path: ':id/styled_action', display_type: :button do
demo_form = ::DemoForm.find(params[:id])
demo_form
end
```
#### Custom success and error messages
`success_message` and `error_message` are lambdas that return an alert Hash with `:header`, `:body`, and `:icon` keys. `success_message` receives the record; `error_message` receives the record and its `errors`. Any key you omit falls back to a sensible default.
```ruby
custom_action name: 'approve', display_name: 'Approve', route_type: 'member', verb: 'patch',
icon_name: 'fa-regular fa-circle-check', path: ':id/approve',
display_type: :button,
success_message: lambda { |demo_form|
{ header: 'Approved', body: "#{demo_form.name} was approved.", icon: 'fa fa-check-circle' }
},
error_message: lambda { |_demo_form, errors|
{ header: 'Approval failed', body: errors.full_messages.to_sentence, icon: 'fa fa-circle-xmark' }
} do
demo_form = ::DemoForm.find(params[:id])
demo_form.approved!
demo_form
end
```
### Related
- [Group Action](./GroupAction.md) — group multiple custom actions under a common heading.
- [Bulk Action](./BulkAction.md) — run an action across multiple selected records.
- [Adding Alert](./AddingAlert.md) — configure success and error alerts.
---
## Custom Filter Method
### Overview
We can add a custom filter method to filter records based on specific criteria. This is useful when the default filtering options do not meet the requirements. The custom filter method can be defined using the `filter_with` option.
### Usage
The custom filter method is implemented using the `filter` dsl_method with the `filter_with` option. Below are the key elements and their usage:
#### Syntax
```ruby
filter :filter_name, :filter_type, filter_with: :custom_filter_method
```
#### Scope signature
| Filter type | Scope arguments |
| --------------------------------- | -------------------------- |
| `:search` | `(value)` |
| `:single_select`, `:multi_select` | `(value, condition = nil)` |
| `:date`, `:range` | `(value, condition = nil)` |
| `:string` | `(value, condition = nil)` |
`condition` is the operator selected in [Advanced Filters](./AdvancedFilters.md), for example `is_not` or `greater_than`. It is `nil` when the scope is called from the standard filter bar. For date and range filters, `value` is the raw string (`"2026-01-01 to 2026-01-31"`, `"18 to 30"`, or a single value such as `"18"`).
:::warning
Lambda scopes enforce arity. A select, date, range, or string scope that does not accept `condition` raises `ArgumentError`.
:::
#### Example
**1. For Search:**
```ruby
class YourModel < ApplicationRecord
scope :custom_filter_method, ->(value) { where('column_name ILIKE ?', "%#{value}%") }
end
```
```ruby
filter :filter_name, :search, filter_with: :custom_filter_method
```
**2. For Single / Multi Select:**
```ruby
class User < ApplicationRecord
scope :by_company_country, lambda { |value, condition = nil|
left_joins(:company).ct_dropdown_filter_condition(column: :country, value:, condition:, table: 'companies')
}
end
```
```ruby
filter :company_country, :multi_select, helper_method: :company_country_options, filter_with: :by_company_country
```
**3. For String:**
```ruby
class User < ApplicationRecord
scope :by_company_name, lambda { |value, condition = nil|
left_joins(:company).ct_string_filter_condition(column: :name, value:, condition:, table: 'companies')
}
end
```
```ruby
filter :company_name, :string, filter_with: :by_company_name
```
**4. For Date and Range:**
```ruby
class User < ApplicationRecord
scope :by_company_size, lambda { |value, condition = nil|
left_joins(:company).ct_range_filter_condition(column: :employee_count, value:, condition:, table: 'companies')
}
end
```
```ruby
filter :company_size, :range, filter_with: :by_company_size
```
#### Condition helpers
These helpers are available on every ActiveRecord model through `CmAdmin::FilterConditions`. `table:` defaults to the model's own table.
| Helper | Handles |
| ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `ct_dropdown_filter_condition(column:, value:, condition:, table:)` | `is`, `is_not`, `is_empty`, `is_not_empty`. `cm_empty` maps to `NULL` |
| `ct_range_filter_condition(column:, value:, condition:, table:)` | `between`, `not_between`, `greater_than`, `less_than`, `greater_than_or_equal_to`, `less_than_or_equal_to`, `is`, `is_not`, `is_empty`, `is_not_empty` |
| `ct_string_filter_condition(column:, value:, condition:, table:)` | `is`, `is_not`, `contains`, `does_not_contain`, `is_empty`, `is_not_empty`. Case-insensitive, literal matches (wildcards escaped); `is_empty` also matches `''` |
If you write the logic by hand instead, handle `is_empty` / `is_not_empty`. They are called with a blank `value`.
---
## Global Search
### Overview
Global Search provides a unified search experience across multiple models in your CmAdmin panel. Users can press **⌘ + K** (Mac) or **Ctrl + K** (Windows) to open a search modal from anywhere on the platform. It searches records using the existing `:search` filters on each model's index action, respects Pundit policies and scopes, and returns up to 5 matching records per model.
In addition to record results, the search also suggests matching model pages (e.g., typing "user" suggests navigating to the Users index page).
### Features
- **Keyboard shortcut** — Open search with ⌘ + K / Ctrl + K from any page.
- **Cross-model search** — Search across all configured models in a single query.
- **Policy-aware** — Only shows results the current user is authorized to see.
- **Concurrent execution** — Searches models in parallel using a configurable thread pool.
- **Keyboard navigation** — Navigate results with Arrow keys and press Enter to visit.
- **Model suggestions** — Suggests matching model pages alongside record results.
- **Customizable subtitle fields** — Control which fields appear below each search result.
- **Custom display name** — Define `cm_display_name` on your model to control the result title.
### Setup
#### Step 1 — Register models for global search
In your CmAdmin initializer (e.g., `config/initializers/cm_admin.rb`), add the models you want to be searchable:
```ruby
CmAdmin.configure do |config|
config.global_search_models = [User, Post, Order]
end
```
> **Note:** The order of models in this array determines the display order of results.
#### Step 2 — Ensure a search filter exists
Each model must have a `:search` filter defined in its `cm_index` block. Global search uses this filter to query records.
```ruby
cm_admin do
cm_index do
filter %i[email first_name last_name], :search, placeholder: 'Search by name or email'
column :first_name
column :last_name
column :email
end
end
```
> **Important:** If a model does not have a `:search` filter on its index action, it will be silently skipped during global search.
#### Step 3 — Define `cm_display_name`
By default, search results display as `"ModelName #id"` (e.g., "User #42"). To show a more meaningful title, define `cm_display_name` on your model:
```ruby
class User < ApplicationRecord
def cm_display_name
"#{first_name} #{last_name}"
end
end
```
```ruby
class Post < ApplicationRecord
def cm_display_name
title
end
end
```
#### Step 4 — Customize subtitle fields
Each search result can display additional metadata below the title. By default, `global_search_fields` is set to `[:id, :created_at, :updated_at]`. You can override this per model by setting the `global_search_fields` attribute:
```ruby
cm_admin do
global_search_fields %i[email status created_at]
cm_index do
filter %i[email first_name], :search
column :email
column :status, field_type: :badge
column :created_at, field_type: :date
end
end
```
> **Note:** The fields listed in `global_search_fields` must also be defined as columns in `cm_index`. Only columns that exist in the index view and pass their `display_if` condition will be shown.
### Configuration Reference
#### `CmAdmin.config.global_search_models`
| Property | Type | Default | Description |
| ---------------------- | ------- | ------- | ------------------------------------------------------------------------ |
| `global_search_models` | `Array` | `[]` | Array of ActiveRecord model classes to include in global search results. |
#### `global_search_fields` (per model)
| Property | Type | Default | Description |
| ---------------------- | ------- | --------------------------------- | -------------------------------------------------------------------- |
| `global_search_fields` | `Array` | `[:id, :created_at, :updated_at]` | Array of field identifiers to display as subtitle in search results. |
#### Environment Variables
| Variable | Default | Description |
| -------------------------------- | ------- | ----------------------------------------------------- |
| `GLOBAL_SEARCH_THREAD_POOL_SIZE` | `5` | Number of threads used for concurrent model searches. |
### How It Works
1. User opens the search modal via **⌘ + K** / **Ctrl + K** or by clicking the search icon.
2. After typing at least 2 characters, a debounced request (300ms) is sent to the server.
3. The server iterates over `global_search_models` in parallel threads.
4. For each model:
- Checks if the user has `index` policy access.
- Checks if a `:search` filter is defined on the index action.
- Applies policy scopes and default scopes.
- Runs the search filter and limits results to 5 records.
5. Results are sorted by the original model order and rendered via Turbo Stream.
6. Additionally, model pages matching the query string are suggested at the bottom.
### Full Example
```ruby
# config/initializers/cm_admin.rb
CmAdmin.configure do |config|
config.global_search_models = [User, Post, Comment]
end
```
```ruby
# app/models/user.rb
class User < ApplicationRecord
def cm_display_name
"#{first_name} #{last_name}"
end
cm_admin do
icon_name 'fa fa-user'
global_search_fields %i[email created_at]
cm_index do
filter %i[email first_name last_name], :search, placeholder: 'Search users'
column :first_name
column :last_name
column :email
column :created_at, field_type: :date
end
end
end
```
```ruby
# app/models/post.rb
class Post < ApplicationRecord
def cm_display_name
title
end
cm_admin do
icon_name 'fa fa-file-text'
global_search_fields %i[status created_at]
cm_index do
filter %i[title], :search, placeholder: 'Search posts'
column :title
column :status, field_type: :badge
column :created_at, field_type: :date
end
end
end
```
### Notes
- Models without a `:search` filter or without index policy access are automatically excluded.
- The search respects all default scopes and action-level scopes configured on the model.
- The search modal will not open if another modal is already visible on the page.
- The placeholder in the search input automatically shows the first 3 configured model names.
---
## Group Action
Documentation coming soon.
---
## Hotwire Breaking Changes
### Links Navigation
When Hotwire/Turbo is enabled, links inside Turbo Frames will navigate within the frame by default instead of the full page.
**Solution:** Add `data: { turbo_frame: "_top" }` to links that should navigate the full page:
```ruby
link_to "View Details", some_path, data: { turbo_frame: "_top" }
```
Alternatively, you can use `target: "_top"` on the turbo-frame element itself if all links should break out of the frame.
---
## Importers
CM Admin importers process files uploaded through an `importable` action. Use `CmAdmin::BaseImporter` for standard CSV and Excel row processing, or provide a standalone importer when you need complete control over parsing and execution.
### Configure the import action
Add `importable` inside the model's `cm_admin` configuration:
```ruby
cm_admin do
importable class_name: 'UserImporter',
importer_type: :default_importer,
sample_file_path: '/user_import.csv'
end
```
| Option | Required | Description |
| -------------------- | -------- | --------------------------------------------------------------------------------------------------------------- |
| `class_name` | Yes | Importer class initialized by CM Admin. |
| `importer_type` | Yes | Use `:default_importer` for a `CmAdmin::BaseImporter` subclass or `:custom_importer` for a standalone importer. |
| `sample_file_path` | No | Public path to a sample file shown on the import page. |
| `route_type` | No | `:collection` by default; use `:member` to import against one record. |
| `display_if` | No | Lambda controlling whether the import action is visible. |
| `page_configuration` | No | Import page settings such as `title`, `description`, and `submit_button_text`. |
The uploaded file is stored as a `CtFileImport` and processed asynchronously by `FileImportProcessorJob`. The importer class must be autoloadable and accept `file_import:` in its initializer.
### Use `CmAdmin::BaseImporter`
The base importer provides file download, CSV/TSV/XLS/XLSX parsing, header validation, blank-row skipping, value trimming, per-row error capture, counters, and import status calculation.
Create an importer and implement `process_row`:
```ruby
class UserImporter < CmAdmin::BaseImporter
def self.required_headers
%w[email name]
end
private
def process_row(row, _line_number)
User.create!(email: row[:email], name: row[:name])
end
def identifier_for(row)
row[:email]
end
end
```
- **`required_headers`** — **Optional**. Lists headers that must exist in the first row. Header matching is exact.
- **`process_row(row, line_number)`** — Runs once for each non-blank row. `row` supports string and symbol keys. Return a truthy value for success; raise an error or return `nil`/`false` for failure.
- **`identifier_for(row)`** — **Optional**. Returns the value shown beside a failed row; the default is `-`.
Without transactions, the importer continues after a failed row. Successful rows remain committed and the final status is `:partial_success` when at least one other row succeeds.
#### Run the whole import in a transaction
Transactions are disabled by default. Opt in by overriding `use_transaction?`:
```ruby
class UserImporter < CmAdmin::BaseImporter
def self.required_headers
%w[email name]
end
def self.use_transaction?
true
end
private
def process_row(row, _line_number)
User.create!(email: row[:email], name: row[:name])
end
def identifier_for(row)
row[:email]
end
end
```
When enabled, all rows run inside one `ActiveRecord::Base.transaction`. The first failed row stops processing and rolls back database changes made earlier in that transaction. The failed row is still included in the import error report.
Use a transaction only when the import must be atomic. Keep it disabled for partial imports or when processing large files where a long-running transaction is undesirable. Transactions only cover database work participating in the same Active Record transaction; files, API calls, jobs, and other external side effects are not rolled back.
### Keep a fully custom importer
Use `:custom_importer` to retain the previous standalone approach. CM Admin does not require inheritance and does not control parsing or transactions:
```ruby
cm_admin do
importable class_name: 'UserImporter', importer_type: :custom_importer, sample_file_path: '/user_import.csv'
end
```
The custom class must satisfy this interface:
```ruby
class UserImporter
InvalidRow = Struct.new(:line_number, :identifier, :errors)
attr_reader :invalid_rows, :success_count, :row_count
def initialize(file_import:)
@file_import = file_import
Current.user = file_import.added_by
@invalid_rows = []
@success_count = 0
@row_count = 0
end
def run!
# Download and process @file_import.import_file here.
rescue StandardError => e
@invalid_rows << InvalidRow.new(1, '-', e.message)
end
def import_status
return :success if invalid_rows.empty?
# use partial_success with success_count and row_count if required.
:failed
end
end
```
The processor expects:
- **`initialize(file_import:)`** — Receives the persisted `CtFileImport` containing the uploaded `import_file` attachment.
- **`run!`** — Performs the import.
- **`invalid_rows`** — Returns objects exposing `line_number`, `identifier`, and `errors`.
- **`import_status`** — Optional. Return `:success`, `:partial_success`, or `:failed`. Without it, CM Admin returns `:success` when `invalid_rows` is empty and `:failed` otherwise.
A custom importer is responsible for file parsing, header validation, error handling, cleanup, transactions, and status semantics. Use it when the base importer's row-oriented workflow does not fit the import.
---
## Lexxy Rich Text Templates 📝
This feature provides AI-powered rich text template integration using Lexxy, allowing users to create reusable prompt templates with category-based filtering and slash command access.
### Overview
The Lexxy Rich Text Templates feature introduces:
- **CmPrompt Model**: Stores reusable rich text templates with category associations
- **Lexxy Integration**: AI-powered slash commands (`/`) in rich text editors
- **Category-Based Filtering**: Organize templates by categories for easy access
- **Policy Scoping**: Role-based access control for template visibility
- **Dynamic Scopes**: Automatic scope methods for category filtering (e.g., `estimation_only`)
### Installation
#### 1. Run the Generator
```bash
rails generate cm_admin:add_prompts_with_constants
```
This creates two migrations:
- `create_cm_prompts` - Creates the prompts table
- `create_constant_maps` - Creates the polymorphic association table
#### 2. Run Migrations
```bash
rails db:migrate
```
#### 3. Add CmPrompt to Included Models
Update your CM Admin configuration:
```ruby
# config/initializers/cm_admin.rb
CmAdmin.configure do |config|
config.included_models = [Constant, User, CmRole, DemoForm, CmPlatformSetting, CmSupportTicket, CmCronJob, CmCronJobLog, ApiToken, CmPrompt]
# ... other configuration
end
```
#### 4. Add Lexxy Template Platform Setting
The generator automatically adds a platform setting for the default Lexxy template. You can configure it in the admin panel under **Platform Settings > RichText Templates**.
### Model Structure
#### CmPrompt
```ruby
class CmPrompt < ApplicationRecord
has_rich_text :prompt
has_many :categories, as: :mappable, dependent: :destroy, class_name: 'ConstantMap'
has_many :constants, through: :categories
validates :name, :slug, presence: true, uniqueness: { case_sensitive: false }
end
```
**Fields:**
- `name` - Template name (required, unique)
- `slug` - URL-friendly identifier (auto-generated from name)
- `prompt` - Rich text content (ActionText)
- `created_by` - User who created the template
- `updated_by` - User who last updated the template
#### ConstantMap
Polymorphic association table linking Constants to any model:
```ruby
class ConstantMap < ApplicationRecord
belongs_to :constant
belongs_to :mappable, polymorphic: true
end
```
### Usage
#### Creating Templates
1. Navigate to **Cm Prompts** in the admin panel
2. Click **New Cm Prompt**
3. Fill in the template details:
- **Name**: Template name (e.g., "Project Estimation")
- **Slug**: Auto-generated, can be customized
- **Categories**: Select one or more categories from `cm_prompt_category` constants
- **Prompt**: Rich text content with formatting
4. Save the template
#### Using Templates in Rich Text Fields
When editing any rich text field in the admin panel:
1. Type `/` in the rich text editor
2. A dropdown appears with available templates
3. Select a template to insert its content
4. The template content is inserted as editable text
#### Category-Based Filtering
Templates can be filtered by category using dynamic scope methods:
```ruby
# Filter by specific category
CmPrompt.with_category('Estimation')
# Dynamic scope method (auto-generated from category name)
CmPrompt.estimation_only
CmPrompt.documentation_only
CmPrompt.reporting_only
```
The `_only` suffix is automatically added to category names to create scope methods.
### Policy Scoping
Templates respect role-based access control through policy scopes:
```ruby
class CmPromptPolicy < ApplicationPolicy
class RichTextTemplateScope < Scope
def resolve
# Implement your access control logic
# Example: Only show templates for user's categories
scope.all
end
end
end
```
The policy scope name must follow the pattern: `CmAdmin::{ModelName}PolicyRichTextTemplateScope`
### Platform Settings
A new platform setting is added for Lexxy configuration:
- **Lexxy Template**: The default Lexxy template to use
Access this in **Platform Settings > RichText Templates**.
### API Endpoints
#### Rich Text Templates Endpoint
```ruby
# lib/cm_admin/constants.rb
rich_text_templates: {
verb: :get,
path: 'rich_text_templates'
}
```
This endpoint is used by the Lexxy integration to fetch available templates.
### Technical Details
#### Lexxy Integration
The Lexxy prompt system is integrated into rich text fields via the `build_lexxy_prompt` helper:
```ruby
def build_lexxy_prompt
policy_scope_name = "CmAdmin::#{@model.ar_model.name}PolicyRichTextTemplateScope"
templates = policy_scope_name.constantize.new(Current.user, ::CmPrompt).resolve
template_items = templates.map { |t| build_lexxy_item(t) }.join
<<~HTML.html_safe
#{template_items}
HTML
end
```
#### Dynamic Scope Methods
The CmPrompt model uses `method_missing` to create dynamic scope methods:
```ruby
def self.method_missing(method_name, *args, &)
if method_name.to_s.end_with?('_only')
category_name = method_name.to_s.gsub('_only', '').titleize
with_category(category_name)
else
super
end
end
```
This allows calling `CmPrompt.estimation_only` which internally calls `CmPrompt.with_category('Estimation')`.
#### Category Association Management
Categories are managed through the `category_ids` virtual attribute:
```ruby
def category_ids=(ids)
@category_ids = Array(ids).reject(&:blank?)
end
def create_category_associations
return if @category_ids.blank?
categories.destroy_all
@category_ids.each do |category_id|
categories.create(constant_id: category_id)
end
end
```
### Database Schema
#### cm_prompts Table
```ruby
create_table :cm_prompts do |t|
t.string :name, null: false
t.citext :slug, null: false
t.references :created_by, foreign_key: { to_table: :users }
t.references :updated_by, foreign_key: { to_table: :users }
t.timestamps
end
add_index :cm_prompts, :name, unique: true
add_index :cm_prompts, :slug, unique: true
```
#### constant_maps Table
```ruby
create_table :constant_maps do |t|
t.references :constant, null: false
t.references :mappable, polymorphic: true, null: false
t.timestamps
end
add_index :constant_maps, %i[constant_id mappable_id mappable_type], unique: true
```
### Dependencies
This feature requires:
- **Lexxy** (~> 0.8) - AI-powered rich text editor integration
- **ActionText** - Rails rich text framework
- **pg_trgm** PostgreSQL extension (for citext support)
### Troubleshooting
#### Templates Not Appearing in Editor
1. **Check Policy Scope**: Ensure the policy scope returns templates for the current user
2. **Verify Categories**: Ensure templates have at least one category assigned
3. **Check Lexxy Configuration**: Verify the Lexxy template platform setting is configured
#### Category Scope Methods Not Working
1. **Verify Category Name**: Ensure the category exists in `cm_prompt_category` constants
2. **Check Naming Convention**: Use the `_only` suffix (e.g., `estimation_only`)
3. **Review Category Association**: Ensure templates are properly associated with categories
#### Migration Errors
If the `constant_maps` table already exists, the migration will skip creation:
```ruby
return if table_exists?(:constant_maps)
```
### Examples
#### Creating a Template with Categories
```ruby
# In Rails console
template = CmPrompt.create!(
name: 'Project Estimation Template',
slug: 'project-estimation',
prompt: '
Project Estimation
Enter your estimation details...
',
category_ids: [1, 2] # IDs of cm_prompt_category constants
)
```
#### Filtering Templates by Category
```ruby
# Using scope method
estimation_templates = CmPrompt.estimation_only
# Using with_category
documentation_templates = CmPrompt.with_category('Documentation')
# Combining with other scopes
recent_estimation = CmPrompt.estimation_only.order(created_at: :desc).limit(10)
```
#### Accessing Template Categories
```ruby
template = CmPrompt.first
template.category_names # => "Estimation, Documentation"
template.category_ids # => [1, 2]
```
### Future Enhancements
Potential improvements for this feature:
- Template versioning with history tracking
- Template preview before insertion
- Template variables/placeholders support
- Template sharing between users
- Template analytics (usage tracking)
---
## Lisiting of Select Two using Ajax 🅰️
### Method 1: Using Ajax URL for Form Field
This method demonstrates how to create a form field with a single select input that uses an Ajax URL to fetch data.
#### Adding Ajax URL Parameter
The below form field creates a single select input for optional languages. It uses an Ajax URL to fetch the language options dynamically.
```ruby
form_field :optional_language_id, input_type: :single_select, label: 'Optional Language', ajax_url: :fetch_optional_language
```
#### Custom Action for Fetching Data
This custom action retrieves data from the database and returns it as a JSON response. Example below, is specifically designed to fetch optional language data based on a search parameter.
**Location**: Define this custom action in the index section of your configuration.
```ruby
custom_action name: 'fetch_optional_language', route_type: :collection, verb: 'get', path: '/fetch_optional_languages',
display_type: :route do
@optional_language = ::Constant.search_constant_by_name(params[:search],"optional_language")
{
results: @optional_language.map { |language| { id: language.id, text: language.name.titleize } },
pagination: { more: false }
}
end
```
This custom action is used to fetch optional languages based on a search parameter. It returns a JSON response with the language results and pagination information.
### Method 2: Using HTML Attributes and JavaScript for Form Field
This method shows how to create a form field with custom HTML attributes and use JavaScript to implement the select functionality.
#### Adding Custom HTML Attributes
```ruby
form_field :optional_language_id, input_type: :single_select, label: 'Optional Language', html_attrs: { 'data-behaviour': 'optional_language-select' }
```
This form field creates a single select input for optional languages with a custom data attribute for JavaScript targeting.
#### Custom Action for Fetching Data
This custom action retrieves data from the database and returns it as a JSON response. Example below, is specifically designed to fetch optional language data based on a search parameter.
**Location**: Define this custom action in the index section of your configuration.
```ruby
custom_action name: 'fetch_optional_language', route_type: :collection, verb: 'get', path: '/fetch_optional_languages',
display_type: :route do
@optional_language = ::Constant.search_constant(params[:search], params[:class],"optional_language")
{
results: @optional_language.map { |language| { id: language.id, text: language.name.titleize } },
pagination: { more: false }
}
end
```
#### JavaScript Function for Select2 Implementation
This JavaScript function initializes a Select2 dropdown for the optional language select field. It configures Ajax loading of options, result processing, and pagination. We can also pass additional parameters to the server using the `data` attribute.
**Location**: Define this JavaScript function in your application's JavaScript file.
```javascript
function selectTwoAjaxCaller(){
const $event = $('select[data-behaviour="optional_language-select"]');
$event.select2({
theme: "bootstrap-5",
ajax: {
url: "fetch_optional_languages",
dataType: 'json',
delay: 250,
data: function (params) {
return {
search: params.term,
class: 12
};
},
processResults: function (data, params) {
return {
results: data.results
};
},
},
minimumInputLength: 0,
});
}
```
This JavaScript function initializes a Select2 dropdown for the optional language select field. It configures Ajax loading of options, result processing, and pagination.
---
## Population of items in drop-downs 🔽
This document provides details on various methods used in our project. Each method is explained with its purpose and usage examples.
### Method 1: Using Collection / Helper Method for Transport Type
The example below demonstrates how to create a section with a single-select field using collection.
```ruby
cm_section 'Mode Of Transport' do
form_field :transport_type_id, input_type: :single_select,
collection: :mode_of_transport
end
```
💡Note: You need to set mode_transport array like this:
```ruby
mode_of_transport = [['Vehicle','vehicle'],['Bike', 'bike']]
```
Or you can use a helper method to fetch the data:
```ruby
cm_section 'Mode Of Transport' do
form_field :transport_type_id, input_type: :single_select,
helper_method: :mode_of_transport_method
end
```
💡Note: This should be defined in the helper module:
```ruby
def mode_of_transport_method
Constant.where(type:"transport_type").all.map { |item| [item.full_name, item.id] }
end
```
### Method 2: Custom Action for Transport Type
The example below demonstrates how to create a section with a single-select field using a custom action.
This custom action is designed to fetch transport type data from the database and automatically populate the dropdown with the appropriate values when triggered.
💡 Note: The retrieved data is used to populate the target_value field.
**Location**: This custom action should be defined in the index section of your configuration.
```ruby
custom_action name: 'transport_type', route_type: :collection, verb: 'get', path: '/transport_type', display_type: :route do
transport_type = Constant.where(type: :transport_type).all.map { |item| [item.full_name, item.id] }
{
fields: [
{
target_type: :select,
target_value: {
table: 'student',
field_name: 'transport_type_id',
field_value: transport_type
}
}
]
}
end
```
💡Note: This needs to be added as a trigger variable for populating the fields:
```ruby
form_field :route_id, input_type: :single_select, target: { action_name: :fetch_transport_type }
```
---
## MCP Server
cm-admin ships with a built-in [Model Context Protocol](https://modelcontextprotocol.io)
server that exposes your admin-registered models to MCP-compatible AI clients
(Claude Desktop, Cursor, Windsurf, GitHub Copilot Chat, etc.) over JSON-RPC.
The current release (**v1**) is intentionally minimal and read-only: every
cm-admin model gets two tools — `_list` and `_show` — plus a
global `list_models` discovery tool. Authentication is done with Bearer
Personal Access Tokens (PATs), and every tool call goes through the same
Pundit policy scopes that gate the cm-admin web UI, so MCP cannot leak rows
that the user could not otherwise see.
---
### What you get
| Tool | Description |
| --------------- | ----------------------------------------------------------------------------------------------------------- |
| `list_models` | Returns every MCP-enabled cm-admin model and the actions exposed for each. Useful for discovery. |
| `fetch_options` | Fetches options for select filters given a helper method name. |
| `_list` | Paginated, sortable, filterable list of `` records. Honors `CmAdmin::PolicyIndexScope`. |
| `_show` | Full record by ID, including nested-field associations and tab data. Honors `CmAdmin::Policy#show?`. |
Tool names are derived from `Model.name.underscore` (e.g. `User` → `user_list`,
`ApiToken` → `api_token_show`). Names longer than 64 characters are truncated
while preserving the action suffix.
---
### Setup
#### 1. Enable MCP globally
```ruby
# config/initializers/cm_admin.rb
CmAdmin.configure do |config|
# ...your existing config...
config.mcp_enabled = true
end
```
When `mcp_enabled` is `true`:
- The endpoint is served by `CmAdmin::McpController` at `/mcp` (e.g.
`/cm_admin/mcp` if you mount cm-admin at `/cm_admin`).
- Models with `mcp_actions` explicitly called in their cm_admin block are MCP-enabled with `list` + `show`.
When `mcp_enabled` is `false` (default), the route is not drawn and no MCP
endpoint is exposed.
#### 2. Configure server identity
The MCP server reports itself using your Rails project settings so different
apps mounting cm-admin can be distinguished by clients:
```ruby
# config/application.rb
config.x.project_settings.name = 'My Cool App' # → MCP server title
config.x.project_settings.slug = 'my_cool_app' # → MCP server name
```
Falls back to `CmAdmin` / `cm_admin` when not set.
#### 3. Provide domain context (system prompt)
The MCP server sends an `instructions` field to the AI client during the
`initialize` handshake. This is the system prompt the AI reads before it
calls any tool, so it is the right place to give it domain-specific
knowledge about your data model, business semantics, and known
limitations.
Set `config.mcp_system_prompt` to inject your own context into every AI
session:
```ruby
# config/initializers/cm_admin.rb
CmAdmin.configure do |config|
config.mcp_enabled = true
config.mcp_system_prompt = <<~PROMPT
You are reading records from the Acme Platform via MCP tools.
Data model notes:
- `assignee` is the current owner of a work item, not necessarily the
original tester or creator.
- `internal_feedback_rating` is currently unused. QA ratings live inside
each work item's history and cannot be bulk-aggregated through MCP tools.
If a user asks for QA rating counts, explain the limitation rather than
returning incorrect results.
Best practices:
- Use aggregate tools for counts/sums/averages instead of listing all rows.
- Use `fetch_options` to resolve select filter values before filtering.
PROMPT
end
```
When `mcp_system_prompt` is left `nil` (default), the server falls back to a
generic one-line instruction. Keep the prompt focused on semantic guidance and
business rules — avoid pasting the full database schema, which the AI can
already discover through `list_models` and the tool schemas.
#### 4. Wire up Personal Access Tokens (PATs)
The MCP controller's auth check looks up tokens against your host app's `ApiToken`
model. The model needs a `token_type` enum (with at least a `pat` value) and
an `expires_at` column.
##### Migration
```ruby
class AddTokenTypeToApiTokens < ActiveRecord::Migration[8.1]
def change
add_column :api_tokens, :token_type, :integer, default: 0, null: false
add_column :api_tokens, :expires_at, :datetime
end
end
```
##### Model
```ruby
class ApiToken < ApplicationRecord
belongs_to :user
enum :token_type, %i[access refresh pat]
scope :live, -> { where(expires_at: [nil, Time.current..]) }
validates :token, presence: true
validates :token_type, presence: true
before_validation :generate_token, on: :create
private
def generate_token
self.token ||= SecureRandom.hex(32)
end
end
```
##### Current attributes
```ruby
# app/models/current.rb
class Current < ActiveSupport::CurrentAttributes
attribute :user, :api_token # ...plus your own attrs
end
```
> The controller also sets `CmCurrent.user_permissions` (cached on
> `cm_role.cache_key_with_version`) so `CmAdmin::PermissionHelper` and
> `Pundit::Policy` work exactly as they do for web requests.
#### 5. (Optional) Manage PATs from the cm-admin UI
Register `ApiToken` like any other admin model. A ready-made concern is
included with cm-admin — just `include` it and add the model to
`config.included_models`:
```ruby
# app/models/api_token.rb
class ApiToken < ApplicationRecord
include CmAdmin::ApiToken
# ...rest of the model...
end
```
```ruby
# config/initializers/cm_admin.rb
config.included_models = [..., ApiToken]
```
Now there's an "API Tokens" sidebar entry where users can create PATs.
---
### Usage
#### Client config
The exact JSON shape varies by client; the universal fields are `serverUrl`
(or `url`) and an `Authorization: Bearer ` header.
```json
{
"mcpServers": {
"feature_test_app": {
"serverUrl": "https://feature-test-app.commutatus.com/cm_admin/mcp",
"headers": {
"Authorization": "Bearer YOUR_PAT_HERE"
}
}
}
}
```
> The key (`feature_test_app` above) should match `config.x.project_settings.slug`
> so the connection is obvious in the client UI.
#### Generating a PAT
Either through the cm-admin UI (**API Tokens → Add API Token**, set
`token_type = pat`) or in a console:
```ruby
user.api_tokens.create!(name: 'MCP Client', token_type: :pat, expires_at: 30.days.from_now)
```
#### Calling tools manually (debugging)
```bash
PAT=... # your token
HOST=http://localhost:3000
# Discovery
curl -s -X POST "$HOST/cm_admin/mcp" \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-H "Authorization: Bearer $PAT" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
# List the first 5 users
curl -s -X POST "$HOST/cm_admin/mcp" \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-H "Authorization: Bearer $PAT" \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call",
"params":{"name":"user_list","arguments":{"per_page":5}}}'
# Show a single record
curl -s -X POST "$HOST/cm_admin/mcp" \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-H "Authorization: Bearer $PAT" \
-d '{"jsonrpc":"2.0","id":3,"method":"tools/call",
"params":{"name":"user_show","arguments":{"id":1}}}'
```
#### Calling tools from the Rails console
Each per-model tool is assigned a constant under its tool class, named after
the model with `::` removed (`User` → `User`, `BlogManagement::Post` →
`BlogManagementPost`). Tools are built at boot (and again on each code reload)
when `mcp_enabled` is true, so the constants are available as soon as the
console starts:
```ruby
Current.user = User.first # policy scopes resolve against Current.user
CmAdmin::Mcp::Tools::Models::ListTool::User.call(per_page: 5)
CmAdmin::Mcp::Tools::Models::GetTool::User.call(id: "1")
CmAdmin::Mcp::Tools::Models::ListTool::BlogManagementPost.call(page: 1)
```
`.call` returns an `MCP::Tool::Response`; use `.structured_content` for the
result hash.
---
### Tool reference
#### `list_models`
```json
{ "type": "object", "properties": {} }
```
Returns:
```json
{ "models": [{ "name": "User", "slug": "user", "actions": ["list", "show"] }] }
```
#### `fetch_options`
| Argument | Type | Default | Description |
| --------------- | ------ | ------- | ---------------------------------------------------------------------- |
| `helper_method` | string | — | Required. The helper method name (e.g., `select_options_for_currency`) |
| `model_name` | string | — | Optional. Model name (e.g., `User`, `DemoForm`) |
| `context` | object | — | Optional. Record-like context for parent-dependent helpers |
Returns:
```json
{
"helper_method": "select_options_for_currency",
"options": [
{ "label": "USD", "value": "usd" },
{ "label": "EUR", "value": "eur" }
]
}
```
#### `_list`
| Argument | Type | Default | Description |
| ---------------- | ------- | ------- | --------------------------------------------------------- |
| `page` | integer | 1 | 1-indexed page number |
| `per_page` | integer | 20 | Records per page (max 100) |
| `sort_column` | string | — | Column to sort by (enum of columns configured via `sort`) |
| `sort_direction` | string | `asc` | `asc` or `desc` |
| `filters` | object | `{}` | Filter parameters based on cm_index filters |
Records are filtered through `CmAdmin::PolicyIndexScope` — the same
scope used by the cm-admin index page and exports.
##### Response structure
```json
{
"data": [...],
"pagination": {
"current_page": 1,
"per_page": 20,
"total_count": 100,
"total_pages": 5,
"has_more": true
}
}
```
- `data` — Array of records for the current page. Each record contains only the
fields declared on `cm_index` (plus `id`); records are empty when no fields are declared.
- `pagination` — Metadata for pagination:
- `current_page` — Current page number
- `per_page` — Records per page
- `total_count` — Total number of records matching the query
- `total_pages` — Total number of pages
- `has_more` — Whether there are more pages available
##### Filter schema
The `filters` parameter accepts key-value pairs based on the model's `cm_index` filters.
For select filters using `helper_method`, the filter schema includes `x-helper-method` and
`x-model-name` metadata. Use the `fetch_options` tool with these values to retrieve
the current options dynamically. This keeps the schema small and ensures options are
always fresh.
#### `_show`
| Argument | Type | Default | Description |
| --------- | ------- | ------- | --------------------------------------------------------------- |
| `id` | string | — | Required. Record ID or friendly ID (e.g., "123" or "CMAD-123"). |
| `per_tab` | integer | 50 | Max records returned per tab association (max 200) |
Returns the fields declared on `cm_show` (empty when none are declared), plus:
```json
{
"...": "...record fields...",
"friendly_id": "CMAD-123",
"nested_fields": {
"": [
{ "id": 1, "": "..." }
]
},
"tabs": [
{ "name": "Profile", "action": "", "type": null },
{ "name": "Tokens", "action": "tokens", "type": "associated_index", "records": [ ... ] }
]
}
```
- `friendly_id` — the friendly ID or formatted ID (only present if the model supports friendly_id)
- `nested_fields` — every `nested_form_field` declared inside a `cm_section`
under `cm_show`, with the fields you declared in the DSL.
- `tabs` — every `tab` declared in `cm_show`. Each tab includes:
- `name` — the tab's nav label
- `action` — the action name (empty string for the default tab)
- `type` — the layout type (`associated_index`, `associated_show`, or `null`)
- `records` — present only when the tab maps to an association; policy-scoped
---
### Per-model control
By default, models are **not** MCP-enabled. You must explicitly call `mcp_actions`
inside the `cm_admin` block to enable MCP for a model:
```ruby
class Article < ApplicationRecord
cm_admin do
mcp_actions # enables list + show
# mcp_actions only: %i[list] # list only, no show
# mcp_actions except: %i[show] # everything except show
end
end
```
---
### Permissions
The MCP server respects the same policy classes used by the web UI:
| Action | Policy hook |
| ----------------------- | --------------------------------------------------------------------------- |
| `_list` | `CmAdmin::PolicyIndexScope#resolve` |
| `_show` | `CmAdmin::PolicyIndexScope#resolve` + `CmAdmin::Policy#show?` |
| Tab association records | `CmAdmin::PolicyIndexScope#resolve` |
If a policy class is missing, the call falls back to `Model.all` (matches
cm-admin's existing `authorize_with_policy` behavior). If
`Pundit::NotAuthorizedError` is raised, MCP returns a structured `Forbidden`
error to the client (not a 500).
---
### Error responses
Errors are returned as MCP tool errors with a JSON-encoded body:
| Scenario | HTTP status | Body |
| ------------------------------ | ---------------- | --------------------------------------------------------------------------- |
| Missing `Authorization` header | 401 | `{ "error": "Authorization token required" }` |
| Unknown / expired PAT | 401 | `{ "error": "Invalid or expired token" }` |
| `ActiveRecord::RecordNotFound` | 200 (tool error) | `{ "error": "Record not found", "message": "..." }` |
| `Pundit::NotAuthorizedError` | 200 (tool error) | `{ "error": "Forbidden", "message": "..." }` |
| Other `StandardError` | 200 (tool error) | `{ "error": "", "message": "..." }` and a `Rails.logger.error` entry |
---
### Observability
The endpoint is a controller, so Scout APM records each MCP request as a web
transaction. When `ScoutApm` is loaded, cm-admin renames the transaction after
its [OpenTelemetry MCP span name](https://opentelemetry.io/docs/specs/semconv/gen-ai/mcp/),
prefixed with `MCP/`, so each tool gets its own endpoint in Scout:
| Request | Scout transaction |
| --------------------------------- | ---------------------------- |
| `tools/call` for `user_list` | `MCP/tools/call user_list` |
| `tools/list` | `MCP/tools/list` |
| `initialize` | `MCP/initialize` |
No configuration is needed. Requests rejected by the PAT check are not renamed
and appear as `CmAdmin::McpController#handle`.
---
### Architecture (for the curious)
```
HTTP POST /cm_admin/mcp
│
▼
CmAdmin::McpController ← ActionController::API; before_action validates Bearer PAT, sets Current/CmCurrent
│
▼
MCP::Server + StreamableHTTPTransport ← built per request (stateless), per the ruby-sdk controller flow
│
▼
Tools: MCP::Tool subclasses
├── Global classes (list_models, fetch_options)
└── Per-model subclasses built once at boot by Tools::Base.build_for
(list / get / aggregate, dispatched by each tool's action_suffix),
named :: (e.g. Tools::Models::ListTool::User)
```
Source files:
- `app/controllers/cm_admin/mcp_controller.rb` — Bearer PAT auth + per-request server/transport
- `lib/cm_admin/mcp/server.rb` — builds `MCP::Server` instance
- `lib/cm_admin/mcp/registry.rb` — MCP-enabled model catalog
- `lib/cm_admin/mcp/filters.rb` — MCP filter arguments → `CmAdmin::Models::Filter` queries
- `lib/cm_admin/mcp/tools/base.rb` — `MCP::Tool` base class (build, naming, response + error handling)
- `lib/cm_admin/mcp/tools/global/list_models_tool.rb` — discovery tool
- `lib/cm_admin/mcp/tools/global/fetch_options_tool.rb` — select filter options fetcher
- `lib/cm_admin/mcp/tools/models/` — per-model `list`, `get`, `aggregate` tool classes
- `lib/cm_admin/mcp/authorization.rb` — Pundit `policy_scope` / `authorize!`
- `lib/cm_admin/mcp/tool_schema.rb` — cm_admin fields/filters → tool input/output schemas
- `lib/cm_admin/mcp/record_serializer.rb` — record → field-values hash (`fields`) and full show response (`show`)
---
## OTP & Authentication
Cm-admin provides built-in OTP and password authentication flows that can be opted into via configuration. The gem ships with a Devise strategy, session controller concern, OTP model, delivery providers, and view templates — so host apps no longer need to maintain these files themselves.
---
### Overview
The auth system supports three methods configured via `CmAdmin.config.auth_method`:
| Method | Flow |
| ----------- | ------------------------------------------------------------------------------- |
| `:otp` | User enters email or phone → receives OTP → enters OTP to sign in |
| `:password` | User enters email or phone → enters password to sign in |
| `:custom` | Host app provides its own flow — the Devise strategy signs the user in directly |
OTP delivery can go through email, phone (WhatsApp via Gupshup), or both. See [CmAdminConfiguration](./CmAdminConfiguration) for all config options.
---
### Setup
#### 1. Enable Catalyst OTP in the initializer
```ruby
# config/initializers/cm_admin.rb
CmAdmin.configure do |config|
config.auth_method = :otp # or :password, :custom
config.use_catalyst_otp = true # registers the gem's Devise strategy + model
# ...other config
end
```
`use_catalyst_otp = true` registers the gem's `CmAdminAuthenticatable` Devise strategy and model module via `ActiveSupport::Reloader.to_prepare`. This runs after app initializers (where the flag is set) but before eager load (where the User model needs the module). Host apps that don't opt in are unaffected.
#### 2. Generate the OTP requests migration
```bash
rails g cm_admin:add_otp_requests
rails db:migrate
```
This creates the `otp_requests` table with columns: `otp`, `email` (citext), `expired_at`, `status`, `verified_at`, `user_id` (optional foreign key), and timestamps. An index is added on `email`.
#### 3. Update the User model
Add the `OtpAuthenticatable` concern and `has_many :otp_requests` to your User model:
```ruby
class User < ApplicationRecord
include OtpAuthenticatable
has_many :otp_requests, dependent: :destroy
devise :database_authenticatable, :registerable,
:recoverable, :rememberable, :validatable, :cm_admin_authenticatable
def can_access_admin_panel?
true # or your own authorization logic
end
end
```
The `OtpAuthenticatable` concern provides:
- `create_otp_request` — generates a 6-digit OTP, creates an `OtpRequest`, and delivers it
- `verify_otp?(otp)` — validates the OTP against the latest active request
- `verify_otp_with_email?(email, otp)` — validates OTP for email-based (no user) flows
- `.create_otp_request_for_email(email)` — class method for creating OTPs without a user record
#### 4. Configure the sessions controller
The gem provides an `OtpSessions` concern that handles `validate_email`, `validate_credentials`, `validate_otp`, `validate_password`, `resend_otp`, and `sign_in_with_credentials`. Include it in your app's sessions controller:
```ruby
# app/controllers/users/sessions_controller.rb
module Users
class SessionsController < Devise::SessionsController
layout 'cm_session'
helper CmAdmin::ViewHelpers
include CmAdmin::OtpSessions
# Add any custom actions or overrides here
# e.g. reset_password_email, send_reset_password_email, update_password
end
end
```
The concern provides `after_successful_sign_in` and `after_sign_in_redirect_path` as extension points for host apps to customize post-login behavior.
#### 5. Configure routes
```ruby
# config/routes.rb
Rails.application.routes.draw do
devise_for :users, controllers: {
sessions: 'users/sessions',
passwords: 'users/passwords'
}
devise_scope :user do
post 'validate_email', to: 'users/sessions#validate_email'
get 'sign_in_with_credentials', to: 'users/sessions#sign_in_with_credentials'
post 'validate_credentials', to: 'users/sessions#validate_credentials'
get 'resend_otp', to: 'users/sessions#resend_otp'
get 'reset_password_email', to: 'users/sessions#reset_password_email'
post 'send_reset_password_email', to: 'users/sessions#send_reset_password_email'
post 'update_password', to: 'users/sessions#update_password'
end
mount CmAdmin::Engine => '/cm_admin'
end
```
---
### Phone-Based OTP Delivery
To deliver OTPs via phone (e.g. WhatsApp through Gupshup):
#### 1. Configure delivery channel and provider
```ruby
CmAdmin.configure do |config|
config.auth_method = :otp
config.use_catalyst_otp = true
config.otp_delivery_channel = :phone_or_email # or :phone
config.otp_delivery_provider = :gupshup
end
```
When `otp_delivery_channel` is `:phone_or_email`, the login screen shows a **toggle** that lets the user switch between phone-based and email-based login:
- The form shows a **mobile number field** (with the country-code selector used across cm-admin) by default.
- A **"Use email instead"** link (styled like the "Forgot Password?" link) toggles to an email field; the link then becomes **"Use phone instead"** to switch back. The user can toggle between the two at any time before submitting.
- OTP delivery follows the chosen identifier — a phone number sends the OTP via the configured phone provider only, and an email sends it via email only. This is handled automatically by the Devise strategy and `OtpSessions` concern based on whether the identifier contains `@`.
#### 2. Add Gupshup credentials
```ruby
# config/credentials.yml.enc or rails credentials:edit
gupshup:
api_key: "your_api_key"
source_number: "your_sender_number"
templates:
otp: "your_gupshup_template_id"
```
#### 3. Ensure User model has `phone_number`
The Gupshup provider calls `user.phone_number` — your User model must have a `phone_number` attribute.
#### Custom delivery providers
Pass any object that responds to `#deliver(user:, otp:, template:)`:
```ruby
class MySmsProvider
def deliver(user:, otp:, template:)
# your SMS sending logic
end
end
config.otp_delivery_provider = MySmsProvider.new
```
---
### Custom User Model
By default cm-admin uses the `User` model for authentication lookups, mentions, support tickets, and the history page. Override `user_class` with the model name or class:
```ruby
config.user_class = 'AdminUser'
# or
config.user_class = AdminUser
```
`user_class` is lazily constantized, so it can be set before the model is loaded or after a Rails code reload.
### Custom User Lookup
The default `user_lookup` lambda resolves users against `config.user_class` by email first, then by phone number (trailing digits match). Override it for custom logic:
```ruby
config.user_lookup = lambda { |identifier|
AdminUser.find_by(email: identifier.downcase) ||
AdminUser.find_by(phone_number: identifier.gsub(/\D/, ''))
}
```
The lambda receives the raw `identifier` string from the login form (which accepts email or phone) and should return a User instance or `nil`.
---
### Migration from Host-App Files
If your host app previously had its own copies of these files, you can remove them after enabling `use_catalyst_otp`:
#### Files to remove from the host app
1. **`lib/devise/strategies/cm_admin_authenticatable.rb`** — The gem now provides this at `lib/devise/strategies/cm_admin_authenticatable.rb`. It's auto-registered when `use_catalyst_otp = true`.
2. **`lib/devise/models/cm_admin_authenticatable.rb`** — The gem provides this at `lib/devise/models/cm_admin_authenticatable.rb`.
3. **Devise module registration in `config/initializers/devise.rb`** — Remove the manual `Devise.add_module(:cm_admin_authenticatable, ...)` block. The gem's engine initializer handles this automatically when `use_catalyst_otp = true`.
4. **OTP methods from User model concern** — If your User model had inline `create_otp_request`, `verify_otp?`, etc. (e.g. in an `Auth::User` concern), replace them with `include OtpAuthenticatable`.
5. **Session controller actions** — If your `Users::SessionsController` had `validate_email`, `validate_credentials`, `validate_otp`, `resend_otp`, `sign_in_with_credentials` methods, replace them with `include CmAdmin::OtpSessions`. Keep only host-app-specific actions like `reset_password_email`, `send_reset_password_email`, `update_password`.
#### What stays in the host app
- `Users::SessionsController` — slim controller that includes `CmAdmin::OtpSessions` and adds password reset actions
- `Users::PasswordsController` — Devise password reset controller (the gem doesn't provide this)
- Routes — the devise scope routes must remain in the host app's `config/routes.rb`
- `User` model — must include `OtpAuthenticatable` and `devise :cm_admin_authenticatable`
---
### How It Works
#### Devise Strategy
The `CmAdminAuthenticatable` strategy (`lib/devise/strategies/cm_admin_authenticatable.rb`) intercepts the `validate_email` action:
1. Extracts the `identifier` from params (supports both `identifier` and legacy `email` param names)
2. Calls `CmAdmin.config.user_lookup` to find the user
3. Checks `can_access_admin_panel?` on the user
4. On success:
- For `:otp` — creates an OTP request (sends the code) and redirects to `sign_in_with_credentials`
- For `:password` — redirects to `sign_in_with_credentials` (password entry page)
- For `:custom` — calls `super(user)` to sign in directly
5. On failure — returns a generic error message
#### OTP Model
`OtpRequest` (`app/models/otp_request.rb`) manages OTP lifecycle:
- **Statuses**: `created`, `verified`, `resent`, `time_out`, `cancelled`
- **Expiry**: 15 minutes (scoped via `active` and `timed_out`)
- **Rate limiting**: Max 5 OTP requests per 30 seconds per user or email
- **Delivery**: `after_create` callback calls `CmAdmin.send_otp` or `CmAdmin.send_otp_email`
#### Delivery Providers
- **`CmAdmin::DeliveryProviders::Email`** — sends OTP via `CmAdmin.send_email` using the `cm_admin/mailers/otp/otp_email` partial
- **`CmAdmin::DeliveryProviders::Gupshup`** — sends OTP via Gupshup WhatsApp API using configured templates
The `CmAdmin.send_otp` method routes to the appropriate provider(s) based on `otp_delivery_channel` config.
---
### Views
The gem provides these view templates:
- `app/views/users/sessions/new.html.slim` — login form (identifier input)
- `app/views/users/sessions/sign_in_with_credentials.html.slim` — OTP/password entry
- `app/views/users/sessions/_otp_sign_in.html.slim` — OTP input partial
- `app/views/users/sessions/_password_sign_in.html.slim` — password input partial
- `app/views/cm_admin/mailers/otp/_otp_email.html.slim` — OTP email template
Host apps can override any of these by creating files at the same paths in the host app's `app/views/` directory.
---
## Platform Settings
### Overview
We can add platform settings with key-value management.
#### Installation
```ruby
rails g cm_admin:add_cm_platform_setting
```
#### Usage
- Add or create a new constant type for platform settings category.
- Add a helper method for platform settings category constant with name `select_options_for_setting_category`.
NOTE: Adding this helper method is mandatory, otherwise platform settings page will fail.
```ruby
def select_options_for_setting_category(_ = nil, _ = nil)
# add the constant type for platform settings category according to your application.
::Constant.setting_category.map { |category| [category.name, category.id] }
end
```
- We can create a translation by going to `/cm_admin/platform_settings`.
```
- setting - The name of the setting. Eg: Project Name
- description - The description of the setting. Eg: The title used to identify your project across the system.
- value - The value of the setting. Eg: Commutatus
- category - The category of the setting. Eg: Emails
```
#### Project settings vs platform settings
These are two separate things and never override each other:
| | Project settings | Platform settings |
| ------------ | -------------------------------------------------------- | --------------------------------------------------- |
| Set by | Developers, in `config/application.rb` | Admins, on the Platform Settings page |
| Stored in | `config.x.project_settings` | The `cm_platform_settings` table, cached |
| Read with | `Rails.configuration.x.project_settings.name` | `CmAdmin::PlatformSettings["slug"]` |
| Examples | Project name, slug, logo URL, favicon URL, default email | Login layout, login image, Catalyst AI model, queue |
| Changes need | A deploy | Nothing; they apply immediately |
A platform setting with the same name as a project setting (for example an old **Project Name** row) has no
effect on `config.x.project_settings`.
#### Reading settings
Read every platform setting through `CmAdmin::PlatformSettings`, from anywhere in the app (models,
views, jobs, mailers):
```ruby
CmAdmin::PlatformSettings["ct-bot-queue"] # => "default"; raises if the setting doesn't exist
CmAdmin::PlatformSettings.fetch("login-layout", "background_image") # default used when the setting is missing or blank
CmAdmin::PlatformSettings.fetch("ct-bot-model") { "openai/gpt-5.6-sol" }
```
- `[]` raises `CmAdmin::PlatformSettings::NotFound` (a `KeyError`) when no setting has that slug, so a typo or a
missing seed fails loudly instead of returning `nil`. It returns `nil` only for a setting that exists with no value.
- `fetch` works like `Hash#fetch`: pass a default or a block when the code has a sensible fallback. Without one,
it raises `NotFound` too.
- Settings are looked up by **slug** (the parameterised name: `Login Layout` → `login-layout`).
- A setting with an uploaded file returns its Active Storage redirect path (`/rails/active_storage/blobs/redirect/...`),
which never expires and can be used directly in `image_tag` or a CSS `url()`.
---
## Role and Permission Management 🎭
### Overview
Role and permission management is facilitated through CmAdmin, which dynamically creates Pundit policies. This tool allows us to manage permissions via an intuitive interface.
### Features
- **Create Role:** Easily create any role needed for the application.
- **Manage Permissions:** View and modify all possible actions for each role, enabling or disabling permissions as necessary.
### Usage
#### Adding Role and Permission Table
Run the following command to add the role and permission table:
```bash
rails g cm_admin:install_role
```
This Rake task generates a default migration.
**Note:** Ensure that you have the `paper_trail` gem installed before running the command.
#### Creating Role Column on User Table
To create a role column in the user table, execute:
```bash
rails g migration AddCmRoleToUser cm_role:references
```
#### Adding Unique Index for Bulk Permission Updates
For improved performance with bulk permission updates, add a unique composite index:
```bash
rails g cm_admin:add_unique_index_to_permissions
```
This creates a migration that adds a unique index on `(ar_model_name, action_name, cm_role_id)` to enable efficient `upsert_all` operations for permission management.
**Note:** The column name must be `cm_role_id`, or the policy will fail.
#### Assigning Roles to Users
Currently, each user can be assigned only one role. To set the current request parameters:
1. In `app/models/current.rb`, add `request_params` as an attribute.
2. In `app/controllers/concerns/authentication.rb`, set the request parameters to help CmAdmin identify the action in the Pundit policy.
```ruby
module Authentication
extend ActiveSupport::Concern
included do
before_action :check_current_user
before_action :set_params
end
def set_params
Current.request_params = params if params
end
# Add other methods here
end
```
3. Add `belongs_to :cm_role, optional: true` in the `User` model.
4. Include `CmRole` in the `config.included_models` section of `config/initializers/cm_admin.rb`.
5. Assign `cm_role_id` to `1` for any user in the `User` Model, and use that user to log in.
### Setting up scopes
By default, `Full Access` scopes is added to each permission item. To add additional scopes, use the following syntax:
```ruby
...
cm_admin do
actions only: []
icon_name "fa fa-user"
policy_scopes [{ scope_name: 'test_supplier_filter', display_name: 'By Test Supplier' }]
cm_index do
page_title 'User'
end
end
```
### Using Permission Scopes in Code
The `cm_permission_scoped_records` helper method allows you to filter records based on the current user's permission scope for a given model. This is useful when you need to apply the same scoping logic outside of the standard CmAdmin interfaces (e.g., in background jobs, custom controllers, or models).
#### Including the Helper
The helper is available in `CmAdmin::PermissionHelper`. Include it in your class or module:
```ruby
module CmAdmin
module CustomHelper
include CmAdmin::PermissionHelper
def select_options_for_users(_ = nil, _ = nil)
cm_permission_scoped_records(::User).map { |user| [user.cm_display_name, user.id] }
end
end
end
```
#### Method Signature
```ruby
cm_permission_scoped_records(model_class)
```
- **model_class**: The ActiveRecord model class to scope (e.g., `User`, `Order`, `Item`)
- **Returns**: An `ActiveRecord::Relation` scoped according to the user's permissions
#### How It Works
1. Looks up the current user's role and permissions
2. Finds the permission for the given model with action `'index'`
3. If the permission has a scope name (other than `'all'`), calls that scope on the model
4. Falls back to `model_class.all` if no scope is defined or scope does not exist
#### Requirements
- User must have a `cm_role` association
- Role must have `cm_permissions` with the appropriate model and `'index'` action
- The model must define the scope method specified in the permission
### Overriding Policies
By default, roles and policies are enabled for all models in the application. To override a policy, use the following syntax:
```ruby
...
cm_admin do
actions only: []
icon_name "fa fa-user"
override_policy true
cm_index do
page_title 'User'
end
end
```
Then, create a policy file for the respective model, e.g., `app/policies/cm_admin/user_policy.rb`:
```ruby
class CmAdmin::UserPolicy < ApplicationPolicy
def index?
true
end
# Add other actions here
end
```
This structure helps ensure that your application's role and permission management is both flexible and secure.
### Permission based fields
We can apply permission logic to display a field on the interface. You can do this with the following syntax.
```ruby
...
tab :details, '' do
row do
cm_show_section 'Details' do
field :status, field_type: :badge, badge_class: Item::STATUS_TAG_COLOR, display_if: -> (record) {
scoped_model = CmAdmin::ItemPolicyArchiveScope.new(Current.user, ::Item).resolve
return scoped_model.find_by(id: record.id).present?
}
end
end
end
```
### Adding Default Redirect Path for a Role
- We can add default redirect path for a role via cm admin.
- need to add this on `routes.rb`.
```ruby
...
root 'cm_admin/static#index'
```
- Create a migration to add a column for default redirect path. (Will be automatically added in new apps)
```ruby
...
add_column :cm_roles, :default_redirect_path, :string, default: "#{CmAdmin::Engine.mount_path}/users"
```
### Permission-Based Access Control
Always use permission-based methods instead of role checks when controlling access to features, fields, or actions.
#### Add these methods to your User model
First, add the following permission helper methods to your User model:
```ruby
def permission_with_scope?(model_name:, action_name:, scope_name:)
cm_role.cm_permissions.where(ar_model_name: model_name, action_name:, scope_name:).exists?
end
def has_permission?(model_name, action_name)
return false unless cm_role.present?
cm_role.cm_permissions.exists?(ar_model_name: model_name, action_name: action_name)
end
```
#### Use `permission_with_scope?` for scoped permissions
When checking permissions with a specific scope (e.g., 'all', 'own', 'team'):
```ruby
unless Current.user.permission_with_scope?(model_name: 'ReimbursementRequest', action_name: 'revert_acknowledged', scope_name: 'all')
# Handle unauthorized access
end
```
#### Use `has_permission?` for general permissions
When checking permissions without a scope:
```ruby
column :note_type, header: 'Note Type', field_type: :enum, display_if: lambda { |_|
::Current.user.has_permission?('Customer', 'customer_restricted_fields')
}
```
#### Never use role checks
Avoid using role-based checks like:
- `Current.user&.role?('admin')`
- `Current.user.cm_role.name == 'admin'`
Instead, always use the permission methods defined above which provide fine-grained access control based on the user's role permissions.
---
## Saved Views
### Overview
Saved Views let each user save the current state of an index page under a name and come back to it in one click. A view stores:
- Search and filters (standard and [advanced](./AdvancedFilters.md))
- Sort
- Visible and hidden columns, including column order (Manage Columns)
- Records per page
- View type (`table`, `card`, `kanban`)
Views are private to their owner (`Current.user`) and are scoped per model. Associated (tab) tables keep their own separate set of views.
Saved Views replace the old **Index Preference** feature (`cm_index_preferences`).
### Setup
#### 1. Run the generator
```bash
rails generate cm_admin:ct_page_view
rails db:migrate
```
The migration:
- Enables the PostgreSQL `citext` extension, so view names are case-insensitive.
- **Drops the legacy `cm_index_preferences` table** if it exists. Its data is not migrated.
- Creates `ct_page_views`:
| Column | Type | Notes |
| -------------------------- | ----------------------- | ---------------------------------------------------- |
| `owner` | polymorphic reference | Required. Set to `Current.user` |
| `name` | `citext` | Required |
| `ar_model_name` | `string` | Model whose index page the view belongs to |
| `associated_ar_model_name` | `string`, default `''` | Parent model when the view is on an associated tab |
| `visible_columns` | `string[]` | Ordered |
| `hidden_columns` | `string[]` | |
| `is_default` | `boolean` | At most one per owner / model (partial unique index) |
| `view_type` | `integer` enum | `table: 0`, `card: 1`, `kanban: 2` |
| `records_per_page` | `integer`, default `20` | |
| `filter_params` | `jsonb` | Decoded filters hash |
| `sort_params` | `json` | |
See [Generators](../Generators.md#17-ct-page-view-generator).
#### 2. Requirements
- `DEFAULT_PER_PAGE` must be defined (it is generated in `config/initializers/constants.rb`). `PER_PAGE_OPTIONS` is optional and defaults to `[10, 20, 50, 100]`.
- `Current.user` must be set for admin requests.
- PaperTrail must be set up. `CtPageView` uses `has_paper_trail`.
There is no DSL to add: Saved Views appear on every index page and associated table.
### Usage
| Action | How |
| --------------------- | --------------------------------------------------------------------------------------------------------- |
| Save a view | Set filters, sort, and columns, then **Save View**, enter a name, and **Save** |
| Apply a view | **Saved Views**, then click the view |
| Update a view | Apply it, then change filters, sort, columns, or page size. Changes are **auto-saved** to the active view |
| Clear the active view | Click **×** on the view button. This returns to the unfiltered page |
| Set as default | Use the **⋮** menu, then **Set as Default**. The view opens automatically when you visit the page |
| Others | Use the **⋮** menu: **Copy Link**, **Edit** (rename), **Duplicate**, **Delete** |
#### Default view behaviour
When a user opens an index page with **no** `filters`, `current_ct_page_view_id`, `clear_filters`, or `clear_ct_page_view` params, they are redirected to their default view. Links that already carry any of these params, such as shared filtered links, open as-is.
#### Shareable links
A view applies itself through query params: `current_ct_page_view_id`, `filters`, `sort`, and `view_type`. **Copy Link** copies these params. Because views belong to their owner, other users only get the filters, sort, and view type from the link, not your saved columns.
### Routes
These routes are added automatically for every cm-admin model, with no setup needed:
| Verb | Path | Action |
| ------ | --------------------------------------- | ------------------------ |
| GET | `//ct_page_views` | `list_ct_page_view` |
| POST | `//ct_page_views` | `create_ct_page_view` |
| PUT | `//ct_page_views/:id` | `update_ct_page_view` |
| DELETE | `//ct_page_views/:id` | `destroy_ct_page_view` |
| POST | `//ct_page_views/:id/duplicate` | `duplicate_ct_page_view` |
Route helpers follow the pattern `_ct_page_view__path`, for example `create_ct_page_view_user_path`. The list route's helper is `ct_page_view__path`.
### Working with `CtPageView` in Ruby
```ruby
view = CtPageView.create!(
owner: user,
name: 'Active adults',
ar_model_name: 'User',
filter_params: { advanced: [{ filter_type: 'single_select', filter_name: 'status', condition: 'is', value: 'active' }] },
sort_params: { created_at: { dir: 'desc', type: nil } },
visible_columns: %w[name email status],
records_per_page: 50,
is_default: true
)
view.apply_params # => { current_ct_page_view_id: 1, filters: "eJy...", sort: { "created_at" => { ... } } }
view.apply_url # => index path with the params above
```
Setting `is_default: true` automatically unsets the previous default for the same owner, model, and associated model.
### Upgrading from Index Preference
See the [v8.2.0 upgrade guide](../5.%20Upgrade%20Guide/v8.2.0.md#index-preferences-replaced-by-saved-page-views).
---
## Hide or Show Section and Fields 🦹
This module provides methods for hiding form sections and fields with HTML attributes.
### Method 1: Using HTML Attributes and JavaScript for Form Section
#### Adding Custom HTML Attributes
Define the html_attrs attribute in a section / field to add custom HTML attributes.
```ruby
cm_section 'Private Van', html_attrs: { "data-behaviour": "private_van-section"} do
form_field :transport_vehicle_number, input_type: :string, label: 'Vehicle Number'
form_field :transport_vehicle_in_charge, input_type: :string, label: 'Vehicle In Charge'
form_field :transport_vehicle_in_charge_phone, input_type: :integer, label: 'Vehicle In Charge Phone'
end
```
The below JavaScript code manages the visibility of the "Private Van" section based on a condition.
**Location**: Define this JavaScript function in your application's JavaScript file.
```javascript
$(document).on("turbo:load", function() {
$(document).on('change', 'select[data-behaviour="transport_type-select"]', handleTransportTypeChange);
});
function handleTransportTypeChange(event) {
// Get the section element by its data attribute
var section = $('div[data-behaviour="private_van-section"]');
// Get the selected value
var selectedValue = $(event.target).val();
// Toggle the visibility based on the condition
if (selectedValue !== "private_van") {
section.addClass('hidden');
} else {
section.removeClass('hidden');
}
}
```
### Method 2: Using `display_if` Attribute for Form Section
#### Adding `display_if` Attribute for Form Section
Define the `display_if` attribute in a section to conditionally display the section based on a condition.
```ruby
cm_section 'Private Van', display_if:->(trip){trip.transport_type== "private_van" } do
form_field :transport_vehicle_number, input_type: :string, label: 'Vehicle Number'
form_field :transport_vehicle_in_charge, input_type: :string, label: 'Vehicle In Charge'
form_field :transport_vehicle_in_charge_phone, input_type: :integer, label: 'Vehicle In Charge Phone'
end
```
#### Adding `display_if` Attribute for Form Field
Define the `display_if` attribute in a field to conditionally display the field based on a condition.
```ruby
form_field :transport_vehicle_number, input_type: :single_select,collection: :mode_of_transport, display_if:->(trip){
trip.transport_type=="private_van" }
```
You can also use the `display_if` attribute in any field to conditionally display the field based on a condition.
💡Note: The `display_if` attribute is used to conditionally display the section based on the value of `trip.transport_type`. It doesn't updated in real time like the JavaScript method.
---
## Showing Hiding Fields with Stimulus
### Overview
We can show and hide fields dynamically with stimulus html attributes. It works on simple fields and nested form fields.
### Usage
We need to pass html attributes for using this.
#### Hiding a Field.
1. We need to specify a `data-action`.
- `input` is event name.
- `fields` is controller name.
- `hide` is action name.
2. We need to provide the `data-cm-hidden-id`. These are ID's of target elements. This ID is added automatically from `from_object`.
3. we can pass `data-cm-toggle-values` a hash for hiding fields on particular values or we can pass `data-cm-toggle-value` a string if all fields need to hidden on a single value.
4. On Target element we need to pass `data-fields-target`, this is stimulus feature and it's value can be either `cmHidden` and `cmVisible`.
```ruby
form_field :title, input_type: :string,
html_attrs: { 'data-action': 'input->fields#hide',
'data-cm-hidden-id': 'is_featured tag_ids',
'data-cm-toggle-values': '{ "is_featured": "hide", "tag_ids": "hides" }' }
form_field :is_featured, input_type: :switch, label: 'My Custom Switch Label',
html_attrs: { 'data-fields-target': 'cmHidden' }
```
#### Showing a Field.
1. It's similar to hiding field. There are some changes:
- action will be changed to `show`.
- `data-cm-hidden-id` will be changed to `data-cm-visible-id`.
- `data-fields-target` value will be changed to `cmVisible`.
```ruby
form_field :user_id, input_type: :single_select, helper_method: :select_options_for_users,
html_attrs: { 'data-action': 'change->fields#show',
'data-cm-visible-id': 'is_featured tag_ids',
'data-cm-toggle-value': ::User.first&.id.to_s }
form_field :tag_ids, input_type: :multi_select, helper_method: :select_options_for_tag,
html_attrs: { 'data-fields-target': 'cmVisible' }
```
#### Hiding a Nested Form Field.
`nested_form_field` also accepts `html_attrs`. The attributes are applied to the nested section wrapper, so the whole nested section can be toggled. The wrapper automatically gets `data-cm-id` set to the association name and `data-toggle-cm-class` set to `nested-field-wrapper`, so only `data-fields-target` needs to be passed.
```ruby
form_field :has_no_previous_addresses, label: 'I have no previous addresses', input_type: :switch,
html_attrs: { 'data-action': 'input->fields#hide',
'data-cm-hidden-id': 'addresses' }
nested_form_field :addresses, html_attrs: { 'data-fields-target': 'cmHidden' } do
form_field :address_type, label: 'Type', input_type: :single_select,
helper_method: :residential_address_type_option_for_select
form_field :street_address
end
```
---
## CmAdmin Support Ticket Management 🎫
### Overview
CmSupportTicket is a feature that allows users to create and manage support tickets within the application, which will get synced with the bridge system.
### Usage
As mentioned above, this feature two applications running in parallel.
App1 - Eg DRE - Running on port 3000
App2 - Eg Bridge - Running on port 3001
Bridge development setup:
- Create a project for DRE in the bridge application, where tasks can be created.
- Create a CmAdmin installation and associate the project.
- Once created, copy the project key and add it to the DRE credentials file. It will look like this:
```yaml
cm_admin:
project_access_key: PROJECT_KEY
```
#### Adding migration
Run the following command to add necessary tables and columns
```bash
rails g cm_admin:add_cm_support_ticket
```
This Rake task generates three migration files
- One to create the cm_support_ticket table
- Second to create the cm_support_ticket_user table for assigning users
- Third to add created_by_name and email to cm_comment
#### Initializer
Add the CmSupportTicket to cm_admin.rb file
```ruby
CmAdmin.configure do |config|
config.included_models = [
# ... other models,
CmSupportTicket
]
config.sidebar = [
# ... other items,
{ path: :cm_index_cm_support_ticket_path }
]
end
```
#### Credentials
For development set the project key in credentials file along with host_url
```yaml
cm_admin:
project_access_key: your_project_key_here
```
For Staging and Production:
- Generate the project key in the bridge application and add it to the environment variable `BUNDLE_CM___GEMS__COMMUTATUS__COM`
Note: This environment variable is shared between both applications for bridge integration. The same production project key from Bridge will be used across both applications. Remember to update the corresponding staging key in the Staging Bridge environment when deploying to staging.
Note:
While running sidekiq, Run sidekiq on one application only, not on both. If we run in parallel, Jobs can run on other application and cause confusion. We need to fix this.
---
## Trackable
### Overview
The `Trackable` concern automatically records which user created or last updated a record by storing references to the `User` model via `created_by_id` and `updated_by_id` columns. It relies on `Current.user` being set in the request lifecycle.
---
### Quick Start
#### 1. Include the Concern in Your Model
```ruby
class Order < ApplicationRecord
include Trackable
end
```
#### 2. Run the Generator
The generator scans all models that include `Trackable` and creates a single migration for any that are missing the required columns:
```bash
rails g cm_admin:add_trackable_columns
rails db:migrate
```
The generator will output which models and columns are being added, or confirm that everything is already in place:
```
Found 2 model(s) missing trackable columns:
- Order: missing created_by_id, updated_by_id
- Product: missing updated_by_id
✅ Migration created: db/migrate/20240101120000_add_trackable_columns_to_models.rb
⚙️ Run `rails db:migrate`
```
---
### What It Does
Once included and migrated, the concern:
- Sets `created_by_id` to `Current.user.id` after a record is created
- Sets `updated_by_id` to `Current.user.id` after every update
- Skips the assignment if `Current.user` is not present (e.g., background jobs, seeds)
It also adds delegates so you can access user details directly on the record:
```ruby
order.created_by_full_name # => "Jane Doe"
order.created_by_email # => "jane@example.com"
order.updated_by_full_name # => "John Smith"
order.updated_by_email # => "john@example.com"
```
---
### Requirements
- **`Current.user`** must be set in the request lifecycle (e.g., via `ApplicationController`)
- The model's table must have `created_by_id` and `updated_by_id` columns (added by the generator)
- A `User` model must exist in the application
---
### Displaying Tracking Info in cm-admin
You can surface `created_by` and `updated_by` on index and show pages like any other column:
```ruby
cm_admin do
cm_index do
column :created_by_full_name, header: "Created By"
column :updated_by_full_name, header: "Last Updated By"
end
cm_show do
cm_section do
cm_row do
field :created_by_full_name, label: "Created By"
field :updated_by_full_name, label: "Last Updated By"
end
end
end
end
```
---
## Translation
### Overview
We can convert certain keys to the language the application needs.
#### Installation
```ruby
rails generate 'i18n:active_record:install'
```
#### Usage
- Create a migration to add a column for locale for the user.
```ruby
class AddLocaleToUser < ActiveRecord::Migration[7.2]
def change
add_column :users, :locale, :string, default: 'en'
end
end
```
- Add the following line to the `config/initializers/i18n_active_record.rb` file.
```ruby
require 'i18n/backend/active_record'
I18n.backend = I18n::Backend::Chain.new(
I18n::Backend::ActiveRecord.new, # DB-backed translations (editable at runtime)
I18n.backend # fallback to the default Simple backend
)
I18n::Backend::ActiveRecord.configure do |config|
config.cache_translations = true # defaults to false
# config.cleanup_with_destroy = true # defaults to false
config.scope = 'cm_admin' # defaults to nil, won't be used
end
```
- We can create a translation by going to `/cm_admin/translations`.
```ruby
- key - The text that needs to be converted. Eg: Edit
- value - The value that needs to be applied. Eg: يحرر
- locale - The language in which the text needs to be converted. Eg: ar
```
- Add this method to the CM admin application controller
```ruby
around_action :switch_locale
def switch_locale(&action)
locale = params[:locale] || Current.user.locale || I18n.default_locale
I18n.with_locale(locale, &action)
end
```
#### Important Notes
- Success messages are not converted yet.
---
## CmAdmin Interactive Dashboard System
### Overview
The CmAdmin Interactive Dashboard System allows you to create fully interactive, namespaced dashboards with dynamic widgets. These dashboards can be global or associated with specific models and can be embedded in model views or other sections.
This guide will walk you through setting up and using the dashboard system with the new dashboard helper functionality and permission scoping.
### Setup
#### Database Setup
First, you need to run the migration to create the necessary tables:
```bash
rails db:migrate
```
### Dashboard Helper System
#### Creating Dashboard Helpers
Create custom dashboard helper methods in `/app/helpers/cm_admin/dashboard_helper.rb`:
```ruby
module CmAdmin
module DashboardHelper
# Dashboard helper methods receive (scoped_ar_object, scoped_filters)
# scoped_ar_object is already permission-scoped based on user permissions
def total_users_metric(scoped_ar_object, scoped_filters = {})
base_query = scoped_ar_object # Already permission-scoped
# Apply dashboard filters
if scoped_filters[:single_select].present?
scoped_filters[:single_select].each do |key, value|
next if value.blank?
base_query = base_query.where(key => value)
end
end
if scoped_filters[:search].present?
search_term = scoped_filters[:search]
base_query = base_query.where("email ILIKE ? OR first_name ILIKE ?",
"%#{search_term}%", "%#{search_term}%")
end
base_query.count
end
def users_by_role_chart_data(scoped_ar_object, scoped_filters = {})
base_query = scoped_ar_object
# Apply filters similar to above
# ... filter logic ...
base_query.joins(:cm_role).group('cm_roles.name').count
end
end
end
```
#### Permission Scoping
Dashboard helpers automatically receive permission-scoped data:
1. **Dashboard-specific permissions**: Uses `scope_name` from permissions with matching `action_name` and `ar_model_name`
2. **Policy scope fallback**: Falls back to `CmAdmin::{Model}PolicyIndexScope` if no dashboard-specific scope
3. **Filter integration**: Dashboard filters are automatically scoped and passed to helper methods
### Creating Dashboards
#### Global Dashboards
Global dashboards are not associated with any specific model:
```ruby
dashboard "Admin Dashboard" do
box "User Statistics" do
metric value: :total_users_metric, title: "Total Users"
chart "Users by Role", type: :pie, data: :users_by_role_chart_data
table "Recent Users", data: :recent_users_table_data
end
box "System Statistics" do
metric value: -> { Rails.cache.stats[:hits] }, title: "Cache Hits"
chart "Daily Signups", type: :line, data: :daily_signups_chart_data
end
end
```
#### Model-Associated Dashboards
Associate dashboards with specific models:
```ruby
# app/models/concerns/cm_admin/student.rb
module CmAdmin
module Student
extend ActiveSupport::Concern
included do
cm_admin do
actions only: []
icon_name 'fa fa-graduation-cap'
cm_dashboard :dashboard_student_type,
display_name: 'Dashboard',
page_title: 'Dashboard',
page_description: 'Student Dashboard' do
# Permissions / access control to be wired into your system:
# - Full Access
# - User Office Only
# - Assignee Only
# Filters - These will be automatically passed to dashboard helpers
filter :office_id,
:multi_select,
helper_method: :select_options_for_office,
placeholder: 'Office',
display_name: 'Office',
active_by_default: true
filter :assigned_to_id,
:multi_select,
helper_method: :select_options_for_lead_assignees,
placeholder: 'Assignee',
display_name: 'Assignee',
active_by_default: true
filter :application_coordinator_id,
:multi_select,
helper_method: :select_options_for_application_coordinators,
placeholder: 'Application Coordinator',
display_name: 'Application Coordinator',
active_by_default: true,
filter_with: :with_application_coordinator_ids
# Row 1 – 3 metric cards (Total Leads | Total Prospects | Total Applications)
row do
cm_section '' do
metric_card :total_leads_metric,
title: 'Total Leads'
end
cm_section '' do
metric_card :total_prospects_metric,
title: 'Total Prospects'
end
cm_section '' do
metric_card :total_applications_metric,
title: 'Total Applications'
end
end
# Charts – Layout: 2 columns, Pie Chart for all
# Row 2 – Leads by Status | Prospects by Status
row do
cm_section '' do
chart :leads_by_status_chart_data,
title: 'Leads by Status',
chart_type: :pie_chart
end
end
row do
cm_section '' do
chart :prospects_by_status_chart_data,
title: 'Prospects by Status',
chart_type: :pie_chart
end
end
# Row 3 – Applications by Status (single column)
row do
cm_section '' do
chart :applications_by_status_chart_data,
title: 'Applications by Status',
chart_type: :pie_chart
end
end
end
cm_index do
page_title 'Students'
# These same filters will be used for dashboard filtering
filter %i[first_name last_name email], :search, placeholder: 'Search'
filter :office_id, :multi_select, helper_method: :select_options_for_office
filter :assigned_to_id, :multi_select, helper_method: :select_options_for_lead_assignees
sort_column :created_at
sort_direction :desc
column :full_name
column :email
column :office_name, header: 'Office'
column :status
column :created_at, field_type: :date, format: '%d %b, %Y'
end
end
end
end
end
```
#### Corresponding Dashboard Helper Methods
Create the dashboard helper methods in `/app/helpers/cm_admin/dashboard_helper.rb`:
```ruby
module CmAdmin
module DashboardHelper
# Metric card methods - receive permission-scoped data and filters
def total_leads_metric(scoped_ar_object, scoped_filters = {})
base_query = scoped_ar_object.where(status: 'lead')
apply_dashboard_filters(base_query, scoped_filters).count
end
def total_prospects_metric(scoped_ar_object, scoped_filters = {})
base_query = scoped_ar_object.where(status: 'prospect')
apply_dashboard_filters(base_query, scoped_filters).count
end
def total_applications_metric(scoped_ar_object, scoped_filters = {})
base_query = scoped_ar_object.where(status: 'application')
apply_dashboard_filters(base_query, scoped_filters).count
end
# Chart data methods
def leads_by_status_chart_data(scoped_ar_object, scoped_filters = {})
base_query = scoped_ar_object.where(status: 'lead')
filtered_query = apply_dashboard_filters(base_query, scoped_filters)
filtered_query.group(:sub_status).count
end
def prospects_by_status_chart_data(scoped_ar_object, scoped_filters = {})
base_query = scoped_ar_object.where(status: 'prospect')
filtered_query = apply_dashboard_filters(base_query, scoped_filters)
filtered_query.group(:sub_status).count
end
def applications_by_status_chart_data(scoped_ar_object, scoped_filters = {})
base_query = scoped_ar_object.where(status: 'application')
filtered_query = apply_dashboard_filters(base_query, scoped_filters)
filtered_query.group(:application_status).count
end
private
# Helper method to apply dashboard filters consistently
def apply_dashboard_filters(base_query, scoped_filters)
return base_query if scoped_filters.blank?
# Apply multi-select filters
if scoped_filters[:multi_select].present?
scoped_filters[:multi_select].each do |key, values|
next if values.blank?
case key.to_s
when 'office_id'
base_query = base_query.where(office_id: values)
when 'assigned_to_id'
base_query = base_query.where(assigned_to_id: values)
when 'application_coordinator_id'
base_query = base_query.where(application_coordinator_id: values)
end
end
end
# Apply search filters
if scoped_filters[:search].present?
search_term = scoped_filters[:search]
base_query = base_query.where(
"first_name ILIKE ? OR last_name ILIKE ? OR email ILIKE ?",
"%#{search_term}%", "%#{search_term}%", "%#{search_term}%"
)
end
# Apply date filters
if scoped_filters[:date].present?
scoped_filters[:date].each do |key, date_range|
next if date_range.blank?
base_query = base_query.where(key => date_range)
end
end
base_query
end
end
end
```
### Widget Types
#### Metric Cards
Display single numeric values:
```ruby
metric :total_users_metric, title: "Total Users", description: "All registered users"
```
#### Charts
Display data visualizations:
```ruby
# Pie Chart
chart :users_by_role_chart_data, type: :pie, title: "Users by Role"
# Line Chart
chart :daily_signups_chart_data, type: :line, title: "Daily Signups"
# Bar Chart
chart :monthly_stats_chart_data, type: :bar, title: "Monthly Stats"
```
#### Tables
Display tabular data:
```ruby
table "Recent Users", data: :recent_users_table_data
```
### Advanced Features
#### Filter Integration
Dashboard filters are automatically passed to helper methods:
```ruby
def filtered_users_metric(scoped_ar_object, scoped_filters = {})
base_query = scoped_ar_object
# Single select filters
if scoped_filters[:single_select]&.dig(:cm_role_id).present?
base_query = base_query.where(cm_role_id: scoped_filters[:single_select][:cm_role_id])
end
# Date filters
if scoped_filters[:date]&.dig(:created_at).present?
date_range = scoped_filters[:date][:created_at]
base_query = base_query.where(created_at: date_range)
end
# Search filters
if scoped_filters[:search].present?
search_term = scoped_filters[:search]
base_query = base_query.where("email ILIKE ?", "%#{search_term}%")
end
base_query.count
end
```
#### Permission-Based Scoping
Dashboard helpers automatically respect user permissions:
```ruby
# This helper will only see data the user has permission to access
def department_users_metric(scoped_ar_object, scoped_filters = {})
# scoped_ar_object is already filtered based on:
# 1. Dashboard-specific permission scope (if configured)
# 2. Model policy scope (fallback)
scoped_ar_object.where(department: 'Engineering').count
end
```
#### Error Handling
Dashboard helpers include built-in error handling:
```ruby
def safe_metric_calculation(scoped_ar_object, scoped_filters = {})
begin
# Your calculation logic
scoped_ar_object.complex_calculation
rescue StandardError => e
Rails.logger.error "Dashboard metric error: #{e.message}"
0 # Return safe default value
end
end
```
### Best Practices
1. **Use descriptive method names**: `total_active_users_metric` instead of `user_count`
2. **Handle empty data gracefully**: Return appropriate defaults for empty datasets
3. **Apply filters consistently**: Use the same filter logic across related widgets
4. **Optimize queries**: Use includes, joins, and select to optimize database queries
5. **Cache expensive calculations**: Use Rails caching for complex metrics
6. **Test with different permission levels**: Ensure widgets work for all user types
### Security Considerations
- Dashboard helpers automatically receive permission-scoped data
- Filters are validated against dashboard configuration
- All database queries respect user access controls
- Error messages don't expose sensitive information
This updated dashboard system provides a secure, flexible, and powerful way to create interactive dashboards with proper permission handling and filter integration.
---
## Status Tags
### Overview
CmAdmin provides a set of Catalyst Tag classes used to display enum or state values with a colored badge. These are rendered via the `:badge` field type and are controlled using the `badge_class` option.
```ruby
column :status, field_type: :badge, badge_class: { approved: 'cm-badge-green', draft: 'cm-badge-yellow', rejected: 'cm-badge-red' }
```
:::note
The `:tag` field type and `tag_class` option were renamed to `:badge` and `badge_class` in v8.0.0. Using `:tag` raises an error at boot. See the [v8.0.0 upgrade guide](../5.%20Upgrade%20Guide/v8.0.0.md).
:::
All tag classes use a `cm-badge-` prefix (Catalyst Tag) to avoid conflicts with generic CSS utility classes.
### Available Tag Classes
| Class | Preview | Use Case Example |
| ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------- |
| `cm-badge-blue` | Sample | Active, In Progress, Enabled |
| `cm-badge-yellow` | Sample | Pending, Awaiting, Draft |
| `cm-badge-orange` | Sample | On Hold, Needs Review, Processing |
| `cm-badge-purple` | Sample | Scheduled, Queued, Resolved |
| `cm-badge-cyan` | Sample | Info, Beta, Staging |
| `cm-badge-green` | Sample | Completed, Approved, Verified |
| `cm-badge-gray` | Sample | Inactive, Archived, Unknown |
| `cm-badge-red` | Sample | Rejected, Failed, Blocked, Deleted |
### Removed Aliases
The following old class names are **no longer supported**: they have no styles, and `field` raises an error at boot if `badge_class` uses them. Replace them with the Catalyst equivalents:
| Removed | Use Instead |
| ----------------------- | ----------------- |
| `active` / `active-one` | `cm-badge-blue` |
| `active-two` | `cm-badge-yellow` |
| `active-three` | `cm-badge-orange` |
| `active-four` | `cm-badge-purple` |
| `active-five` | `cm-badge-cyan` |
| `completed` | `cm-badge-green` |
| `neutral` | `cm-badge-gray` |
| `danger` | `cm-badge-red` |
:::warning
Bootstrap badge color classes (`bg-success`, `bg-warning`, `bg-danger`, `bg-secondary`) are **not supported** as badge classes. Always use `cm-badge-*` Catalyst Tags.
:::
### Best Practices
1. **Use `cm-badge-green`** for positive terminal states (approved, verified, done)
2. **Use `cm-badge-red`** for negative terminal states (rejected, failed, blocked)
3. **Use `cm-badge-gray`** for inactive or non-significant states (archived, unknown)
4. **Use `cm-badge-blue`** for currently live or enabled items
5. **Use `cm-badge-yellow`** for items awaiting action (pending, draft)
6. **Use `cm-badge-orange` through `cm-badge-cyan`** to differentiate multiple intermediate states when two or more non-red/green statuses exist
7. **Never use Bootstrap badge color classes** — they are not equivalent and will produce inconsistent styling
---
## Catalyst Config
Catalyst Config is the mechanism cm-admin uses to bundle and sync deploy configuration files (like `.hatchbox/`) from the gem into your application. It works like Rails migrations — but for config files instead of database schema.
### How It Works
```
deploy-config/ ← gem source (ships with cm-admin)
.hatchbox/ ← synced to app_root/.hatchbox/
(any future config) ← synced to app_root/
```
1. The gem stores all deploy config files in `deploy-config/` at the gem root
2. `rake cm_admin:deploy_config:sync` copies everything inside `deploy-config/` to your app root
3. A `.deploy-config` marker file at your app root records a SHA256 checksum of the bundled config
4. On every boot in development, cm-admin compares the stored checksum against the current checksum of the gem's `deploy-config/` directory and warns if they differ
### Checksum Tracking
The gem computes a SHA256 checksum of the `deploy-config/` directory at runtime. The `.deploy-config` marker file at the app root contains the checksum that was last synced:
```
a3f5c2...
```
No manual version bump is needed — changing any file inside `deploy-config/` produces a different checksum, which triggers the sync warning automatically.
### Available Tasks
| Task | Command | Description |
|------|---------|-------------|
| Sync | `rake cm_admin:deploy_config:sync` | Copies everything from `deploy-config/` to app root and writes the marker |
| Check | `rake cm_admin:deploy_config:check` | Fails (non-zero exit) if config is out of date — use in CI |
### Boot Check
In development, cm-admin checks config sync on every boot. If the marker checksum is stale or missing, you'll see:
```
⚠️ Deploy Config is out of date
Run `rake cm_admin:deploy_config:sync` and commit the changes.
```
This never runs in production and never syncs automatically — you must run the sync task manually and commit the result.
### Workflow for App Developers
When updating the cm-admin gem:
1. Run `bundle update cm-admin`
2. Start the app — if the boot warning appears, run `rake cm_admin:deploy_config:sync`
3. Commit the synced files (e.g. `.hatchbox/`, `.deploy-config`)
4. Deploy
For CI, add a check after `bundle update`:
```bash
bundle exec rake cm_admin:deploy_config:check
```
This fails the build if config is out of date, preventing deploying stale config.
### Workflow for Gem Developers
#### Adding a New Config File or Folder
1. Create the file or folder inside `deploy-config/`:
```
deploy-config/
.hatchbox/ ← existing
.new-tool/ ← new — just drop it in
├── setup.sh
└── config.yml
```
2. Commit and release a new gem version
That's it — no code changes needed. The checksum changes automatically, so all apps will get the sync warning on next boot and `rake cm_admin:deploy_config:sync` will copy the new files to their app root.
#### Modifying an Existing Config File
1. Edit the file inside `deploy-config/` (e.g. `deploy-config/.hatchbox/lib/config.rb`)
2. Commit and release
#### Important Notes
- **Sync is destructive**: `sync!` removes existing files at the target before copying. Any app-local modifications to synced files will be overwritten.
- **No manual version bump needed**: The SHA256 checksum detects changes automatically when any file inside `deploy-config/` is added, removed, or modified.
- **Commit after syncing**: The synced files must be committed to the app's git repo — deploy platforms like Hatchbox read from git, not from the gem at boot time.
### Current Contents
| Path | Purpose |
|------|---------|
| `deploy-config/.hatchbox/` | Hatchbox deployment scripts with Slack notifications |
See [HatchboxScripts](./HatchboxScripts.md) for details on the `.hatchbox/` config.
---
## **Guide to Deploying a Rails App to Hatchbox**
---
### **1️⃣ Prepare Your App Locally**
1. Make sure your Rails app runs locally without errors.
2. Commit all code to **GitHub** (Hatchbox pulls from Git).
---
### **2️⃣ Create a Cluster in Hatchbox**
1. Log in to **[Hatchbox.io](https://www.hatchbox.io)**.
2. Click **New Cluster**.
3. Fill in:
**Page 1: Create New Cluster**
* **Name**: Your cluster name (e.g., `Uddhava Production`).
* **Hosting Provider**: AWS, DigitalOcean, Linode, etc.
* **Region**: Choose the region closest to your users (e.g., `ap-south-1`).
**Page 2: Connect to Service Provider (Example: AWS)**
* **Name**: Account name to recognize it.
* **Access Key ID**: Your AWS Access Key ID.
* **Secret Access Key**: Your AWS Secret Access Key.
* **Is this a GovCloud account?**: No.
**Page 3: Choose Region**
* **Region**: Choose the region closest to your users (e.g., `ap-south-1`).
4. Save the cluster.
---
### **3️⃣ Create a VPC Setting in Hatchbox**
1. In **Cluster** tab → Click the cluster or **New Server**.
2. If prompted, click **Create and Configure VPC for me**.
3. Choose any subnet and set AZ to **Let AWS choose for me**.
4. Save the cluster.
---
### **4️⃣ Create a Server in a Cluster in Hatchbox**
1. **Name**: Server name (e.g., `uddhava-production-server`).
2. **Size**: Choose server size (e.g., `t4g.small` for most apps).
3. **Volume**: Choose volume size (e.g., `50GB` for most apps).
4. **Role**: Choose server role (e.g., `Web Server`, `Background Worker`, `Cron Jobs`, `Database`, `Redis`).
---
### **5️⃣ Create an App in Hatchbox**
1. In **App** tab → Click **New App**.
2. Add:
* **Cluster**: Choose your cluster.
* **Name**: Your app name (e.g., `uddhava-production`).
---
### **6️⃣ Setting Up the App Repository in Hatchbox**
1. In **Current App** tab (the one you created).
2. Add:
* **Git Host**: Connect to your host or select a pre-set host (e.g., `Commutatus`).
* **Repository**: Your repository path (e.g., `commutatus/uddhava`).
* **Branch**: Branch to deploy (e.g., `main`).
3. Set **Automatic Deploys**.
---
### **7️⃣ Creating Database in Hatchbox (Unmanaged)**
1. Create a PostgreSQL instance in AWS RDS:
* Go to Databases tab in Current App → Click required Unmanaged Database.
* For PostgreSQL: Provide DB name (e.g., uddhava_production).
* For Redis: Just click create.
* The database will be created and connected to your app via Environment Variables.
---
### **8️⃣ Assign Cron Jobs in Hatchbox**
1. Go to **Cron Jobs** tab in Current App → Click **New Cron Job**.
2. Add:
* **Name**: Cron job name (e.g., `uddhava-production-cron`).
* **Command**: Cron job command (e.g., `bundle exec wheneverize . && bundle exec whenever --update-crontab`).
* **Schedule**: Cron job schedule (e.g., `*/5 * * * *`).
---
### **9️⃣ Adding Environment Variables in Hatchbox**
1. Go to **Environment Variables** tab in Current App → Click **New Environment Variable**.
2. Add:
* **Name**: Environment variable name (e.g., `DATABASE_URL`).
* **Value**: Environment variable value (e.g., `postgres://username:password@rds-endpoint:5432/uddhava_production`).
3. Click **Save Changes** after adding all variables.
---
### **🔟 Deploy the App to Hatchbox**
1. In Hatchbox → App → Click **Deploy**.
2. Hatchbox will:
* Clone repo
* Install Ruby + Gems
* Precompile assets
* Run migrations
> **Note:** If errors occur, check **Process Logs** in Hatchbox.
> Sidekiq and Rails server will be set up and started automatically.
---
### **1️⃣1️⃣ Domain & SSL**
1. In Hatchbox → App → **Domains** → Add your domain.
2. Point DNS to your Hatchbox on Route53.
---
### **Bonus: Setting Up AWS RDS (Managed)**
1. Go to **Database** tab in Current App → Click **New Database**.
* Follow screenshots (`Screenshot_1.png`, `Screenshot_2.png`).
* Choose the **VPC** selected in Hatchbox.
* If prompted for subnets, create one in any remaining AZ in the VPC.
2. Go to **Environment Variables** in your app:
```
DATABASE_URL=postgres://USERNAME:PASSWORD@RDS-ENDPOINT:5432/uddhava_production
```
Save.
---
### **SSH into Your Server**
1. In Hatchbox → App → Click **SSH Keys** → Add your SSH key.
2. In the current app, go to **Process** and find the SSH command for the server.
3. Open Terminal and paste the SSH command.
---
### References
Video Links:
- [Part 1 Video][part1]
- [Part 2 Video][part2]
[part1]: https://commutatus-cm-docs.s3.ap-south-1.amazonaws.com/hatchbox-setup-part-1.mp4
[part2]: https://commutatus-cm-docs.s3.ap-south-1.amazonaws.com/hatchbox-setup-part-2.mp4
✅ **After this, your app should be live with**:
* **Main App Server** handling requests.
* **Worker Server** running background jobs.
* **AWS RDS** as the database.
* **Managed by Hatchbox** for deployments.
---
## Hatchbox Deployment Scripts
Version-control your Hatchbox build/deploy scripts. Hatchbox auto-detects these scripts during deployment.
### Setup
1. Run `rake cm_admin:deploy_config:sync` in your app to copy `.hatchbox/` to the app root (this also records the checksum marker `.deploy-config`)
2. Commit the changes and remove scripts from the Hatchbox dashboard
### Keeping Config in Sync
Hatchbox reads `.hatchbox/` from the **deployed app's git repo** — gems installed via Bundler are never scanned. So when you update the `cm-admin` gem and its bundled config changes, sync it into your app:
```bash
rake cm_admin:deploy_config:sync
```
The gem stores all deploy config files in a `deploy-config/` directory at the gem root. The sync task copies everything inside `deploy-config/` to your app root and writes a `.deploy-config` marker file tracking a SHA256 checksum of the bundled config.
| Mechanism | When it runs | Behavior |
|-----------|--------------|----------|
| `rake cm_admin:deploy_config:sync` | Manual, after each `cm-admin` gem update | Copies everything from the gem's `deploy-config/` directory into the app root and writes the checksum marker (`.deploy-config` at app root) |
| App boot check (development) | Every boot | Warns like the pending-migrations alert when the marker checksum differs from the gem's `deploy-config/` checksum — sync is never done automatically |
| `rake cm_admin:deploy_config:check` | CI | Fails the build when config is out of date (add after `bundle update cm-admin`) |
To add new deploy config files in the future, just drop them into `deploy-config/` in the gem source — no code changes needed. The checksum changes automatically and all apps will get the sync warning on next boot.
After syncing, **commit the changes before deploying** — Hatchbox picks them up from git, not from a boot-time copy.
### Environment Variables
| Variable | Used for | Example |
| ----------------------------------- | ----------------------------------------------------------------------------------- | --------------------------------------- |
| `APP_NAME` | App name shown in the notification message | `Uddhava Staging` |
| `APP_URL` | "View Application" button | `https://myapp-staging.com` |
| `SLACK_WEBHOOK_URL` | Slack webhook to post to | `https://hooks.slack.com/services/...` |
| `HATCHBOX_APP_ID` + `LOG_ID` | "View Hatchbox Log" button | Provided by Hatchbox |
| `DEPLOYMENT_ALERTS` | Set to `false` to skip notifications entirely | `true` |
### Configuration
**`lib/config.rb`** - every value is read from the environment, so the scripts stay independent of the host app:
```ruby
module Hatchbox
module Config
def self.app_name
ENV.fetch('APP_NAME', nil)
end
def self.slack_webhook
ENV.fetch('SLACK_WEBHOOK_URL', nil)
end
end
end
```
Set `SLACK_WEBHOOK_URL` in your Hatchbox environment variables to the Slack channel/workspace you want to post to. If unset, deployment notifications are skipped and the script warns that the variable isn't set.
### Skipping Notifications
Set `DEPLOYMENT_ALERTS` to `false` in your Hatchbox environment variables to stop deployment notifications from being sent during Hatchbox builds:
```
DEPLOYMENT_ALERTS=false
```
When it's disabled, `hatchbox_notification.rb` exits before sending any Slack notification. This is useful for suppressing notifications in certain environments (e.g. CI builds, preview apps).
### Files
```
.hatchbox/
├── lib/
│ ├── config.rb # Env-var values for the notifications (APP_NAME, APP_URL, etc.)
│ ├── event_types.rb # Slack payload definitions (don't touch)
│ └── hatchbox_notification.rb # Notification entrypoint, run via ruby (don't touch)
├── pre-build # Deployment started
├── post-deploy # Deployment complete
└── failed-deploy # Deployment failed
```
### Slack Notification Format
The scripts send compact Slack notifications with the following format:
- **Title**: Deployment status header (Deployment Started, Deployment Complete, Deployment Failed)
- **Message**: One line with the app name and status (e.g., ":hourglass_flowing_sand: Uddhava - Staging has started to deploy via Hatchbox.")
- **Buttons**:
- "View on Hatchbox" - Links to the Hatchbox deployment log page
- "View Application" - Links to the deployed app (`APP_URL`)
---
## Release Changelog & Notifications
cm-admin automates release notes generation, changelog docs updates, and Slack notifications as part of the gem release process. When a release is published, an AI-generated summary of the changes is posted to the docs site and to Slack — no manual changelog writing required.
### How It Works
```
release-cm-gem.yml (manual dispatch)
└─ build & push gem
└─ changelog-release.yml (reusable workflow)
├─ collect_context.sh → gathers git log, PR bodies, docs diff
├─ generate_release_notes.rb → calls OpenRouter, returns structured JSON
├─ Create GitHub Release → tag + AI-generated body
├─ update_docs_page.sh → prepends entry to monthly changelog page
├─ update_release_registry.rb→ updates docs-site/static/releases.json
├─ Commit & push to main → docs changes
└─ notify_slack_release.rb → rich Slack notification
```
### Workflow: `changelog-release.yml`
A reusable workflow (`workflow_call`) invoked by `release-cm-gem.yml` after the gem is pushed. It receives the release version as input.
| Input | Type | Description |
| -------- | ------ | ------------------------------------ |
| `version` | string | Release tag, e.g. `v7.2.26` |
#### Required Secrets
| Secret | Purpose |
| -------------------- | ---------------------------------------------------- |
| `OPENROUTER_API_KEY` | Authenticates the release-notes generation call |
| `SLACK_WEBHOOK_URL` | Incoming webhook for the Slack notification |
| `MARVIN_GH_PAT` | PAT used to check out, create the release, and push docs commits to `main` |
#### Optional Variables
| Variable | Purpose |
| ------------------ | -------------------------------------------------------------- |
| `OPENROUTER_MODEL` | Overrides the default model (`openai/gpt-6-luna`) |
### Components
#### `collect_context.sh`
Gathers everything the AI needs into `/tmp/release-context/`:
- **`git_log`** — commits since the previous tag (up to 50)
- **`pr_bodies`** — merged PR titles and bodies since the previous tag's commit date (via `gh pr list --search "merged:>="`)
- **`docs_diff`** — diff stat and content of `docs-site/` changes since the previous tag
#### `generate_release_notes.rb`
Sends the collected context to OpenRouter with a strict JSON schema (`response_format`) and prints the result to stdout:
```json
{
"docs_markdown": "...", // full changelog page content
"slack_highlights": "...", // 1-2 sentence paragraph for the ✨ Highlights section
"slack_details": ["..."], // bullet items for the 📋 Details section
"warning": "..." | null, // breaking-change warning, if any
"migration_commands": ["..."] | null
}
```
If `OPENROUTER_API_KEY` is unset or the API call fails, the script falls back to raw git-log output so the release never blocks on AI availability.
#### `update_docs_page.sh`
Prepends a `## ()` section to the current month's changelog page at `docs-site/docs/Changelog/.md`. If the file doesn't exist yet, it creates it with frontmatter (`sidebar_position` derived from the year-month) and a `# Release Notes — ` header.
#### `update_release_registry.rb`
Appends release metadata to `docs-site/static/releases.json`:
```json
{
"version": "7.2.26",
"date": "2026-09-07",
"has_breaking_changes": false,
"has_migrations": false,
"docs_url": "https://docs.cm-admin.commutatus.com/docs/Changelog/2026-09"
}
```
Entries are deduplicated by version and sorted with `Gem::Version` semantics.
#### `notify_slack_release.rb`
Posts a Block Kit message to the Slack webhook containing:
- Header with the version
- ✨ Highlights paragraph summarizing the release
- ⚠️ Breaking Changes block (shows a "✅ No breaking changes" fallback when there are none)
- 📋 Details bullets with emoji and PR links
- Buttons linking to the GitHub release and the changelog docs page
- Context footer with the release date
### Prompt Template
The AI prompt lives at `.github/prompts/prompt_template.txt`. It instructs the model to produce categorized release notes (🚀 Features, 🐛 Bug Fixes, ✨ Improvements, ⚠️ Breaking Changes, 📚 Documentation) plus Slack content: a highlights paragraph, detail bullets with PR links, and breaking-change/migration info. The Slack Blocks themselves are built by `notify_slack_release.rb`. Edit this file to tune the tone or structure of generated notes.
### Changelog Docs
Generated pages live under `docs-site/docs/Changelog/`, one file per month (`2026-09.md`), with an `index.md` landing page. Each release prepends its section, so the newest release appears first within a month.
---
## Releasing the Gem
cm-admin is published to the private **CM Gems** registry via two manually dispatched GitHub workflows: a stable release from `main` and a beta release from `beta`.
### Stable Release — `release-cm-gem.yml`
**Workflow name**: Bump Gem to CM Gems
**Trigger**: Manual (`workflow_dispatch`) — must run on the `main` branch
#### Input
| Input | Type | Options | Default | Description |
| ----------- | ------ | ------------------------ | ------- | -------------------------------------------------- |
| `bump_type` | choice | `patch`, `minor`, `major` | `patch` | Semantic version segment to bump via `gem-release` |
#### Pipeline
```
build (main branch only)
├─ Checkout with MARVIN_GH_PAT
├─ Set up Ruby 3.4 + bundler cache
├─ gem install gem-release
├─ gem bump --version → updates lib/cm_admin/version.rb
├─ bundle install → refreshes Gemfile.lock
└─ Commit & push to main → "Add bundle files"
release (needs: build)
├─ Checkout + git pull
├─ gem build cm_admin.gemspec
├─ Read version from CmAdmin::VERSION → exposed as job output
└─ ./upload.sh $CM_GEMS_PASSWORD $CM_GEMS_HOST
changelog (needs: release)
└─ Calls changelog-release.yml with version=v
→ AI release notes, GitHub Release, docs update, Slack notification
(see Release Changelog & Notifications)
```
#### Secrets & Variables
| Name | Kind | Purpose |
| ------------------ | ------- | -------------------------------------------- |
| `MARVIN_GH_PAT` | secret | Checkout and push version-bump commits |
| `CM_GEMS_PASSWORD` | var | Password for the private gem host |
| `CM_GEMS_HOST` | var | Hostname of the private gem registry |
| `OPENROUTER_API_KEY`, `SLACK_WEBHOOK_URL` | secret | Passed through to the changelog workflow |
### Beta Release — `release-beta-gem.yml`
**Workflow name**: Release Beta Version to CM Gems
**Trigger**: Manual (`workflow_dispatch`) — must run on the `beta` branch
#### How Beta Versioning Works
1. Reads the base version from `CmAdmin::VERSION` and strips any existing `.beta` suffix
2. Counts existing git tags matching `v.beta*` to determine the next beta number
3. Writes `.beta` into `lib/cm_admin/version.rb` (e.g. `7.2.26.beta1`, `7.2.26.beta2`, …)
4. Commits the version bump to `beta`, builds the gem, and pushes to CM Gems
5. Creates a GitHub **pre-release** tag `v.beta` via `softprops/action-gh-release`
Beta releases do not trigger the changelog workflow — they are pre-releases for testing only.
### Notes
- Both workflows abort early if dispatched from the wrong branch (`main` / `beta` respectively)
- `bundle config set frozen false` is set before `bundle install` so `Gemfile.lock` can be updated during the bump
- The stable release's changelog step runs **after** the gem is pushed, so a changelog failure never blocks the gem publish
---
## PR Review Apps
Every non-draft pull request to `main` gets its own running copy of `feature-app/`, built from the PR's code, at:
```
https://pr-.catalyst.platforms.commutatus.com
```
Reviewers can click through the change in a real app instead of pulling the branch locally. The app is redeployed on every push and destroyed when the PR is closed or merged.
### At a Glance
| Piece | What it is | Where it's defined |
| ---------------- | ------------------------------------------------------------------ | --------------------------------------------- |
| Workflow | GitHub Actions job that deploys and tears down review apps | `.github/workflows/review-app.yml` |
| Fly app | `cm-admin-pr-`: Puma, Sidekiq and Redis on one machine | `feature-app/fly.review.toml` |
| Docker image | Built from the repo root so the gem source is this PR's checkout | `feature-app/Dockerfile.review` |
| Database | Neon branch `pr-`, one per PR | Created by the workflow |
| File uploads | Tigris bucket `cm-admin-pr-`, one per PR | Created by the workflow, `config/storage.yml` |
| Router | `cm-admin-review-router`: Caddy that maps each `pr-*` host to its app | `feature-app/review-router/` |
| Rails env | `review`, which behaves like staging | `feature-app/config/environments/review.rb` |
| Seeds | Shared seeds plus the reviewer accounts | `feature-app/db/seeds/review.rb` |
### Lifecycle
```
PR opened / reopened / pushed to / marked ready for review
└─ deploy job
├─ Create Neon branch pr- (reused if it exists)
├─ Create Fly app cm-admin-pr- (reused if it exists)
├─ Create Tigris bucket (reused if it exists)
├─ Stage secrets: DATABASE_URL, POSTMARK_API_TOKEN, APP_HOST,
│ SECRET_KEY_BASE (first deploy only)
├─ flyctl deploy (remote build of Dockerfile.review)
│ └─ release command: bin/review-app-release
│ ├─ bin/rails db:prepare (schema + seeds on first run, migrations after)
│ └─ bin/rails db:seed (fills in cm_admin permissions)
├─ Set the Tigris bucket's CORS rules (so browsers can direct-upload to it)
├─ Allocate a private Flycast address (so the router can reach the app)
└─ Post or update the "Review app:" comment on the PR
PR closed or merged
└─ teardown job
├─ Destroy the Tigris bucket and the Fly app
└─ Delete the Neon branch
```
Every step in the deploy job is safe to repeat, so a push to an existing PR simply redeploys the same app against the same database. Pushes that arrive while a deploy is running cancel it, so only the latest commit is deployed.
#### When a PR does *not* get a review app
The deploy job is skipped when:
- the PR is a **draft** (marking it ready for review triggers the deploy);
- the PR comes from a **fork** or from **Dependabot**, since GitHub doesn't give those runs the Actions secrets;
- the PR targets a branch other than `main`;
- every changed file is documentation: `**/*.md`, `**/*.mdx`, `docs/**` or `docs-site/**`.
### Using a Review App
1. Open the link in the **Review app** comment on the PR. The comment is updated in place on each deploy and shows the commit SHA it was built from.
2. Sign in with your email address. It must be one of the users listed in `feature-app/db/seeds/review.rb`.
3. Enter the one-time code that is emailed to you.
Things to keep in mind:
- **It sleeps when idle.** Fly suspends the machine after a period without traffic, and the first request afterwards can take a few seconds while it wakes.
- **Emails are really sent** through Postmark. Don't trigger mail to addresses outside the team.
- **Data persists across pushes.** The Neon branch and Tigris bucket live as long as the PR, so records and uploads you create survive redeploys. Sessions do too, as `SECRET_KEY_BASE` is generated once per app.
- **Background jobs run.** Sidekiq runs inside Puma (`EMBEDDED_SIDEKIQ=true`), using the `default` and `mailers` queues.
#### Adding a reviewer
Add an entry to the `users` list in `feature-app/db/seeds/review.rb` and merge it to `main`. The seeds run on every deploy and only create users that don't exist yet, so open PRs pick the new user up on their next push once they include the change.
### How It Fits Together
#### Routing
```
Browser ── https://pr-42.catalyst.platforms.commutatus.com
│
▼
Fly edge (TLS, wildcard certificate *.catalyst.platforms.commutatus.com)
│
▼
cm-admin-review-router (Caddy)
│ Host ^pr-(\d+)\.catalyst… → cm-admin-pr-42.flycast:80
▼
cm-admin-pr-42 (Puma on :3000)
```
The router holds a single wildcard certificate, so review apps need no certificate or DNS record of their own and don't count towards Let's Encrypt's weekly limit for the domain. It reaches each app over **Flycast** (Fly's private network), which is HTTP-only, so HTTPS is forced at the router and Caddy passes `X-Forwarded-Proto: https` on to Rails. Requests through Flycast wake a suspended app just as public requests do. Any host that doesn't match the pattern gets a 404 saying "No review app here."
Each app is also reachable directly at `https://cm-admin-pr-.fly.dev`.
#### The image
`Dockerfile.review` is built with the **repo root** as its context, so `feature-app`'s `gem 'cm-admin', path: '../'` uses the PR's version of the gem. In particular:
- `config/database.review.yml` is copied over `config/database.yml`.
- `bundle install` is not frozen, because `Gemfile.lock` can lag behind the gem version in the checkout.
- `assets:precompile` also runs `cm_admin:scss:build`, which needs npm in the repo root.
- `bin/review-app-start` is the entrypoint. It starts Redis inside the machine before Puma, so Redis suspends and resumes along with the app.
`Dockerfile.review.dockerignore` keeps `.git`, `node_modules`, logs, master keys and the docs site out of the build context. Because `.git` is excluded, `cm_admin.gemspec` falls back to a file glob for its file list.
#### The `review` environment
`config/environments/review.rb` loads the staging config and then:
- points `db:seed` at `db/seeds/review.rb`;
- reads the Postmark token from the `POSTMARK_API_TOKEN` Fly secret, since only development credentials are committed;
- builds mailer links from `APP_HOST`;
- stores Active Storage uploads in the app's Tigris bucket.
#### The database
Each PR gets a Neon branch named `pr-`, branched from the project's default branch. The app connects with the **direct (unpooled)** URL, because PgBouncer's transaction mode breaks prepared statements and migration advisory locks.
### Configuration
#### Repository secrets and variables
| Name | Type | Used for |
| -------------------- | -------- | --------------------------------------------------------- |
| `FLY_API_TOKEN` | Secret | Creating, deploying and destroying Fly apps and buckets |
| `NEON_API_KEY` | Secret | Creating and deleting Neon branches |
| `POSTMARK_API_TOKEN` | Secret | Sending OTP and other emails from review apps |
| `FLY_ORG` | Variable | Fly organisation that review apps are created in |
| `NEON_PROJECT_ID` | Variable | Neon project that PR branches are created in |
#### One-off router setup
The router has to exist before any review app is reachable on the custom domain. From `feature-app/review-router/`:
```bash
fly apps create cm-admin-review-router --org
fly deploy
fly certs add "*.catalyst.platforms.commutatus.com"
```
Then add two records in Route 53:
1. the `_acme-challenge` CNAME that `fly certs setup "*.catalyst.platforms.commutatus.com"` prints;
2. `*.catalyst.platforms.commutatus.com` CNAME `cm-admin-review-router.fly.dev`.
After changing the `Caddyfile`, run `fly deploy` in that directory again.
### Troubleshooting
| Symptom | Likely cause and fix |
| ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| No **Review app** comment on the PR | The PR is a draft, from a fork or Dependabot, or only touches docs. Check the "Review app" workflow run in the Actions tab. |
| "No review app here." (404) | The Fly app doesn't exist yet or the host is mistyped. Wait for the deploy job to finish. |
| Deploy fails in the release command | A migration or seed failed. Check the release logs with `fly logs --app cm-admin-pr-`. |
| The domain hangs or errors but `fly.dev` works | The app has no Flycast address, or the router is down. Re-run the workflow, or check `fly status --app cm-admin-review-router`. |
| No OTP email arrives | Your email isn't in `db/seeds/review.rb`, or the `POSTMARK_API_TOKEN` secret is wrong. |
| First request is slow | Expected: the machine was suspended and is waking up. |
| Need a clean database | Delete the `pr-` branch in Neon and push to the PR (or re-run the workflow). The deploy creates a fresh branch and seeds it. |
| Resources left behind after a PR closed | The teardown job tolerates missing resources but can still fail. Re-run it from the Actions tab, or destroy `cm-admin-pr-` and the Neon branch by hand. |
---
## Deployment Documentation
Guides for deploying Rails applications to various hosting platforms.
### Available Guides
- **[Deploy on Hatchbox](./DeployOnHatchbox.md)** - Step-by-step guide for deploying Rails apps to Hatchbox with AWS infrastructure
- **[Catalyst Config](./CatalystConfig.md)** - How cm-admin bundles and syncs deploy config files from the gem into your app
- **[Hatchbox Deployment Scripts](./HatchboxScripts.md)** - Version-controlled build/deploy scripts with Slack notifications
- **[PR Review Apps](./ReviewApps.md)** - How every pull request gets its own deployed copy of the feature app, and how it is torn down
---
## Upgrade Guide
This guide helps you upgrade between versions of cm-admin. Each version has its own guide listing breaking changes, method renames, and migration steps.
### Versions
- [v9.0.0](./v9.0.0.md)
- [v8.4.1](./v8.4.1.md)
- [v8.4.0](./v8.4.0.md)
- [v8.3.3](./v8.3.3.md)
- [v8.2.18](./v8.2.18.md)
- [v8.2.0](./v8.2.0.md)
- [v8.0.3](./v8.0.3.md)
- [v8.0.0](./v8.0.0.md)
---
## v8.0.0
### Breaking Changes
#### Model Configuration Methods — Renamed
Model configuration uses declarative methods inside the `cm_admin do` block. Pass a value to configure an option; calling the same method without an argument returns its current value.
| Before | After |
| --------------------------------- | ----------------------------------- |
| `set_icon 'fa fa-file'` | `icon_name 'fa fa-file'` |
| `set_display_name 'Blog Post'` | `display_name 'Blog Post'` |
| `set_policy_scopes [...]` | `policy_scopes [...]` |
| `set_default_scope [...]` | `default_scopes [...]` |
| `self.comments_tab = {...}` | `comments_tab mentions_model: User` |
| `visible_on_sidebar false` | `visible_on_sidebar false` |
| `permit_additional_fields [...]` | `additional_permitted_fields [...]` |
| `override_pundit_policy true` | `override_policy true` |
| `self.mcp_enabled = true` | `mcp_enabled true` |
| `self.description = '...'` | `description '...'` |
| `self.global_search_fields = ...` | `global_search_fields [...]` |
Standard Ruby assignment remains available for programmatic configuration outside the DSL, for example `model.icon_name = 'fa fa-file'`.
#### Internal Method Renames
These are internal changes. If you were reading these values directly, update references:
| Before | After |
| ----------------------------- | ----------------------------- |
| `model.is_visible_on_sidebar` | `model.visible_on_sidebar` |
| `model.mcp_action_list` | `model.available_mcp_actions` |
:::note
The `mcp_actions` DSL method (used inside `cm_admin do` blocks) is unchanged. Only the internal reader was renamed.
:::
#### Sort Configuration — Use `sort` for Each Column
The `sortable_columns`, `sort_column`, and `sort_direction` methods have been removed. Define each sortable column with a separate `sort` call instead.
```ruby
# Before
sortable_columns [
{ column: 'name', display_name: 'Name' },
{ column: 'created_at', display_name: 'Created At', default: true, default_direction: 'desc' },
{ column: 'metadata_updated_at', display_name: 'Metadata Updated At', sort_datatype: 'TIMESTAMP' }
]
# After
sort column: 'name', display_name: 'Name'
sort column: 'created_at', display_name: 'Created At', default: true, default_direction: 'desc'
sort column: 'metadata_updated_at', display_name: 'Metadata Updated At', datatype: 'TIMESTAMP'
```
The `sort_datatype` option has also been renamed to `datatype`.
If you previously configured the default sort separately, combine it into one `sort` declaration:
```ruby
# Before
sort_column :created_at
sort_direction :desc
# After
sort column: 'created_at', default: true, default_direction: 'desc'
```
:::warning
Sort query parameters now use the nested `sort[column][dir]` format to support multiple sort columns. Update any saved URLs or integrations that construct the old `sort_column`, `sort_direction`, or `sort_datatype` parameters.
:::
#### Tag Fields Renamed to Badge Fields
The `:tag` field type and `tag_class` option have been renamed to `:badge` and `badge_class`. Update both index columns and show-page fields.
```ruby
# Before
column :status, field_type: :tag, tag_class: { approved: 'cm-badge-green', draft: 'cm-badge-yellow' }
field :status, field_type: :tag, tag_class: { approved: 'cm-badge-green', draft: 'cm-badge-yellow' }
# After
column :status, field_type: :badge, badge_class: { approved: 'cm-badge-green', draft: 'cm-badge-yellow' }
field :status, field_type: :badge, badge_class: { approved: 'cm-badge-green', draft: 'cm-badge-yellow' }
```
The supported CSS class names remain `cm-badge-blue`, `cm-badge-yellow`, `cm-badge-orange`, `cm-badge-purple`, `cm-badge-cyan`, `cm-badge-green`, `cm-badge-gray`, and `cm-badge-red`.
#### Migration Steps
1. Update all model concern files. Here is a before/after example:
```ruby
# Before
cm_admin do
actions except: %i[import]
set_display_name 'Cron Job'
set_icon 'fa-regular fa-clock'
visible_on_sidebar false
set_policy_scopes [{ scope_name: 'by_current_user', display_name: 'User Only' }]
permit_additional_fields [category_ids: []]
end
# After
cm_admin do
actions except: %i[import]
display_name 'Cron Job'
icon_name 'fa-regular fa-clock'
visible_on_sidebar false
policy_scopes [{ scope_name: 'by_current_user', display_name: 'User Only' }]
additional_permitted_fields [category_ids: []]
end
```
2. Replace `sortable_columns`, `sort_column`, and `sort_direction` with one `sort` declaration per sortable column
3. Replace `sort_datatype` with `datatype` and update integrations that construct sort query parameters
4. Replace `field_type: :tag` with `field_type: :badge` and `tag_class` with `badge_class`
5. If you reference `is_visible_on_sidebar` or `mcp_action_list` anywhere in your app, update those to `visible_on_sidebar` and `available_mcp_actions`
#### Migration Template File Extensions
All migration template files in generators have been renamed from `.rb` to `.tt` extensions to follow Rails conventions.
**Impact:**
- This change only affects generator templates, not existing migrations
- No action required for existing applications
---
## v8.0.3
### Breaking Changes
#### `FileImport` and `FileExport` Models Renamed
The built-in `FileImport` and `FileExport` models have been renamed to `CtFileImport` and `CtFileExport` to avoid colliding with identically named models in host applications. Every public surface that used the old names has been renamed:
| Before | After |
| -------------------------------------------- | ------------------------------------------------ |
| `FileImport` / `FileExport` models | `CtFileImport` / `CtFileExport` |
| `file_imports` / `file_exports` tables | `ct_file_imports` / `ct_file_exports` |
| `CmAdmin::FileImport` concern | `CmAdmin::CtFileImport` |
| `CmAdmin::FileExport` concern | `CmAdmin::CtFileExport` |
| `FileImportPolicy` | `CtFileImportPolicy` |
| `cm_*_file_import_path` route helpers | `cm_*_ct_file_import_path` |
| `cm_*_file_export_path` route helpers | `cm_*_ct_file_export_path` |
| `file_import[...]` form/param key | `ct_file_import[...]` |
| `included_models = [FileImport, FileExport]` | `included_models = [CtFileImport, CtFileExport]` |
##### Migration Steps
1. Run the rename generator to create a migration for your application:
```bash
rails generate cm_admin:rename_file_import_and_export_tables
rails db:migrate
```
The generated migration renames `file_imports` to `ct_file_imports` and `file_exports` to `ct_file_exports`. It also rewrites stored model-name references in `active_storage_attachments.record_type`, `versions.item_type`, `cm_permissions.ar_model_name`, and `cm_index_preferences.ar_model_name`.
2. Update `config.included_models` in `config/initializers/zcm_admin.rb` if you list the old constants:
```ruby
# Before
config.included_models = [FileImport, FileExport]
# After
config.included_models = [CtFileImport, CtFileExport]
```
3. Update any references in your application code:
- Constant references: `FileImport`, `FileExport`, `FileImportPolicy`
- Associations such as `has_many :file_exports` or `belongs_to :file_import`
- Route helpers like `cm_show_file_export_path` or `cm_index_file_import_path`
- Custom import forms or API clients that post a `file_import[...]` param key — the key is now `ct_file_import[...]`
- Factories, seeds, or permission records that store `FileImport`/`FileExport` model names
:::note
The `file_import:` keyword argument on importer classes (`initialize(file_import:)`) and `CmAdmin::BaseImporter`'s `file_import` reader are unchanged — only the model, table, route, and param names were renamed.
:::
### New Configuration
#### `config.user_class` — Configurable User Model
Hardcoded `::User` references have been replaced with `CmAdmin.config.user_class`, which defaults to `'User'`. If your application uses a different user model, set it in the initializer:
```ruby
CmAdmin.configure do |config|
config.user_class = 'AdminUser'
# or
config.user_class = AdminUser
end
```
The value is lazily constantized, so it can be set before the model is loaded or after a Rails code reload.
It is used by the default `user_lookup` (OTP and password auth), the `Trackable` concern's `created_by`/`updated_by` associations, `CmRole#users`, `CmEmailLog#triggered_by`, `CmSupportTicket` associations and search, and the comments/mentions model.
**Impact:**
- No action required if your user model is `User`.
- If your app already overrides `user_lookup` for auth, consider setting `user_class` as well — other features (mentions, history, support tickets) read it independently of `user_lookup`.
### Dependencies
#### Devise Is Now a Required Dependency
`devise (>= 4.9)` has moved from an opt-in extra to a required dependency and is loaded by the engine at boot (`ostruct >= 0.6.0` was also added). Bundler installs it automatically, but note:
- If your Gemfile pins `devise` below 4.9, relax the constraint.
- Apps that don't use Devise still work — `auth_method :custom` remains available — but Devise will now be loaded in your process.
### Internal Changes
#### Removed `$available_actions` Global Variable
`CmAdmin::Model` previously stored the model's action list in a process-wide global variable, `$available_actions`, while dynamically generating the model's controller and Pundit policy. The generated classes now use the model's own `available_actions` attribute, and the global variable is no longer set.
**Impact:**
- No action required for standard applications — this is an internal refactor with no public API changes.
- Removing the global fixes a subtle issue where `$available_actions` retained the last-initialized model's actions for the lifetime of the process and could be shared across models defined in the same process.
- If your application references `$available_actions` directly (for example, in an initializer or monkey patch), read the model's `available_actions` attribute instead:
```ruby
# Before
$available_actions
# After
CmAdmin::Model.find_by(name: 'User').available_actions
```
---
## v8.2.0
This release adds [**Advanced Filters**](../3.%20Features/AdvancedFilters.md) (nested AND/OR condition groups with operators such as `is_not`, `greater_than`, `is_empty`) and [**Saved Views**](../3.%20Features/SavedViews.md), which replace Index Preferences / Manage Columns. Saved views store filters, sort, visible columns, records per page, and view type for each user.
### Breaking Changes
#### `filter_with` Scopes Now Receive a `condition` Argument
Every scope referenced by `filter_with` on a `:single_select`, `:multi_select`, `:date`, or `:range` filter is now called with **two arguments**: the filter value and the selected operator (`condition`).
| Filter type | Before | After |
| --------------------------------- | ------------------------------- | --------------------------------------- |
| `:single_select`, `:multi_select` | `records.send(scope, value)` | `records.send(scope, value, condition)` |
| `:date`, `:range` | `records.send(scope, from, to)` | `records.send(scope, value, condition)` |
| `:search` | `records.send(scope, value)` | Unchanged |
The date and range behaviour has changed most:
- `value` is now the **raw string**, for example `"2026-01-01 to 2026-01-31"` or `"18 to 30"`. Previously the gem split it into `from` and `to`.
- For single-value operators (`greater_than`, `less_than`, `is`, …), `value` holds a single value with no `to`.
`condition` is `nil` for the standard filter bar. It is set only when the filter is used inside Advanced Filters.
:::danger
Scopes defined with `lambda` / `->` enforce arity strictly. A scope that still takes one argument (or `from, to`) raises `ArgumentError` when filtering. **Update every `filter_with` scope before upgrading.**
:::
##### Available conditions
| Filter type | Conditions | Default |
| --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | --------- |
| `:single_select`, `:multi_select` | `is`, `is_not`, `is_empty`, `is_not_empty` | `is` |
| `:date` | `between`, `not_between`, `is_empty`, `is_not_empty` | `between` |
| `:range` | `between`, `not_between`, `is`, `is_not`, `greater_than`, `less_than`, `greater_than_or_equal_to`, `less_than_or_equal_to`, `is_empty`, `is_not_empty` | `between` |
Your scope is still called with `is_empty` and `is_not_empty` even when `value` is blank. Handle these conditions without relying on `value`.
##### Migration Steps
Add a `condition` parameter to every `filter_with` scope. Make it optional (`condition = nil`) so that existing direct callers keep working.
**Select filters:**
```ruby
# Before
scope :custom_status_filter, ->(statuses) { where(status: statuses) }
# After
scope :custom_status_filter, lambda { |statuses, condition = nil|
case condition.to_s
when 'is_not' then where.not(status: statuses)
when 'is_empty' then where(status: nil)
when 'is_not_empty' then where.not(status: nil)
else where(status: statuses)
end
}
```
**Date / range filters:**
```ruby
# Before
scope :custom_age_range, lambda { |from, to|
where('age BETWEEN ? AND ?', from.to_i, to.to_i) if from.present? && to.present?
}
# After
scope :custom_age_range, lambda { |value, condition = nil|
from, to = value.to_s.split(' to ')
case condition.to_s
when 'not_between' then where.not(age: from.to_i..to.to_i)
when 'greater_than' then where('age > ?', from.to_i)
when 'is_empty' then where(age: nil)
# ...handle the other operators you want to support
else where(age: from.to_i..to.to_i)
end
}
```
If your scope only wraps a column with extra joins or a different column, delegate the operator handling to the new helpers. They are available on every ActiveRecord model through `CmAdmin::FilterConditions`:
```ruby
# Filter users by the country of their company (users -> companies)
scope :by_company_country, lambda { |value, condition = nil|
left_joins(:company).ct_dropdown_filter_condition(column: :country, value:, condition:, table: 'companies')
}
# Filter users by the size of their company
scope :by_company_size, lambda { |value, condition = nil|
left_joins(:company).ct_range_filter_condition(column: :employee_count, value:, condition:, table: 'companies')
}
```
```ruby
filter :company_country, :multi_select, helper_method: :company_country_options, filter_with: :by_company_country
filter :company_size, :range, filter_with: :by_company_size
```
`table:` defaults to the model's own table. Pass the joined table's name whenever the column lives on an association.
:::tip
Use `left_joins` rather than `joins` when you support `is_empty`. With an inner `joins`, records that have no associated row are dropped before the `IS NULL` check runs.
:::
| Helper | Handles |
| ------------------------------------------------------------------------------ | ---------------------------------------------------------------------- |
| `ct_dropdown_filter_condition(column:, value:, condition:, table: table_name)` | `is`, `is_not`, `is_empty`, `is_not_empty`. Maps `cm_empty` to `NULL` |
| `ct_range_filter_condition(column:, value:, condition:, table: table_name)` | All date and range operators. Parses the `"from to to"` string for you |
#### Index Preferences Replaced by Saved Page Views
The `CmIndexPreference` model and the `cm_index_preferences` table have been removed. Column visibility, records per page, filters, sort, and view type are now saved per user as named **page views** in a new `ct_page_views` table.
##### Migration Steps
1. Run the [CT Page View generator](../Generators.md#17-ct-page-view-generator), then migrate:
```bash
rails generate cm_admin:ct_page_view
rails db:migrate
```
The generated migration:
- Enables the PostgreSQL `citext` extension if it is not already enabled. The view name is case-insensitive.
- **Drops the `cm_index_preferences` table** if it exists.
- Creates `ct_page_views` with a polymorphic `owner`, `name`, `ar_model_name`, `associated_ar_model_name`, `visible_columns`, `hidden_columns`, `is_default`, `view_type`, `records_per_page`, `filter_params` (jsonb), and `sort_params` (json).
- Adds a partial unique index so that each owner has at most one default view per model / associated model.
:::warning
Existing column preferences in `cm_index_preferences` are **not migrated**. Users have to recreate their column layouts as saved views. If you need to keep the data, back up the table or copy it into `ct_page_views` before running the migration.
:::
2. Remove any references to the old index preference API from your application:
| Removed | Replacement |
| ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
| `CmIndexPreference` model | `CtPageView` |
| `cm_index_preference__path` route helper | `create_ct_page_view__path` / `update_ct_page_view__path` |
| `reset_cm_index_preference__path` route helper | Clear the active view (`?clear_ct_page_view=true`) |
| `cm_index_preference` / `reset_cm_index_preference` controller actions | `list_ct_page_view`, `create_ct_page_view`, `update_ct_page_view`, `destroy_ct_page_view`, `duplicate_ct_page_view` |
| `CmAdmin::ViewHelpers::IndexPreferencePopupHelper` | Partials under `cm_admin/page_view/` and `cm_admin/main/_manage_columns_modal` |
| `@cm_index_preference` instance variable | `@current_ct_page_view` |
3. Make sure the following are present in your host app. Most cm-admin installs already have them:
- The `DEFAULT_PER_PAGE` constant (generated in `config/initializers/constants.rb`). `CtPageView` references it at load time. `PER_PAGE_OPTIONS` is optional and defaults to `[10, 20, 50, 100]`.
- `Current.user` is set for admin requests. It becomes the `owner` of each saved view.
- PaperTrail's `versions` table. `CtPageView` declares `has_paper_trail`.
4. Remove `cm_index_preferences` from `db/schema.rb` / `structure.sql` once migrated, and remove any factories, seeds, or specs that build `CmIndexPreference` records.
##### New routes
These routes are added automatically for every cm-admin model, with no setup needed:
| Verb | Path | Action |
| ------ | --------------------------------------- | ------------------------ |
| GET | `//ct_page_views` | `list_ct_page_view` |
| POST | `//ct_page_views` | `create_ct_page_view` |
| PUT | `//ct_page_views/:id` | `update_ct_page_view` |
| DELETE | `//ct_page_views/:id` | `destroy_ct_page_view` |
| POST | `//ct_page_views/:id/duplicate` | `duplicate_ct_page_view` |
All actions are scoped to `owner: Current.user`, so users can only see and change their own views.
##### Default views
A user can mark one view per list page as **default**. When that user opens the index page with no `filters`, `current_ct_page_view_id`, `clear_filters`, or `clear_ct_page_view` params, they are redirected to their default view. Deep links that include any of those params are not affected.
### Behaviour Changes
#### `filters` URL Param Is Now Compressed
The `filters` query param on index and dashboard pages is now a zlib-compressed, base64url-encoded JSON string (produced with `pako` on the frontend), not a nested `filters[...]` hash. Compressing it keeps URLs short now that Advanced Filters can contain nested groups.
- **Old links still work.** A nested `filters[...]` hash or a plain JSON string is still accepted.
- If your application code reads `params[:filters]` directly (custom controllers, helpers, `table_source_method`s, export hooks), decode it first:
```ruby
# Before
filters = JSON.parse(params[:filters] || '{}')
# After
filters = CmAdmin::Utils.decode_filter_params(params[:filters]) || {}
```
- To generate a filtered link from Ruby, encode the hash:
```ruby
cm_admin.cm_index_user_path(filters: CmAdmin::Utils.encode_filter_params({ single_select: { status: 'active' } }))
```
Dashboard widget source methods (charts, metrics, tables) still receive a decoded filters hash, so no change is needed there.
#### Sidebar `query` Items Linking to Filtered Index Pages
If your `config.sidebar` entries open a pre-filtered index page through `query:`, prefer moving that default into the filter's `default_value:` option instead of passing filters in the URL:
```ruby
# Before — sidebar entry carrying a filters hash
{ display_name: 'Draft Posts', path: 'cm_index_post_path',
query: { filters: { single_select: { status: 'draft' } } } }
# After — the filter applies itself whenever no filter params are present
filter :status, :single_select, collection: %w[draft published], default_value: -> { 'draft' }
```
A `filters` hash in `query:` also suppresses the default-view redirect on every visit, so saved views never apply when the page is opened through that entry.
If a sidebar entry must keep its `query:` — for example, several entries pointing at different filtered states of the same index — include the current user's default view keys so the page resolves the correct view context:
```ruby
{
display_name: 'Posts',
path: 'cm_index_post_path',
query: lambda {
view = CtPageView.find_by(owner: Current.user, is_default: true, ar_model_name: 'Post')
{
current_ct_page_view_id: view&.id,
filters: view&.filter_params
}
}
}
```
`query` accepts a proc that is evaluated on each request, which is required here because `Current.user` is only set while rendering. `filter_params` is a plain hash, so it serializes back into the nested `filters[...]` URL format, which is still supported.
:::tip
`CtPageView#apply_params` returns the same keys (`current_ct_page_view_id`, `filters`, `sort`, `view_type`) already URL-ready, so `query: -> { CtPageView.find_by(...)&.apply_params || {} }` is a shorthand for the above.
:::
#### Advanced Filters Override Standard Filters
When a request contains `advanced` filter conditions, all standard filter keys (`date`, `range`, `single_select`, `multi_select`) **and their `default_value`s** are ignored. Only `search` and `advanced` are applied. Exports follow the same rule. If you rely on a `default_value` filter always being applied, enforce it with a policy scope or default scope instead.
#### `Model#default_filters` Signature
`default_filters` now also accepts a `filter_list:` keyword, alongside the existing `action:`. Existing calls such as `model.default_filters(action:)` keep working.
#### `available_fields` Uses Indifferent Access
`CmAdmin::Model#available_fields` is now a `HashWithIndifferentAccess`. Both `available_fields[:index]` and `available_fields['index']` work. No action is needed.
### Checklist
1. Update every application `filter_with` scope for `select`, `date`, and `range` filters to accept `(value, condition = nil)`.
2. Run `rails generate cm_admin:ct_page_view && rails db:migrate`. Back up `cm_index_preferences` first if you need the data.
3. Remove references to `CmIndexPreference`, `cm_index_preference_*` / `reset_cm_index_preference_*` route helpers, and `@cm_index_preference`.
4. Confirm that `DEFAULT_PER_PAGE`, `Current.user`, and PaperTrail are available.
5. Replace any direct `JSON.parse(params[:filters])` with `CmAdmin::Utils.decode_filter_params(params[:filters])`.
6. Review sidebar `query:` entries that inject `filters` — prefer `default_value:` on the filter, or include `current_ct_page_view_id` and `filters` from the user's default `CtPageView`.
---
## v8.2.18
This release adds a required `name` field to API tokens, replacing the `description` field introduced in v8.2.14.
### Breaking Changes
#### API Token `description` Replaced by Required `name`
API tokens now have a required `name` field (string, max 100 characters) to identify what each token is used for. This replaces the `description` field introduced in v8.2.14 — the `cm_admin:add_description_to_api_token` generator has been removed, so run the name generator below even if you already generated the description migration.
The index page lists `name` as the first column and adds a search-by-name filter. A new Edit page lets users rename a token; the token value and other fields stay read-only.
##### Migration Steps
1. Run the [Add Name to API Token generator](../Generators.md#21-add-name-to-api-token-generator), then migrate:
```bash
rails generate cm_admin:add_name_to_api_token
rails db:migrate
```
The generated migration:
- Adds a `name` string column to `api_tokens` (skipped if the column already exists).
- Backfills existing records with `'Unnamed Token'`.
- Drops the `description` column if it exists — this cleans up apps that ran v8.2.14's `add_description_to_api_token` generator.
:::warning
Existing `description` values are **not** carried over to `name`. If you ran the v8.2.14 generator and want to keep the descriptions, copy them across in the generated migration before `remove_column` runs, for example:
```ruby
execute "UPDATE api_tokens SET name = description WHERE name IS NULL"
```
:::
2. Pass a `name` wherever your application creates `ApiToken` records directly. `ApiToken` validates `name` presence (and a 100-character limit) for every token type, so `ApiToken.create(...)` calls that worked before now fail validation without one. The admin form already requires a name.
### Checklist
1. Run `rails generate cm_admin:add_name_to_api_token && rails db:migrate` (skip `add_description_to_api_token` — it has been removed).
2. Set a `name` in any code that creates `ApiToken` records directly.
---
## v8.3.3
This release adds per-recipient email open and click tracking to the Postmark webhook pipeline and the Email Log UI. Webhook events are now linked to the recipient they belong to, Click events record the clicked URL, and the Email Log show page surfaces per-recipient open counts and click details.
### Required Migration (Existing Installs)
#### `cm_webhook_events` Gains Recipient and Click Link Columns
`CmWebhookEvent` now carries two new pieces of data, both populated at webhook receipt:
| Column | Type | Purpose |
| --------------- | ------------------- | -------------------------------------------------------------------------------------------------------------- |
| `recipient` | polymorphic + index | The recipient the event belongs to (matched by `MessageID` + payload recipient email against the log's record) |
| `original_link` | string | For Click events, the clicked URL — denormalized at receipt so clicks can be grouped and counted in SQL |
Events for CC/BCC recipients of the same message stay unlinked (`recipient: nil`) so they are never attributed to the To recipient.
##### Migration Steps
1. Run the [Add Webhook Event Recipient generator](../Generators.md#21-add-webhook-event-recipient-generator), then migrate:
```bash
rails generate cm_admin:add_webhook_event_recipient
rails db:migrate
```
The generated migration:
- Adds `recipient_type` (string), `recipient_id` (bigint), and a composite index on both — each step is skipped if it already exists.
- Adds `original_link` (string) — skipped if the column already exists.
- Is idempotent (`column_exists?` / `index_exists?` guards) and safe to re-run.
:::note
Fresh installs don't need the extra generator — the `create_cm_webhook_events` template now includes the new columns.
:::
:::warning
Recipient attribution happens when a webhook is received. Historical events are **not** backfilled — they keep `recipient: nil`, and repeat-open history only exists from the upgrade onward (Postmark only retains the first open server-side).
:::
### New Features
#### `clicked` Recipient Status
A new `clicked` status sits after `opened` in the recipient status flow and renders with a purple badge:
| From | Allowed transitions |
| ----------- | ------------------- |
| `delivered` | `opened`, `clicked` |
| `opened` | `clicked` |
| `clicked` | (terminal) |
`opened` and `clicked` are both treated as open-equivalent statuses (`CmEmailLog::OPEN_EQUIVALENT_STATUSES`) so click-stage recipients still count as engaged.
#### Email Log Show Page Additions
- **Recipients table** now shows **First Opened At**, **Total Opens**, and **Link Clicks** per recipient, built from the log's webhook events via `recipients_with_engagement`.
- **Click Details** section (shown when the log has clicks): one row per clicked link with its click count and click rate, sorted by click count.
- **Events** tab: the log's webhook events with recipient, event type, created/processed timestamps, and error message.
The `CmWebhookEvent` index page also gains a **Recipient** column.
### Internal Changes
#### `status_helper_icon` Renamed to `cm_info_tooltip`
The badge tooltip helper in `CmAdmin::ViewHelpers::FieldDisplayHelper` was renamed and now sets `bs_container: 'body'` so tooltips render correctly inside scrollable containers. No action is needed unless your application called `status_helper_icon` directly.
### Checklist
1. Run `rails generate cm_admin:add_webhook_event_recipient && rails db:migrate` (existing installs only — fresh installs get the columns from `create_cm_webhook_events`).
---
## v8.4.0
This release adds **Catalyst AI (CT Bot)** — an opt-in chat widget that answers admin users' questions via read-only MCP tools — plus a fix to the MCP `aggregate` tool schema for models without numeric columns.
### New Features
#### Catalyst AI (CT Bot)
A floating chat widget is available on every admin page for users whose `CmRole` has **Catalyst AI** enabled. Conversations stream replies over ActionCable, support attachments and clarifying-question cards, and record per-request token/cost usage as `CtCostTracking`. See the [Catalyst AI feature guide](../3.%20Features/CatalystAi.md) for details.
##### Migration Steps
1. Run the [Add Bot Conversations generator](../Generators.md#22-add-bot-conversations-generator), then migrate:
```bash
rails generate cm_admin:add_bot_conversations
rails db:migrate
```
The generated migrations create four tables — `ct_bot_conversations`, `ct_bot_messages`, `ct_bot_artifacts`, `ct_cost_trackings` — and add `is_catalyst_ai_enabled` (boolean, default `false`) to `cm_roles`.
:::warning
The conversations migration adds a foreign key to `users`. If your user class maps to a different table (e.g. `admins`), edit the generated migration's `foreign_key:` target before running `db:migrate`.
:::
2. Register the new models in `config/initializers/zcm_admin.rb`:
```ruby
config.included_models = [..., CtBotConversation, CtBotMessage, CtCostTracking]
```
3. Add your OpenRouter key to credentials (`rails credentials:edit`):
```yaml
catalyst_ai:
openrouter_api_key: sk-or-...
```
4. Ensure `config/cable.yml` exists — the assistant streams replies over ActionCable. Use the Redis adapter in production.
5. Seed the Catalyst AI platform settings (`CmPlatformSetting.add_cm_default_settings`) or create **CT Bot Greeting**, **CT Bot Model**, and **CT Bot Queue** via the Platform Settings page.
6. Switch on **Catalyst AI** on each `CmRole` that should see the widget, and declare `mcp_actions` on the models the assistant may query.
### Checklist
1. Run `rails generate cm_admin:add_bot_conversations && rails db:migrate` (adjust the user-table foreign key if needed).
2. Add `CtBotConversation`, `CtBotMessage`, `CtCostTracking` to `config.included_models`.
3. Set the `catalyst_ai.openrouter_api_key` credential.
4. Confirm `config/cable.yml` is configured (Redis in production) and Sidekiq is running.
5. Seed/create the three Catalyst AI platform settings; restart the app and Sidekiq after changing them.
6. Enable **Catalyst AI** on the relevant roles and add `mcp_actions` to assistant-visible models.
---
## v8.4.1
This release adds **voice input** to the Catalyst AI (CT Bot) chat widget. Recordings are uploaded as `CtBotTranscription` records, transcribed in the background, and the text is streamed back to the widget over ActionCable. Transcription cost is recorded as `CtCostTracking`, like chat replies.
### Required Migration
#### New `ct_bot_transcriptions` Table
Voice recordings and their transcribed text are stored in a new `ct_bot_transcriptions` table:
| Column | Type | Purpose |
| ------------------------ | ----------------------- | -------------------------------------------------------------- |
| `user_id` | reference (FK, not null) | The user who recorded the audio |
| `ct_bot_conversation_id` | reference (FK) | The open conversation at recording time, if any |
| `ct_bot_message_id` | reference (FK) | The message the transcription was sent with, once linked |
| `request_id` | string | Client request ID used to route the result to the right widget |
| `status` | integer (enum) | `pending`, `success`, `failed` |
| `text` | text | The transcribed text |
| `error_message` | string | Failure reason, if any |
| `total_cost_cents` | decimal | Rolled-up cost of the transcription |
| `completed_at` | datetime | When the transcription finished (success or failure) |
The recorded audio is stored as an Active Storage attachment (`audio`).
##### Migration Steps
1. Run the [Add Bot Transcriptions generator](../Generators.md#24-add-bot-transcriptions-generator), then migrate:
```bash
rails generate cm_admin:add_bot_transcriptions
rails db:migrate
```
:::note
This applies to every install using Catalyst AI — including fresh installs, which should run `add_bot_conversations` first and then `add_bot_transcriptions`.
:::
:::warning
The migration adds a foreign key to `users`. If your user class maps to a different table (e.g. `admins`), edit the generated migration's `foreign_key:` target before running `db:migrate`.
:::
2. Register the new model in `config/initializers/zcm_admin.rb`:
```ruby
config.included_models = [..., CtBotConversation, CtBotMessage, CtBotTranscription, CtCostTracking]
```
### New Features
#### Voice Input in the Chat Widget
- A microphone button in the chat widget records audio and uploads it to `CmAdmin::CtBotTranscriptionsController`.
- `CtBotTranscriptionJob` transcribes the recording, stores the text, status and cost, then broadcasts the result over the signed `CmAdmin::CtBotTranscriptionChannel` stream.
- When a message is sent in the same conversation, the transcription is linked to the resulting `CtBotMessage`.
- A **Catalyst AI Transcriptions** index/show page lists each recording with its audio, text, status, Total Cost, and a Cost Trackings tab.
#### New Platform Setting: CT Bot Transcription Model
A new **CT Bot Transcription Model** platform setting (category **Catalyst AI**) selects the OpenRouter model used for transcription. Leave it blank to use the default (`openai/gpt-transcribe`). Seed it with `CmPlatformSetting.add_cm_default_settings` or create it via the Platform Settings page, and restart the app and Sidekiq after changing it.
### Checklist
1. Run `rails generate cm_admin:add_bot_transcriptions && rails db:migrate` (adjust the user-table foreign key if needed).
2. Add `CtBotTranscription` to `config.included_models`.
3. Seed/create the **CT Bot Transcription Model** platform setting (optional); restart the app and Sidekiq after changing it.
---
## v9.0.0
:::warning
This release contains breaking changes. Work through the [Checklist](#checklist) before upgrading.
:::
Settings are now split into two clearly separate kinds that never override each other:
- **Project settings**: set by developers in `config/application.rb` (`config.x.project_settings`): project name,
slug, logo URL, favicon URL and default email. Read them with `Rails.configuration.x.project_settings.*`.
- **Platform settings**: edited by admins on the Platform Settings page and stored in `cm_platform_settings`.
Read them with `CmAdmin::PlatformSettings`, which is backed by `Rails.cache` and expired whenever a setting is
created, updated or deleted, so changes apply straight away in every process, with no restart.
### What changed
| Before | Now |
| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- |
| `PLATFORM_SETTINGS["slug"]`: a frozen hash loaded at boot; changes needed a restart of the app and Sidekiq | `CmAdmin::PlatformSettings["slug"]`, cached and expired on save |
| File settings returned a lambda that had to be called to get the URL | File settings return an Active Storage redirect path string |
| **Project Name**, **Project Slug**, **Logo URL**, **Favicon URL** and **Default Email** platform settings overwrote `config.x.project_settings`, but only in the process that saved them | `config.x.project_settings` is only ever set from `config/application.rb` |
| Those five settings were seeded by `CmPlatformSetting.add_cm_default_settings` | They are no longer seeded |
See [Platform Settings](../3.%20Features/PlatformSetting.md#project-settings-vs-platform-settings) for the full API.
### Required changes
#### 1. Move project values into `config/application.rb`
If admins changed **Project Name**, **Project Slug**, **Logo URL**, **Favicon URL** or **Default Email** on the
Platform Settings page, those values no longer apply. Copy them into `config/application.rb`:
```ruby
config.x.project_settings.name = "Acme"
config.x.project_settings.slug = "acme"
config.x.project_settings.logo_url = "https://cdn.example.com/logo.png"
config.x.project_settings.favicon_url = "https://cdn.example.com/favicon.png"
config.x.project_settings.default_from_email = "no-reply@example.com"
```
Then delete the old rows so they don't confuse admins:
```ruby
CmPlatformSetting.where(slug: %w[project-name project-slug logo-url favicon-url default-email]).destroy_all
```
#### 2. Replace `PLATFORM_SETTINGS` (recommended)
`PLATFORM_SETTINGS` still works: `PLATFORM_SETTINGS["slug"]` is forwarded to `CmAdmin::PlatformSettings["slug"]`,
so it finds the setting by slug and reads the same cached value. It is deprecated, though, and reports a `cm-admin`
deprecation warning through `Rails.application.deprecators` each time it is used. In development every
`cm-admin` deprecation raises `ActiveSupport::DeprecationException`, so remaining uses are caught before they ship;
in staging and production it keeps working. Compared with the old frozen hash
there are three differences:
- File settings return a path string instead of a lambda, so code like `PLATFORM_SETTINGS["login-image"].call` must
drop the `.call`.
- A slug with no matching setting raises `CmAdmin::PlatformSettings::NotFound` instead of returning `nil`. Where the
code has a fallback, use `fetch` with a default.
- Only `[]` and `fetch` are supported. Other Hash methods such as `dig`, `each`, `keys` and `to_h` raise
`NoMethodError`.
```ruby
# Before
PLATFORM_SETTINGS["ct-bot-model"].presence || "openai/gpt-5.6-sol"
# After
CmAdmin::PlatformSettings.fetch("ct-bot-model", "openai/gpt-5.6-sol")
```
#### Removed
- `CmPlatformSetting.sync_project_settings_to_config`
- `CmPlatformSetting::PROJECT_SETTINGS_SLUG_MAP`
### Checklist
1. Copy any admin-edited project values into `config/application.rb`, then delete those five platform setting rows.
2. Replace `PLATFORM_SETTINGS[...]` with `CmAdmin::PlatformSettings[...]` (or `.fetch(slug, default)` where a missing
setting is expected), and drop any `.call` on file settings.
3. Remove any calls to `CmPlatformSetting.sync_project_settings_to_config`.
---
## 6.UI
UI components and styles available in cm-admin explained here.
---
## Buttons
### Overview
CmAdmin provides a set of custom button classes built on top of Bootstrap. These custom classes ensure consistent styling across the admin panel while leveraging Bootstrap's button foundation.
:::warning Important
**Do not use Bootstrap button classes directly** (e.g., `btn-outline-primary`, `btn-outline-dark`). Always use CmAdmin's custom button classes listed below to maintain design consistency.
:::
### Available Button Classes
| Class | Description | Based On |
| ----------------- | --------------------------------------------------- | --------------------------- |
| `btn-cta` | Primary call-to-action button with solid background | Primary color filled |
| `btn-primary` | Primary outlined button | `btn-outline-primary` |
| `btn-secondary` | Secondary outlined button | `btn-outline-dark` |
| `btn-ghost` | Light/subtle button | `btn-light` |
| `btn-transparent` | Fully transparent button | Transparent background |
| `btn-danger` | Danger/destructive action button | `btn-outline-danger` |
| `btn-link` | Link-styled button with underline | `btn-link` |
| `btn-dark` | Dark outlined button | `btn-outline-dark` |
| `btn-loading` | Disabled state for loading actions | Primary with disabled state |
#### Preview

### Usage
#### Basic Usage
```html
```
#### With Icons
All button classes include `btn-icon-spacing` which provides proper spacing when using icons alongside text:
```html
```
### Button Class Details
#### `btn-cta`
The main call-to-action button. Use for primary actions like "Save", "Submit", or "Create".
- **Appearance:** Solid primary color background with white text
- **Hover:** Slightly darker shade
#### `btn-primary`
An outlined version of the primary button. Use for secondary emphasis actions.
- **Appearance:** Primary color outline with primary color text
#### `btn-secondary`
A neutral outlined button. Use for less prominent actions.
- **Appearance:** Dark outline with dark text
#### `btn-ghost`
A subtle, light button. Use for tertiary actions or within cards.
- **Appearance:** Light gray background
#### `btn-transparent`
A completely transparent button. Use for icon-only buttons or minimal UI.
- **Appearance:** No background or border
#### `btn-danger`
For destructive actions like delete or remove.
- **Appearance:** Red outline with red text
#### `btn-link`
A button styled as a link with underline.
- **Appearance:** Link color with underline
#### `btn-loading`
Use when an action is in progress. Automatically includes disabled state.
- **Appearance:** Disabled primary button
### Best Practices
1. **Use `btn-cta`** for the most important action on a page
2. **Use `btn-primary`** for secondary important actions
3. **Use `btn-secondary`** for cancel or back actions
4. **Use `btn-danger`** only for destructive actions
5. **Use `btn-ghost` or `btn-transparent`** for less prominent actions
### Why Custom Classes?
CmAdmin's button classes:
- Ensure consistent sizing with `btn-sm` applied by default
- Include icon spacing utilities automatically
- Use the application's primary color variables
- Provide a unified look across all admin pages
---
## UI Introduction
cm-admin uses **Bootstrap 5** as its CSS foundation, wrapped inside a `.cm-admin` root class to ensure style isolation and prevent conflicts with the host application's styles.
**Design System:** [CM-Admin Design System (Figma)](https://www.figma.com/design/YJpnOd589W7vQRnrgbFqFJ/CM-Admin---Design-System?node-id=7107-38569&t=Oi3PCol1zOd7br9t-0)
---
### Core Concept: Scoped Bootstrap
All cm-admin styles are scoped under the `.cm-admin` class. When creating custom components, we use SCSS `@extend` to inherit Bootstrap classes while keeping them namespaced:
```scss
// Don't use Bootstrap classes directly in markup
// Use cm-admin scoped classes
```
#### How It Works
```scss
@import "../helpers/index.scss";
// Custom button extending Bootstrap classes
.cm-admin .btn-cta {
@extend .btn, .btn-sm;
--bs-btn-color: var(--bs-white);
--bs-btn-bg: var(--bs-primary);
}
.cm-admin .btn-primary {
@extend .btn, .btn-outline-primary, .btn-sm;
}
```
This pattern:
- **Isolates styles** - cm-admin styles won't leak into the host app
- **Leverages Bootstrap** - Full access to Bootstrap's utility classes and components
- **Enables customization** - Override Bootstrap variables and add custom properties
---
### File Structure
```
app/assets/stylesheets/cm_admin/
├── helpers/
│ ├── _variable.scss # Color, typography, spacing variables
│ ├── _mixins.scss # Reusable mixins (flex, font, etc.)
│ ├── _typography.scss # Typography utilities and classes
│ └── index.scss # Exports all helpers
├── components/
│ ├── _buttons.scss
│ ├── _alerts.scss
│ ├── _modal.scss
│ └── ...
├── base/ # Page-specific styles
└── dependency/
└── bootstrap/ # Bootstrap source files
```
---
## Mixins
### `flex` Mixin
Quickly create flexbox layouts:
```scss
@mixin flex($direction: row, $justify: normal, $align: normal, $wrap: nowrap) // Usage
.my-container {
@include flex($justify: center, $align: center);
}
.my-column {
@include flex($direction: column, $justify: space-between);
}
```
---
### `font` Mixin
Apply font styles consistently:
```scss
@mixin font($size: $t3-text, $color: $primary-text-clr, $weight: normal) // Usage
.my-label {
@include font($size: $t4-text, $color: $ink-lighter-clr, $weight: bold);
}
```
---
### `typography` Mixin
Enhanced typography with more options:
```scss
@mixin typography(
$size: 14,
$weight: "regular",
$color: $primary-text-clr,
$line-height: 1.5,
$family: $primary-font
) // Usage
.field-label {
@include typography(14, "bold", $primary-text-clr);
}
.section-heading {
@include typography(16, "bold", $primary-text-clr);
}
```
---
### `text-truncate` Mixin
Truncate text with ellipsis:
```scss
@mixin text-truncate($lines: 1) // Single line truncation
.truncate-single {
@include text-truncate(1);
}
// Multi-line truncation
.truncate-multi {
@include text-truncate(3);
}
```
---
### `transition-linear` Mixin
Standard transition for hover effects:
```scss
@mixin transition-linear // Usage
.my-button {
@include transition-linear;
&:hover {
background: $brand-hover-color;
}
}
```
---
### Utility Classes
#### Font Size Classes
```scss
.fs-12, .f12 // 12px
.fs-14, .f14 // 14px
.fs-16, .f16 // 16px
.fs-18, .f18 // 18px
.fs-20, .f20 // 20px
.fs-24, .f24 // 24px
```
#### Font Weight Classes
```scss
.fw-regular // 400
.fw-medium // 500
.fw-bold // 600
.fw-bolder // 900
.bold // 600 (legacy)
.bolder // 900 (legacy)
```
#### Line Height Classes
```scss
.lh-tight // 1.2
.lh-normal // 1.5
.lh-relaxed // 1.75
.lh-loose // 2
```
#### Text Truncation Classes
```scss
.text-truncate // Single line
.text-truncate-2 // 2 lines
.text-truncate-3 // 3 lines
```
---
### Creating New Components
When adding new components, follow this pattern:
```scss
// 1. Import helpers
@import "../helpers/index.scss";
// 2. Scope under .cm-admin
.cm-admin .my-component {
// 3. Extend Bootstrap classes
@extend .d-flex, .align-items-center;
// 4. Use cm-admin variables
background: $grey-lightest-clr;
border-radius: $radius-4;
// 5. Use mixins for common patterns
@include font($size: $t4-text, $color: $primary-text-clr);
// 6. Add custom styles
padding: 16px;
&:hover {
@include transition-linear;
background: $grey-lighter-clr;
}
}
```
#### Example: Custom Alert
```scss
@import "../helpers/index";
.cm-admin .alert {
@extend .alert-dismissible, .d-flex, .align-items-center;
}
.flag-alert {
max-width: 752px;
padding: 24px;
background: $red-lightest-clr;
border-radius: $radius-4;
.alert-header {
@include font($size: $t6-text, $color: $red-regular-clr, $weight: bold);
text-transform: uppercase;
}
.alert-body {
margin-top: 16px;
.body-title {
@include font($size: $t4-text, $color: $primary-text-clr, $weight: bold);
}
}
}
```
---
### Best Practices
1. **Always scope under `.cm-admin`** - Prevents style leakage
2. **Use `@extend` for Bootstrap classes** - Keeps markup clean
3. **Prefer variables over hardcoded values** - Ensures consistency
4. **Use mixins for repeated patterns** - Reduces code duplication
5. **Follow the color scale** - Use semantic colors for states (error, success, etc.)
---
## Variables
:::warning Important
Always use variables in the codebase. If a color is missing from the variables, find an appropriate name and add it to `_variable.scss` before using it. **Never use hex codes directly in SCSS files.**
:::
Import variables in your SCSS files:
```scss
@import "../helpers/index.scss";
```
---
### Color Variables
| Variable | Value | Usage |
| -------------------- | --------- | ------------------- |
| `$brand-color` | `#6554e0` | Primary brand color |
| `$brand-hover-color` | `#e6e4fa` | Brand hover state |
| `$sidebar-bg-color` | `#252525` | Sidebar background |
#### Semantic Colors
| Variable | Value | Usage |
| ------------------ | --------- | --------------- |
| `$informative-clr` | `#2f80ed` | Info states |
| `$error-clr` | `#f83636` | Error states |
| `$warning-clr` | `#ffc845` | Warning states |
| `$positive-clr` | `#30c39e` | Success states |
| `$disabled-clr` | `#9ca7ae` | Disabled states |
#### Color Scales
Each semantic color has three variants:
```scss
// Blue
$blue-regular-clr: #2f80ed;
$blue-light-clr: #cde1fb;
$blue-lightest-clr: #eef5fe;
// Red
$red-regular-clr: #f83636;
$red-light-clr: #fdcfcf;
$red-lightest-clr: #feefef;
// Yellow
$yellow-regular-clr: #ffc845;
$yellow-light-clr: #ffefc7;
$yellow-lightest-clr: #fff8e9;
// Green
$green-regular-clr: #30c39e;
$green-light-clr: #c1ede2;
$green-lightest-clr: #eefaf7;
// Grey
$grey-regular-clr: #c7ced5;
$grey-light-clr: #d9dee3;
$grey-lighter-clr: #f3f4f6;
$grey-lightest-clr: #f8f9fa;
```
#### Ink (Text) Colors
```scss
$ink-regular-clr: #1d2129; // Primary text
$ink-light-clr: #3b4352; // Secondary text
$ink-lighter-clr: #6b7586; // Muted text
$ink-lightest-clr: #9ca7ae; // Placeholder text
$primary-text-clr: #1d2129;
```
---
### Typography Variables
```scss
// Font family
$primary-font: "Open Sans", sans-serif;
// Font sizes
$t1-text: 20px;
$t2-text: 18px;
$t3-text: 16px;
$t4-text: 14px;
$t5-text: 13px;
$t6-text: 12px;
// Font weights (use with font-weight() function)
$font-weight: (
"regular": 400,
"medium": 500,
"bold": 600,
"bolder": 900,
);
```
---
### Border Radius Variables
```scss
$radius-0: 0;
$radius-1: 1px;
$radius-2: 2px;
$radius-3: 3px;
$radius-4: 4px;
$radius-5: 5px;
$radius-6: 6px;
$radius-8: 8px;
$circle: 50%;
```
---
## Windsurf Setup
### Steps to set up Windsurf for a project
```bash
git rm -rf .windsurf/
brew tap commutatus/tools https://github.com/commutatus/homebrew-tools
brew install cm-tools
cd [repo-path]
rm .github/workflows/sync-windsurf-config.yml
cm-tools windsurf-sync -p rails
```
Add changes, commit, and raise a PR.
---
## AWS
Documentation coming soon.
---
## AssociatedRecordHistory
### Description
CM Admin supports tracking changes to associated models by specifying them in `paper_trail_associations`. This is useful when you want to show a single history timeline that includes the parent record and its related models.
### Usage
Add `CmAdmin::PaperTrailAssociations` to your host app's `ApplicationRecord`:
```ruby
class ApplicationRecord < ActiveRecord::Base
include CmAdmin::PaperTrailAssociations
primary_abstract_class
end
```
In the model that needs associated history, call `paper_trail_associations` with the associations to include:
```ruby
has_paper_trail
paper_trail_associations :po_items, :po_parts, :po_pieces, :release_orders, :ro_pieces, :attachments
```
Note:
- `has_paper_trail` must be added to the model as well.
- In the example above, the parent model has `has_many :po_items`, `PoItem` has `has_many :po_parts`, and so on.
Example associations:
```ruby
has_many :po_items, inverse_of: :production_order, dependent: :destroy
has_many :po_parts, through: :po_items
has_many :po_pieces, through: :po_parts
has_many :release_orders, dependent: :destroy
has_many :ro_pieces, through: :release_orders
has_many :attachments, as: :attachable, dependent: :destroy
```
---
## Catalyst Setup
This guide covers how to set up the `cm-admin` gem (internally referred to as Catalyst) locally and connect it to another Rails application for development and testing.
### Overview
`cm-admin` is a Ruby on Rails admin panel gem. This repository contains both the gem source and a `feature-app/` Rails application that can be used to test changes.
When developing new features or fixing bugs, you typically want to point a host Rails app at your local copy of `cm-admin` instead of the published gem.
### Prerequisites
- Ruby 3.3.0 or higher
- Bundler
- A supported Rails application (the `feature-app/` in this repo or another project)
- PostgreSQL (if running the `feature-app/`)
- Node.js (if building the documentation site)
### Local Setup
#### 1. Clone the repository
```bash
git clone git@github.com:commutatus/cm-admin.git
cd cm-admin
```
#### 2. Install gem dependencies
```bash
bundle install
```
This installs the dependencies declared in the root `Gemfile`.
#### 3. Run the feature app
The `feature-app/` directory is a Rails application configured to load `cm-admin` from the local path.
```bash
cd feature-app
bundle install
bin/rails db:prepare
bin/rails server
```
Visit `http://localhost:3000` to see the admin panel in action.
### Connect to Another Local App
To test `cm-admin` changes in a different Rails project, point that project's Gemfile to your local checkout.
#### 1. Update the host app's Gemfile
Open the host app's `Gemfile` and change the `cm-admin` line to use a local path:
```ruby
gem 'cm-admin', path: '../cm-admin'
```
Make sure the relative path matches your directory layout. For example, if `cm-admin` and the host app are siblings:
```
workspace/
├── cm-admin/
└── my-rails-app/
```
Then the path from `my-rails-app` is `../cm-admin`.
#### 2. Install dependencies
```bash
cd my-rails-app
bundle install
```
Bundler will now resolve `cm-admin` from the local directory instead of RubyGems or GitHub.
#### 3. Restart the server
```bash
bin/rails server
```
Any code changes you make in `cm-admin/` are reflected in the host app after a server restart.
### Verify Changes
When making changes to the gem:
1. Edit the source files in the root `cm-admin` directory.
2. Restart the host app or `feature-app` server.
3. Reload the page in your browser.
If the change affects assets, styles, or JavaScript, you may also need to clear the host app's tmp cache:
```bash
bin/rails tmp:clear
```
### Troubleshooting
- **Bundle cannot find the local gem**: Double-check the relative `path:` in the host app's Gemfile.
- **Changes not appearing**: Restart the Rails server. The gem is loaded at boot time, not on every request.
- **Database errors in `feature-app`**: Run `bin/rails db:prepare` or `bin/rails db:create db:migrate` before starting the server.
- **Sprockets or asset errors**: Make sure the host app includes the required `cm-admin` assets in its manifests.
### Related
- [CM Admin Introduction](/)
- [CM Admin Starter Pack](https://github.com/commutatus/cm-admin-panel-demo) — example Rails app using `cm-admin`
---
## Catalyst v8.0.12
### v8.0.12 (2026-09-25)
**Release date:** 2026-09-26
> ✅ No breaking changes and no database migrations or rake tasks are required for this release — it is a drop-in upgrade from v8.0.11.
### 🚀 Features
- ✨ **Year-select dropdown for all Flatpickr date pickers** — Every Flatpickr date picker across the admin panel now includes a year-select dropdown, letting users jump straight to a specific year instead of clicking through months one at a time. The dropdown is opt-out per field (e.g. birth dates that need free-form year entry can keep Flatpickr's native year navigation), and inputs support optional `data-min-date` / `data-max-date` attributes (ISO `YYYY-MM-DD`) for per-picker date ranges. Filter pickers initialize per element so each input's own `data-*` attributes are honored. [#1576](https://github.com/commutatus/cm-admin/pull/1576) ([@austinjb32](https://github.com/austinjb32))


### 🐛 Bug Fixes
- 🐛 **CSV imports no longer fail on non-UTF-8 files** — Imported CSVs that arrive in other encodings (e.g. Windows-1252) or contain BOM characters previously caused header and delimiter parsing errors. Incoming CSV content is now normalized to clean UTF-8 and BOM characters are stripped before parsing. [#1621](https://github.com/commutatus/cm-admin/pull/1621) ([@Mahaveer1141](https://github.com/Mahaveer1141))
- 🐛 **Failed imports no longer get stuck in `in_progress`** — `FileImportProcessorJob#perform` now has a top-level `rescue StandardError` that marks the import as `failed`, sets `completed_at`, and notifies Airbrake, so unhandled exceptions no longer leave file imports hanging indefinitely. [#1621](https://github.com/commutatus/cm-admin/pull/1621) ([@Mahaveer1141](https://github.com/Mahaveer1141))
- 🐛 **Import error reporting format aligned** — The rescue block's `invalid_row_items` payload now uses the same array-of-arrays structure produced by `run_importer`, so failed imports report invalid rows consistently with successful importer output. [#1621](https://github.com/commutatus/cm-admin/pull/1621) ([@Mahaveer1141](https://github.com/Mahaveer1141))
### ✨ Improvements
- ♻️ **Simpler CSV encoding and delimiter detection** — Dynamic `csv_encoding` detection was replaced with a `BaseImporter::CSV_ENCODING` constant plus a `normalize_to_utf8` helper, and files are always read as UTF-8. This removes a source of parsing ambiguity and makes import behavior predictable. [#1621](https://github.com/commutatus/cm-admin/pull/1621) ([@Mahaveer1141](https://github.com/Mahaveer1141))
- 🛡️ **Broader failure visibility for imports** — Unexpected failures during import processing and the completion-email step are now caught and surfaced through Airbrake. Note: the `rescue StandardError` is intentionally broad; if `Airbrake.notify` itself raises, the status update may be skipped. [#1621](https://github.com/commutatus/cm-admin/pull/1621) ([@Mahaveer1141](https://github.com/Mahaveer1141))
### 📚 Documentation
- 📚 **Changelog updated for v8.0.11** — Added the v8.0.11 entry (including the Flatpickr year-select work from [#1576](https://github.com/commutatus/cm-admin/pull/1576) ([@austinjb32](https://github.com/austinjb32))) to `docs-site/docs/Changelog/2026-09.md` and refreshed `docs-site/static/releases.json` so the docs site and release feed stay in sync. ([52dacecd3](https://github.com/commutatus/cm-admin/commit/52dacecd3))
### v8.0.11 (2026-09-25)
- Merge pull request #1576 from commutatus/CT-317 (d94eeb984)
- refactor(flatpickr): use date-fns for year bounds and px padding (079a66630)
- fix(flatpickr): pass filter elements to yearSelectOptions for per-element data attributes (91a8ba034)
- Merge branch 'main' of github.com:commutatus/cm-admin into CT-317 (9c2934114)
- refactor(flatpickr): normalise data-min-date/data-max-date to dates (0b1fc4106)
- fix(demo-forms): use a dedicated renewal_date column for the default year selector demo (7e41ae8b0)
- fix(flatpickr): restore native year input for default year selector pickers (55a911cd4)
- feat(flatpickr): add option to use default year selector via data attribute (78bf270de)
- fix(flatpickr): add max-height to year-select dropdown (e0cf23814)
- refactor(flatpickr): restore minDate/maxDate defaults with data-attribute overrides (e8f7ac59c)
- fix(flatpickr): improve year-select plugin integration robustness (0698a056f)
### v8.0.10 (2026-09-24)
## Catalyst v8.0.10
**Release date:** 2026-10-01
**Gem:** `cm_admin` v8.0.10
**Compare:** [`v8.0.9...v8.0.10`](https://github.com/commutatus/cm-admin/compare/v8.0.9...v8.0.10)
> ✅ **No breaking changes. No migrations required.** This release is a safe drop-in upgrade — no rake tasks, no database migrations, and no API changes to run. The bulk of the work hardens the release-automation pipeline so generated changelogs reliably reach `main`.
---
### 🚀 Features
- 🤖 **AI-powered changelog generation and Slack notifications for releases** — every gem release now automatically assembles release notes from `CHANGELOG.md`, the git log, merged PR bodies, and the docs diff via the OpenRouter API, publishes them to a monthly Docusaurus changelog page, and posts a rich Block Kit summary to Slack. No manual changelog writing required. [#1580](https://github.com/commutatus/cm-admin/pull/1580) ([@austinjb32](https://github.com/austinjb32))
- 🔗 **PR links and embedded media in generated release notes** — each generated change entry now links back to its source PR, and GitHub attachment URLs found in PR bodies are preserved and embedded as inline images rather than being dropped. [#1580](https://github.com/commutatus/cm-admin/pull/1580) ([@austinjb32](https://github.com/austinjb32))
- 📊 **Release registry with breaking-change and migration tracking** — a new `releases.json` registry records the version, date, and whether a release carries breaking changes or migrations, so downstream tooling can surface upgrade warnings programmatically. [#1580](https://github.com/commutatus/cm-admin/pull/1580) ([@austinjb32](https://github.com/austinjb32))
- 🌐 **`releases.json` served as a static file via Docusaurus** — the registry ships with the docs site and is available at a stable public URL. [#1580](https://github.com/commutatus/cm-admin/pull/1580) ([@austinjb32](https://github.com/austinjb32))
- ⚠️ **Warning badges and migration commands in generated release notes** — release notes now open with a prominent Breaking Changes / Migration Required badge and include the exact rake or migration commands to run when one applies. [#1580](https://github.com/commutatus/cm-admin/pull/1580) ([@austinjb32](https://github.com/austinjb32))
### 🐛 Bug Fixes
- 🔁 **Changelog commits no longer silently dropped on non-fast-forward push rejections** — the docs push step now rebases on `origin/main` and retries up to 5 times with a 5s backoff. Previously a single `git push origin main` died whenever `main` advanced between checkout and push, so the September 2026 page and `releases.json` never landed on main. [#1615](https://github.com/commutatus/cm-admin/pull/1615) ([@austinjb32](https://github.com/austinjb32))
- ♻️ **Docs-page updates are now idempotent** — job re-runs no longer duplicate a version's release section, and legacy `./` index links are migrated in place so re-runs can't insert duplicate entries. [#1615](https://github.com/commutatus/cm-admin/pull/1615) ([@austinjb32](https://github.com/austinjb32))
- 🔗 **Changelog index links corrected to `./Changelog/`** — the previous relative links (`./2026-09`) resolved to `/docs/2026-09`, a missing page, because the index is served at `/docs/Changelog` without a trailing slash. [#1615](https://github.com/commutatus/cm-admin/pull/1615) ([@austinjb32](https://github.com/austinjb32))
- 🏷️ **Release registry versions stored without the leading `v`** — versions are now stored in a numerically comparable format, and dedup handles both `v8.0.9` and `8.0.9` so re-runs can't create duplicate entries. [#1615](https://github.com/commutatus/cm-admin/pull/1615) ([@austinjb32](https://github.com/austinjb32))
- 🌐 **`docs_url` now points at the live domain** — registry and Slack links now use `docs.cm-admin.commutatus.com`; the previously used `cm-admin-docs.commutatus.com` has no DNS records and would 404 for users. [#1615](https://github.com/commutatus/cm-admin/pull/1615) ([@austinjb32](https://github.com/austinjb32))
### ✨ Improvements
- 📦 **Pipeline-generated v8.0.9 artifacts added** — the September 2026 release-notes page and its `releases.json` registry entry are now committed, backfilling the release that the broken push step had dropped. [#1615](https://github.com/commutatus/cm-admin/pull/1615) ([@austinjb32](https://github.com/austinjb32))
- 🪶 **Release registry simplified** to track only version, date, breaking-change/migration flags, and docs URL, dropping redundant metadata. [#1580](https://github.com/commutatus/cm-admin/pull/1580) ([@austinjb32](https://github.com/austinjb32))
- 🧩 **`response_format` extracted into a dedicated method** in the release-notes generator for readability. [#1580](https://github.com/commutatus/cm-admin/pull/1580) ([@austinjb32](https://github.com/austinjb32))
- 📐 **JSON schema moved into OpenRouter's `response_format`** and the prompt template relocated to `.github/prompts/prompt_template.txt`. [#1580](https://github.com/commutatus/cm-admin/pull/1580) ([@austinjb32](https://github.com/austinjb32))
- 📏 **`max_tokens` increased to 4000 to prevent JSON truncation** — the model response was occasionally cut off mid-JSON, which caused changelog generation to silently fall back to raw git-log output. [#1580](https://github.com/commutatus/cm-admin/pull/1580) ([@austinjb32](https://github.com/austinjb32))
- 📦 **Inline workflow scripts extracted out of YAML** into `.github/scripts` and then relocated to `.github/lib`, making the workflows far easier to read and maintain. [#1580](https://github.com/commutatus/cm-admin/pull/1580) ([@austinjb32](https://github.com/austinjb32))
- 🧪 **Release creation and push skipped during test runs; `escape_md` cleaned up** — dry runs no longer create tags or push commits to `main`. [#1580](https://github.com/commutatus/cm-admin/pull/1580) ([@austinjb32](https://github.com/austinjb32))
- 🔧 **`workflow_dispatch` and `pr_number` test inputs removed** from the changelog workflow now that the pipeline is stable. [#1580](https://github.com/commutatus/cm-admin/pull/1580) ([@austinjb32](https://github.com/austinjb32))
- 🗑️ **Obsolete `notify_slack_release.sh` script removed** — superseded by `notify_slack_release.rb`. [#1580](https://github.com/commutatus/cm-admin/pull/1580) ([@austinjb32](https://github.com/austinjb32))
### ⚠️ Breaking Changes
None in this release. ✅
### 📚 Documentation
- 📘 **`ReleaseChangelog.md` examples updated** to match the new registry format and live domain — the sample `releases.json` entry now shows `"version": "7.2.26"` (no leading `v`) and `"docs_url": "https://docs.cm-admin.commutatus.com/docs/Changelog/2026-09"`, so anyone following the docs copies a working configuration. [#1615](https://github.com/commutatus/cm-admin/pull/1615) ([@austinjb32](https://github.com/austinjb32))
- 📑 **Changelog index link corrected** in `docs-site/docs/Changelog/index.md` — the September 2026 entry now points at `./Changelog/2026-09`, matching the served URL structure. [#1615](https://github.com/commutatus/cm-admin/pull/1615) ([@austinjb32](https://github.com/austinjb32))
- 🗓️ **September 2026 release-notes page published** (`docs-site/docs/Changelog/2026-09.md`) — documents the v8.0.9 release, including its feature, bug-fix, and improvement sections. [#1615](https://github.com/commutatus/cm-admin/pull/1615) ([@austinjb32](https://github.com/austinjb32))
- 🚀 **"Releasing the Gem"** (`docs-site/docs/4. Deployment/ReleaseGem.md`) — covers the stable `release-cm-gem.yml` workflow (trigger, `bump_type` input, build/release/changelog job graph, required secrets and variables) and the beta `release-beta-gem.yml` workflow, including how beta version numbers are derived from existing `v.beta*` tags. Also notes that beta releases are pre-releases and do not trigger the changelog workflow. [#1580](https://github.com/commutatus/cm-admin/pull/1580) ([@austinjb32](https://github.com/austinjb32))
- 🤖 **"Release Changelog & Notifications"** (`docs-site/docs/4. Deployment/ReleaseChangelog.md`) — documents the reusable `changelog-release.yml` workflow and each of its components: `collect_context.sh`, `generate_release_notes.rb` (including the JSON response shape and fallback behaviour when OpenRouter is unavailable), `update_docs_page.sh`, `update_release_registry.rb`, and `notify_slack_release.rb`. Also documents the prompt template location and how to tune generated note structure and tone. [#1580](https://github.com/commutatus/cm-admin/pull/1580) ([@austinjb32](https://github.com/austinjb32))
- 📑 **Changelog section index** (`docs-site/docs/Changelog/index.md`) — a landing page for the Changelog docs section listing recent months, with each monthly page (`.md`) holding that month's AI-generated release notes. [#1580](https://github.com/commutatus/cm-admin/pull/1580) ([@austinjb32](https://github.com/austinjb32))
---
sidebar_position: 202609
---
## Release Notes — September 2026
### v8.0.9 (2026-09-24)
## Catalyst v8.0.9
**Release date:** September 24, 2026
**Gem:** `cm_admin` v8.0.9
**Compare:** [`v8.0.8...v8.0.9`](https://github.com/commutatus/cm-admin/compare/v8.0.8...v8.0.9)
> ✅ **No breaking changes. No migrations required.** This release is a safe drop-in upgrade — no rake tasks, no database migrations, and no API changes to run. The bulk of the work is release-automation tooling plus two UI fixes.
---
### 🚀 Features
- 🤖 **AI-powered changelog generation and Slack notifications for releases** — every gem release now automatically assembles release notes from `CHANGELOG.md`, the git log, merged PR bodies, and the docs diff via the OpenRouter API, publishes them to a monthly Docusaurus changelog page, and posts a rich Block Kit summary to Slack. No manual changelog writing required. [#1580](https://github.com/commutatus/cm-admin/pull/1580) ([@austinjb32](https://github.com/austinjb32))
- 🔗 **PR links and embedded media in generated release notes** — each generated change entry now links back to its source PR, and GitHub attachment URLs found in PR bodies are preserved and embedded as inline images rather than being dropped. [#1580](https://github.com/commutatus/cm-admin/pull/1580) ([@austinjb32](https://github.com/austinjb32))
- 📊 **Release registry with breaking-change and migration tracking** — a new `releases.json` registry records the version, date, and whether a release carries breaking changes or migrations, so downstream tooling can surface upgrade warnings programmatically. [#1580](https://github.com/commutatus/cm-admin/pull/1580) ([@austinjb32](https://github.com/austinjb32))
- 🌐 **`releases.json` served as a static file via Docusaurus** — the registry ships with the docs site and is available at a stable public URL. [#1580](https://github.com/commutatus/cm-admin/pull/1580) ([@austinjb32](https://github.com/austinjb32))
- ⚠️ **Warning badges and migration commands in generated release notes** — release notes now open with a prominent Breaking Changes / Migration Required badge and include the exact rake or migration commands to run when one applies. [#1580](https://github.com/commutatus/cm-admin/pull/1580) ([@austinjb32](https://github.com/austinjb32))
### 🐛 Bug Fixes
- 🔒 **Custom form modal errors now render in shared alert banner markup, safely** — `custom_form_submit_controller.js` renders submit errors using the standard `alert-banner` structure instead of ad-hoc `d-flex` markup, so modal errors match the alert styling used elsewhere in cm-admin (`_alert_banner.html.slim`, `_default_import_notes.html.slim`). The server-provided message is now assigned via `textContent` rather than interpolated into `innerHTML`, closing an XSS vector whenever a server error message reflects user input. `errorContainer` is also null-guarded on both the submit and error paths, removing a latent `TypeError` when the container is absent. New spacing styles for `ul`, `p`, and `li` inside `.alert-banner-text` keep list-based alert content readable. [#1614](https://github.com/commutatus/cm-admin/pull/1614) ([@BrijeshSajeev](https://github.com/BrijeshSajeev))
- 🏷️ **Catalyst branding in release notes and Slack notifications** — generated notes and Slack messages no longer leak internal project naming. [#1580](https://github.com/commutatus/cm-admin/pull/1580) ([@austinjb32](https://github.com/austinjb32))
- 📏 **`max_tokens` increased to 4000 to prevent JSON truncation** — the model response was occasionally cut off mid-JSON, which caused changelog generation to silently fall back to raw git-log output. [#1580](https://github.com/commutatus/cm-admin/pull/1580) ([@austinjb32](https://github.com/austinjb32))
- 🧪 **Release creation and push skipped during test runs; `escape_md` cleaned up** — dry runs no longer create tags or push commits to `main`. [#1580](https://github.com/commutatus/cm-admin/pull/1580) ([@austinjb32](https://github.com/austinjb32))
- 🧹 **Code review findings on the release automation addressed** — follow-up fixes from review of the changelog pipeline. [#1580](https://github.com/commutatus/cm-admin/pull/1580) ([@austinjb32](https://github.com/austinjb32))
### ✨ Improvements
- 🎨 **Export modal secondary button styling moved to SCSS `@extend`** — the modal footer now carries a `[type="button"], .btn-secondary` rule extending `.btn, .btn-outline-dark, .btn-sm, .btn-icon-spacing`, mirroring the existing primary-action rule. This fixes the export modal's Close button rendering as Bootstrap's solid `btn-secondary` instead of the cm-admin design-system outline style, which happened because the export modal renders outside the `.cm-admin` wrapper and never picked up `.cm-admin .btn-secondary`. The export modal's class list was simplified from `btn btn-secondary` to `btn-secondary`. [#1613](https://github.com/commutatus/cm-admin/pull/1613) ([@BrijeshSajeev](https://github.com/BrijeshSajeev))
- 🧩 **`response_format` extracted into a dedicated method** in the release-notes generator for readability. [#1580](https://github.com/commutatus/cm-admin/pull/1580) ([@austinjb32](https://github.com/austinjb32))
- 🪶 **Release registry simplified** to track only version, date, breaking-change/migration flags, and docs URL, dropping redundant metadata. [#1580](https://github.com/commutatus/cm-admin/pull/1580) ([@austinjb32](https://github.com/austinjb32))
- 🔧 **`workflow_dispatch` and `pr_number` test inputs removed** from the changelog workflow now that the pipeline is stable. [#1580](https://github.com/commutatus/cm-admin/pull/1580) ([@austinjb32](https://github.com/austinjb32))
- 📦 **Inline workflow scripts extracted out of YAML** into `.github/scripts` and then relocated to `.github/lib`, making the workflows far easier to read and maintain. [#1580](https://github.com/commutatus/cm-admin/pull/1580) ([@austinjb32](https://github.com/austinjb32))
- 📐 **JSON schema moved into OpenRouter's `response_format` and the prompt template relocated** to `.github/prompts/prompt_template.txt`. [#1580](https://github.com/commutatus/cm-admin/pull/1580) ([@austinjb32](https://github.com/austinjb32))
- 💅 **Hash shorthand used for `response_format`** and a redundant comment dropped. [#1580](https://github.com/commutatus/cm-admin/pull/1580) ([@austinjb32](https://github.com/austinjb32))
- 🗑️ **Obsolete `notify_slack_release.sh` script removed** — superseded by `notify_slack_release.rb`. [#1580](https://github.com/commutatus/cm-admin/pull/1580) ([@austinjb32](https://github.com/austinjb32))
### ⚠️ Breaking Changes
None in this release. ✅
### 📚 Documentation
Three new pages were added to the docs site (182 insertions across 3 files), documenting the release pipeline end to end:
- 🚀 **"Releasing the Gem"** (`docs-site/docs/4. Deployment/ReleaseGem.md`) — covers the stable `release-cm-gem.yml` workflow (trigger, `bump_type` input, build/release/changelog job graph, required secrets and variables) and the beta `release-beta-gem.yml` workflow, including how beta version numbers are derived from existing `v.beta*` tags. Also notes that beta releases are pre-releases and do not trigger the changelog workflow. [#1580](https://github.com/commutatus/cm-admin/pull/1580) ([@austinjb32](https://github.com/austinjb32))
- 🤖 **"Release Changelog & Notifications"** (`docs-site/docs/4. Deployment/ReleaseChangelog.md`) — documents the reusable `changelog-release.yml` workflow and each of its components: `collect_context.sh`, `generate_release_notes.rb` (including the JSON response shape and fallback behaviour when OpenRouter is unavailable), `update_docs_page.sh`, `update_release_registry.rb`, and `notify_slack_release.rb`. Also documents the prompt template location and how to tune generated note structure and tone. [#1580](https://github.com/commutatus/cm-admin/pull/1580) ([@austinjb32](https://github.com/austinjb32))
- 📑 **Changelog section index** (`docs-site/docs/Changelog/index.md`) — a new landing page for the Changelog docs section listing recent months, with each monthly page (`.md`) holding that month's AI-generated release notes. [#1580](https://github.com/commutatus/cm-admin/pull/1580) ([@austinjb32](https://github.com/austinjb32))
---
## Release Notes — October 2026
### v8.4.2 (2026-10-30)
## Catalyst v8.4.2 — 2026-10-30
### 🚀 Features
_No new features in this release._
### 🐛 Bug Fixes
- 🐛 Removed the opacity transition from the chat widget’s copy button. [#1783](https://github.com/commutatus/cm-admin/pull/1783) ([#Mahaveer1141](https://github.com/Mahaveer1141))
### ✨ Improvements
- ✨ Updated OTP email rendering to use the shared branded template and styled code block used by other Catalyst email flows. [#1782](https://github.com/commutatus/cm-admin/pull/1782) ([#BrijeshSajeev](https://github.com/BrijeshSajeev))
### ⚠️ Breaking Changes
_No breaking changes._
### 📚 Documentation
- 📚 Updated the October 2026 changelog with the v8.4.1 release notes. [215a313d2](https://github.com/commutatus/cm-admin/commit/215a313d2)
### v8.4.1 (2026-10-30)
## Catalyst v8.4.1 — 2026-10-30
⚠️ **WARNING: Migration Required** — Catalyst AI voice input adds the `ct_bot_transcriptions` table. Generate and run the migration:
```bash
rails generate cm_admin:add_bot_transcriptions
rails db:migrate
```
Fresh installs using Catalyst AI should first run `rails generate cm_admin:add_bot_conversations`, then the commands above. If your user model uses a table other than `users`, update the generated migration’s user foreign-key target before running `rails db:migrate`. Add `CtBotTranscription` to `config.included_models` after migrating.
### 🚀 Features
- 🎙️ Added voice input to the Catalyst AI chat widget, with recording controls and inline recording, transcription, and failure states. Transcribed text is added to the composer. [#1776](https://github.com/commutatus/cm-admin/pull/1776) ([#Mahaveer1141](https://github.com/Mahaveer1141))
- 🗂️ Added Catalyst AI Transcriptions admin index and show pages, including audio playback and cost tracking; transcriptions can also be viewed from bot conversations and messages. [#1776](https://github.com/commutatus/cm-admin/pull/1776) ([#Mahaveer1141](https://github.com/Mahaveer1141))
- 📋 Added a **Copy as Markdown** action for chat messages. [#1776](https://github.com/commutatus/cm-admin/pull/1776) ([#Mahaveer1141](https://github.com/Mahaveer1141))
### 🐛 Bug Fixes
- 🔊 Normalized transcription audio formats and shared the ActionCable consumer to improve transcription delivery. [#1776](https://github.com/commutatus/cm-admin/pull/1776) ([#Mahaveer1141](https://github.com/Mahaveer1141))
### ✨ Improvements
- 💰 Added background transcription processing, with transcription status and costs recorded and costs rolled up to the conversation. A new **CT Bot Transcription Model** platform setting configures the transcription model. [#1776](https://github.com/commutatus/cm-admin/pull/1776) ([#Mahaveer1141](https://github.com/Mahaveer1141))
- 🧭 Made transcriptions visible in sidebar navigation and updated chat widget button tooltips and labels. [#1776](https://github.com/commutatus/cm-admin/pull/1776) ([#Mahaveer1141](https://github.com/Mahaveer1141))
- 🧪 Removed root-level RSpec setup, its dependency, and Rake tasks; tests now live in `feature-app/spec`. The README points developers to the feature app for running tests. [#1780](https://github.com/commutatus/cm-admin/pull/1780) ([#Mahaveer1141](https://github.com/Mahaveer1141))
### ⚠️ Breaking Changes
_No breaking changes._
### 📚 Documentation
- 📚 Added the v8.4.1 upgrade guide and `add_bot_transcriptions` generator documentation. Updated the Catalyst AI guide with voice input setup, model settings, transcription behavior, and admin views. [#1776](https://github.com/commutatus/cm-admin/pull/1776) ([#Mahaveer1141](https://github.com/Mahaveer1141))
- 📝 Updated docs-site setup references to use npm instead of Yarn. [#1780](https://github.com/commutatus/cm-admin/pull/1780) ([#Mahaveer1141](https://github.com/Mahaveer1141))
### v8.4.0 (2026-10-30)
## Catalyst v8.4.0 — 2026-10-30
⚠️ **WARNING: Migration Required** — Catalyst AI adds tables for conversations, messages, question artifacts, and cost tracking, and adds an AI opt-in flag to `cm_roles`. Generate and run the migrations:
```bash
rails generate cm_admin:add_bot_conversations
rails db:migrate
```
If your user model uses a table other than `users`, update the generated conversation migration’s foreign-key target before running `rails db:migrate`.
### 🚀 Features
- 🤖 Added Catalyst AI (CT Bot), an opt-in assistant available as a floating chat widget in the admin panel. It answers questions using read-only MCP tools scoped to the asking user’s permissions. [#1746](https://github.com/commutatus/cm-admin/pull/1746) ([#Mahaveer1141](https://github.com/Mahaveer1141))
- 💬 Added conversation history, live ActionCable reply streaming, file attachments, unread badges, and interactive clarifying-question cards. [#1746](https://github.com/commutatus/cm-admin/pull/1746) ([#Mahaveer1141](https://github.com/Mahaveer1141))
- 💰 Added per-request token and cost tracking, with totals available for messages and conversations. [#1746](https://github.com/commutatus/cm-admin/pull/1746) ([#Mahaveer1141](https://github.com/Mahaveer1141))
### 🐛 Bug Fixes
- 🧮 Fixed the MCP aggregate tool input schema so it omits the `column` field for models without numeric columns. [#1746](https://github.com/commutatus/cm-admin/pull/1746) ([#Mahaveer1141](https://github.com/Mahaveer1141))
### ✨ Improvements
- 📱 Improved the chat widget for mobile screens and added dynamic tooltips and clearer UI details. [#1746](https://github.com/commutatus/cm-admin/pull/1746) ([#Mahaveer1141](https://github.com/Mahaveer1141))
- ⏱️ Added a completion timestamp for assistant messages to track when replies finish. [#1746](https://github.com/commutatus/cm-admin/pull/1746) ([#Mahaveer1141](https://github.com/Mahaveer1141))
### ⚠️ Breaking Changes
_No breaking changes in this release._
### 📚 Documentation
- 📚 Added a Catalyst AI guide covering setup, configuration, permissions, security, and admin views; documented the new generator and added v8.4.0 upgrade steps. [#1746](https://github.com/commutatus/cm-admin/pull/1746) ([#Mahaveer1141](https://github.com/Mahaveer1141))
### v8.3.12 (2026-10-29)
## Catalyst v8.3.12 — 2026-10-29
### 🚀 Features
- 🔑 Enabled history tracking for API Tokens, so the History tab shows an audit trail of record changes and actions. [#1760](https://github.com/commutatus/cm-admin/pull/1760) ([#anbublacky](https://github.com/anbublacky))
- 📝 Added BlogPost and BlogCategory admin models and specs to the feature app. [61e0de384](https://github.com/commutatus/cm-admin/commit/61e0de384)
- 🏖️ Expanded the feature app’s leave-management example with leave requests, types, balances, compensatory credits, and holiday management. Leave records now belong directly to User, avoiding a separate Employee model. [#1752](https://github.com/commutatus/cm-admin/pull/1752) ([#BrijeshSajeev](https://github.com/BrijeshSajeev))
### 🐛 Bug Fixes
- 🧭 Normalized breadcrumb-chain parameters when building new associated-record URLs, preventing malformed breadcrumb chains on subsequent pages. [#1768](https://github.com/commutatus/cm-admin/pull/1768) ([#anbublacky](https://github.com/anbublacky))
### ✨ Improvements
- ⚙️ Added project-level Claude Code settings to remove attribution footers and session links from pull request descriptions. [#1769](https://github.com/commutatus/cm-admin/pull/1769) ([#mikevic](https://github.com/mikevic))
### ⚠️ Breaking Changes
_No breaking changes in this release._
### 📚 Documentation
- 📚 Added the v8.3.11 release notes to the October changelog and updated the release index. [34b2661e4](https://github.com/commutatus/cm-admin/commit/34b2661e4)
### v8.3.11 (2026-10-28)
## Catalyst v8.3.11 — 2026-10-28
### 🚀 Features
_No new features in this release._
### 🐛 Bug Fixes
_No bug fixes in this release._
### ✨ Improvements
_No improvements in this release._
### ⚠️ Breaking Changes
_No breaking changes in this release._
### 📚 Documentation
- 📚 Added the v8.3.10 release notes to the October changelog, documenting the initializer configuration change and related documentation updates. [#1739](https://github.com/commutatus/cm-admin/pull/1739) ([#mikevic](https://github.com/mikevic))
- 📚 Updated the release index to include v8.3.10. [337421688](https://github.com/commutatus/cm-admin/commit/337421688)
### v8.3.10 (2026-10-27)
## Catalyst v8.3.10 — 2026-10-27
### 🚀 Features
_No new features in this release._
### 🐛 Bug Fixes
_No bug fixes in this release._
### ✨ Improvements
- ✨ Removed the requirement to name the Catalyst initializer `zcm_admin.rb`. Configuration now runs after all app initializers have loaded, so it can reference models and other initializer-defined constants regardless of filename or load order. Configuration is reapplied on code reloads in development. [#1739](https://github.com/commutatus/cm-admin/pull/1739) ([#mikevic](https://github.com/mikevic))
### ⚠️ Breaking Changes
_No breaking changes in this release._
### 📚 Documentation
- 📚 Updated the generator output and documentation to use `config/initializers/cm_admin.rb` as the default filename, and removed the old initializer naming guideline. [#1739](https://github.com/commutatus/cm-admin/pull/1739) ([#mikevic](https://github.com/mikevic))
### v8.3.9 (2026-10-27)
## Catalyst v8.3.9 — 2026-10-27
⚠️ **WARNING: Migration Required** — The install generator now includes the `CtFileImport` improvement migration. Existing apps missing the import columns must generate and run it. The migration checks for existing columns before adding them.
```bash
rails g cm_admin:file_import_improvement && rails db:migrate
```
### 🚀 Features
- 🚀 Added PR review apps: eligible pull requests to `main` get a deployed feature-app instance, with its own database and file storage, which is removed when the PR closes. [#1721](https://github.com/commutatus/cm-admin/pull/1721) ([#mikevic](https://github.com/mikevic))
- 🌐 Review apps now have Commutatus-branded URLs at `https://pr-.catalyst.platforms.commutatus.com`; the existing `fly.dev` URL remains available. [#1725](https://github.com/commutatus/cm-admin/pull/1725) ([#mikevic](https://github.com/mikevic))
### 🐛 Bug Fixes
- 🐛 Fixed record history pages crashing when loading a record. The history action now fetches the record through the model’s policy scope, matching the show action’s visibility rules. [#1750](https://github.com/commutatus/cm-admin/pull/1750) ([#BrijeshSajeev](https://github.com/BrijeshSajeev))
- 🐛 Fixed the install generator omitting the migration that adds required import columns to `ct_file_imports`, which could break file import forms and pages. [#1741](https://github.com/commutatus/cm-admin/pull/1741) ([#mikevic](https://github.com/mikevic))
- 🐛 Fixed migration checking to detect `.tt` migration templates rather than looking for `.rb` files. [#1747](https://github.com/commutatus/cm-admin/pull/1747) ([#Kishore-Girivasan](https://github.com/Kishore-Girivasan))

- 🐛 Fixed Postmark and generated policy constant warnings by using the current Postmark error class and replacing generated constants before redefining them. [#1738](https://github.com/commutatus/cm-admin/pull/1738) ([#BrijeshSajeev](https://github.com/BrijeshSajeev))
- 🐛 Removed an Rswag UI deprecation warning by switching to `openapi_endpoint`, without changing the API documentation URL or label. [#1730](https://github.com/commutatus/cm-admin/pull/1730) ([#mikevic](https://github.com/mikevic))
### ✨ Improvements
- ✨ Models can now declare enums after including a CmAdmin concern. Enum filters and DSL references are initialized after the model class body has finished loading. [#1740](https://github.com/commutatus/cm-admin/pull/1740) ([#mikevic](https://github.com/mikevic))
- ⚡️ Improved role-permission factory setup to save permissions in bulk, reducing the queries used when creating test admin users. [#1742](https://github.com/commutatus/cm-admin/pull/1742) ([#mikevic](https://github.com/mikevic))
- 🛠️ Asset precompilation and other asset tasks can now boot without a database connection; platform settings sync is skipped for `db:` and `assets:` tasks. [#1716](https://github.com/commutatus/cm-admin/pull/1716) ([#mikevic](https://github.com/mikevic))
- 🔧 Review app deployment now runs after tests pass, and review app workflows skip Dependabot PRs and docs-site-only changes. [#1735](https://github.com/commutatus/cm-admin/pull/1735) ([#mikevic](https://github.com/mikevic)) [#1733](https://github.com/commutatus/cm-admin/pull/1733) ([#mikevic](https://github.com/mikevic))
- 🧰 Tests, linters, and security checks now run on GitHub-hosted runners. CI dependency installation was also streamlined, and the project added StandardRB linting. [#1720](https://github.com/commutatus/cm-admin/pull/1720) ([#mikevic](https://github.com/mikevic)) [#1722](https://github.com/commutatus/cm-admin/pull/1722) ([#mikevic](https://github.com/mikevic)) [#1736](https://github.com/commutatus/cm-admin/pull/1736) ([#BrijeshSajeev](https://github.com/BrijeshSajeev))
### ⚠️ Breaking Changes
- ✅ No breaking changes in this release.
### 📚 Documentation
- 📚 Added a PR Review Apps guide covering how review apps are deployed and removed, how to access them, their architecture and configuration, and troubleshooting. Added a link to the guide from the Deployment index. [#1737](https://github.com/commutatus/cm-admin/pull/1737) ([#mikevic](https://github.com/mikevic))
- 📚 Removed the enum declaration order rule from the introduction because it is no longer required. [#1740](https://github.com/commutatus/cm-admin/pull/1740) ([#mikevic](https://github.com/mikevic))
### v8.3.8 (2026-10-23)
## Catalyst v8.3.8 — 2026-10-23
### 🚀 Features
- No new features in this release.
### 🐛 Bug Fixes
- 🐛 Fixed export failures for columns where the configured `id` field uses `field_type: :association`. Synthesized export ID columns now use a safe field type and preserve the configured `export_method`, so exports return the associated record’s ID as declared. [#1711](https://github.com/commutatus/cm-admin/pull/1711) ([#Kishore-Girivasan](https://github.com/Kishore-Girivasan))
### ✨ Improvements
- No additional improvements in this release.
### ⚠️ Breaking Changes
- No breaking changes in this release.
### 📚 Documentation
- 📚 Updated the October 2026 changelog and release listing with the v8.3.7 release notes. [b160e7172](https://github.com/commutatus/cm-admin/commit/b160e7172)
### v8.3.7 (2026-10-23)
## Catalyst v8.3.7 — 2026-10-23
### 🚀 Features
- 🚀 Added lazy loading for row action dropdowns in index, kanban, and associated table views. Actions are fetched when a dropdown is first opened and cached for subsequent opens, reducing the work performed during initial page loads. [#1578](https://github.com/commutatus/cm-admin/pull/1578) ([#austinjb32](https://github.com/austinjb32))

### 🐛 Bug Fixes
- No bug fixes in this release.
### ✨ Improvements
- No additional improvements in this release.
### ⚠️ Breaking Changes
- No breaking changes in this release.
### 📚 Documentation
- 📚 Updated the October 2026 changelog and release listing with v8.3.6 release notes. [6d912bfff](https://github.com/commutatus/cm-admin/commit/6d912bfff)
### v8.3.6 (2026-10-23)
## Catalyst v8.3.6 — 2026-10-23
### 🚀 Features
- No new features in this release.
### 🐛 Bug Fixes
- 🐛 Fixed OTP resend integration, expired OTP scope resolution, and password-reset token expiry handling. Added regression coverage for OTP generation, verification, expiry, throttling, sign-in, and password reset. Feature-app OTP delivery remains email-only. [#1713](https://github.com/commutatus/cm-admin/pull/1713) ([#BrijeshSajeev](https://github.com/BrijeshSajeev))
### ✨ Improvements
- ✨ Routed MCP requests through a controller so Scout APM can record them, and gave transactions distinct names based on the MCP method and tool. Apps without Scout are unaffected. [#1703](https://github.com/commutatus/cm-admin/pull/1703) ([#mikevic](https://github.com/mikevic))
- ✨ Cached Ruby test dependencies in CI to speed up test runs. [680b968a2](https://github.com/commutatus/cm-admin/commit/680b968a2)
### ⚠️ Breaking Changes
- No breaking changes in this release.
### 📚 Documentation
- 📚 Updated the MCP server guide with the controller architecture and Scout APM transaction naming details. [#1703](https://github.com/commutatus/cm-admin/pull/1703) ([#mikevic](https://github.com/mikevic))
- 📚 Updated the October 2026 changelog and release listing. [5d2e906d3](https://github.com/commutatus/cm-admin/commit/5d2e906d3)
### v8.3.5 (2026-10-23)
## Catalyst v8.3.5 — 2026-10-23
### 🚀 Features
- No new features in this release.
### 🐛 Bug Fixes
- 🐛 Webhook events now display the recipient email from the payload when the associated recipient record is missing, instead of showing “—”. [#1707](https://github.com/commutatus/cm-admin/pull/1707) ([#Kishore-Girivasan](https://github.com/Kishore-Girivasan))
- 🐛 Fixed exports that failed when an ID column was typed as an association. [9a22abc61](https://github.com/commutatus/cm-admin/commit/9a22abc61)
### ✨ Improvements
- ✨ Extracted webhook recipient resolution into the `CmWebhookEvent` model. [e1cec013b](https://github.com/commutatus/cm-admin/commit/e1cec013b)
### ⚠️ Breaking Changes
- No breaking changes in this release.
### 📚 Documentation
- 📚 Updated the October 2026 changelog with the v8.3.4 release notes and updated the docs-site release listing. [a86031ad8](https://github.com/commutatus/cm-admin/commit/a86031ad8)
### v8.3.4 (2026-10-23)
## Catalyst v8.3.4 — 2026-10-23
### 🚀 Features
- No new features in this release.
### 🐛 Bug Fixes
- 🐛 Form validation now checks date fields, handles required selects with no selected value safely, and keeps nested-field validation scoped to the submitted form. [#1675](https://github.com/commutatus/cm-admin/pull/1675) ([#anbublacky](https://github.com/anbublacky))
- 🧭 When validation fails, the page now scrolls to the first invalid field instead of jumping back to the top. [#1675](https://github.com/commutatus/cm-admin/pull/1675) ([#anbublacky](https://github.com/anbublacky))
- 🔁 Fixed the `eventually` test helper so it retries expectations until they pass or the wait time expires. [#1693](https://github.com/commutatus/cm-admin/pull/1693) ([#mikevic](https://github.com/mikevic))
### ✨ Improvements
- ⚡ Parallelized feature-app specs, replaced fixed sleeps with condition checks, disabled animations in system specs, and limited screenshots to failures by default to speed up test runs. [#1693](https://github.com/commutatus/cm-admin/pull/1693) ([#mikevic](https://github.com/mikevic))
- 🧪 Updated the Tests workflow to install JavaScript dependencies with `npm ci` and use the existing cache for faster, lockfile-based installs. [#1705](https://github.com/commutatus/cm-admin/pull/1705) ([#mikevic](https://github.com/mikevic))
- 📦 Updated the docs-site dependencies `follow-redirects` to 1.16.1 and `launch-editor` to 2.14.2. [#1694](https://github.com/commutatus/cm-admin/pull/1694) ([#app/dependabot](https://github.com/app/dependabot)) [#1696](https://github.com/commutatus/cm-admin/pull/1696) ([#app/dependabot](https://github.com/app/dependabot))
### ⚠️ Breaking Changes
- No breaking changes in this release.
### 📚 Documentation
- 📚 Updated the October 2026 changelog with the v8.3.3 release notes and updated the docs-site release listing. [fbd4391d4](https://github.com/commutatus/cm-admin/commit/fbd4391d4)
### v8.3.3 (2026-10-23)
## Catalyst v8.3.3 — 2026-10-23
> ⚠️ **WARNING: Breaking Changes** — `status_helper_icon` was renamed to `cm_info_tooltip`. Applications that call `status_helper_icon` directly must update those calls.
>
> 🔄 **WARNING: Migration Required** — Existing installs need new `cm_webhook_events` recipient and clicked-link columns for per-recipient tracking. Fresh installs do not need this extra generator.
>
> Run on existing installs:
> ```bash
> rails generate cm_admin:add_webhook_event_recipient
> rails db:migrate
> ```
### 🚀 Features
- 📬 Added per-recipient email open and click tracking to the Postmark webhook pipeline. Events are linked to matching recipients, and clicked URLs are stored for reporting. CC/BCC events remain unattributed to the To recipient. [#1670](https://github.com/commutatus/cm-admin/pull/1670) ([#Kishore-Girivasan](https://github.com/Kishore-Girivasan))
- 📊 Expanded the Email Log page with recipient open and click counts, first-open timestamps, click details with click rates, and an Events tab. [#1670](https://github.com/commutatus/cm-admin/pull/1670) ([#Kishore-Girivasan](https://github.com/Kishore-Girivasan))
- 🟣 Added a `clicked` recipient status after `opened`, including status-transition rules and badge styling. [#1670](https://github.com/commutatus/cm-admin/pull/1670) ([#Kishore-Girivasan](https://github.com/Kishore-Girivasan))
### 🐛 Bug Fixes
- No bug fixes in this release.
### ✨ Improvements
- 🔁 Identical Postmark webhook redeliveries now resolve to the original event, preventing duplicate open and click counts. The full supported webhook payload is also stored. [#1670](https://github.com/commutatus/cm-admin/pull/1670) ([#Kishore-Girivasan](https://github.com/Kishore-Girivasan))
- 💡 Renamed the `status_helper_icon` view helper to `cm_info_tooltip` and set its Bootstrap tooltip container to `body` so tooltips render correctly inside scrollable containers. [#1670](https://github.com/commutatus/cm-admin/pull/1670) ([#Kishore-Girivasan](https://github.com/Kishore-Girivasan))
### ⚠️ Breaking Changes
- ⚠️ Applications that call `status_helper_icon` directly must change those calls to `cm_info_tooltip`. [#1670](https://github.com/commutatus/cm-admin/pull/1670) ([#Kishore-Girivasan](https://github.com/Kishore-Girivasan))
### 📚 Documentation
- 📚 Added the v8.3.3 upgrade guide and documented webhook recipient attribution, clicked-link storage, and the new generator. The upgrade guide explains migration steps, fresh-install behavior, and that historical events are not backfilled. [#1670](https://github.com/commutatus/cm-admin/pull/1670) ([#Kishore-Girivasan](https://github.com/Kishore-Girivasan))
- 📝 Updated the October 2026 changelog with v8.3.2 release notes. [0b8fc45a1](https://github.com/commutatus/cm-admin/commit/0b8fc45a1)
### v8.3.2 (2026-10-22)
## Catalyst v8.3.2 — 2026-10-22
### 🚀 Features
- No new features in this release.
### 🐛 Bug Fixes
- 🛡️ Fixed authorization for member custom actions under the index page. These actions now load the target record through the policy scope and authorize it before running, preventing users from invoking an action on records they cannot access. [#1704](https://github.com/commutatus/cm-admin/pull/1704) ([#Kishore-Girivasan](https://github.com/Kishore-Girivasan))
### ✨ Improvements
- No other improvements in this release.
### ⚠️ Breaking Changes
- No breaking changes.
### 📚 Documentation
- 📚 Updated the October 2026 changelog and release index with the v8.3.1 release notes. [1a2ba4104](https://github.com/commutatus/cm-admin/commit/1a2ba4104)
### v8.3.1 (2026-10-22)
## Catalyst v8.3.1 — 2026-10-22
### 🚀 Features
- ✨ Added an optional header icon button next to the page title, configurable with an icon, URL, tooltip, and visibility condition. ([#1659](https://github.com/commutatus/cm-admin/pull/1659)) ([#Kishore-Girivasan](https://github.com/Kishore-Girivasan))
- ✨ Upgraded page descriptions to sanitized rich text, shown on pages with the top navbar and truncated with a View more / View less toggle. ([#1659](https://github.com/commutatus/cm-admin/pull/1659)) ([#Kishore-Girivasan](https://github.com/Kishore-Girivasan))
### 🐛 Bug Fixes
- 🐛 Improved page-description toggle positioning and tooltip offset, with better ellipsis alignment. ([5cc84656e](https://github.com/commutatus/cm-admin/commit/5cc84656e))
- 🐛 Adjusted page-description toggle positioning and ellipsis alignment for a cleaner truncated layout. ([5b620bb72](https://github.com/commutatus/cm-admin/commit/5b620bb72))
### ✨ Improvements
- 📦 Changed gem packaging to an allow list so releases include runtime files, deployment configuration, and Markdown documentation without unnecessary development files. ([#1689](https://github.com/commutatus/cm-admin/pull/1689)) ([#mikevic](https://github.com/mikevic))
### ⚠️ Breaking Changes
- No breaking changes.
### 📚 Documentation
- 📚 Expanded the DSL reference with page-description rich-text behavior and examples, plus the title-icon options and usage example. ([#1659](https://github.com/commutatus/cm-admin/pull/1659)) ([#Kishore-Girivasan](https://github.com/Kishore-Girivasan))
### v8.3.0 (2026-10-22)
## Catalyst v8.3.0 — 2026-10-22
### 🚀 Features
- No new user-facing features in this release.
### 🐛 Bug Fixes
- 🛡️ Hardened custom action modal handling with constantization safety, a nil guard, and action-name validation to address a Brakeman-reported remote code execution risk. ([#1673](https://github.com/commutatus/cm-admin/pull/1673)) ([#austinjb32](https://github.com/austinjb32))
- 🐛 Fixed filter display text for collections with a single option. ([#1673](https://github.com/commutatus/cm-admin/pull/1673)) ([#austinjb32](https://github.com/austinjb32))
### ✨ Improvements
- ✨ Refactored resource actions into dedicated services and moved exports and page-view helpers to the service layer, leaving controllers thinner without changing behavior. ([#1673](https://github.com/commutatus/cm-admin/pull/1673)) ([#austinjb32](https://github.com/austinjb32))
- ✨ Converted form validation to a Stimulus controller while preserving validation, discard-button, unsaved-change tracking, and leave-page guard behavior. ([#1673](https://github.com/commutatus/cm-admin/pull/1673)) ([#austinjb32](https://github.com/austinjb32))
- ✨ Cleaned up JavaScript controllers and vendored styling, and aligned feature-app schemas and specs with existing behavior. ([#1673](https://github.com/commutatus/cm-admin/pull/1673)) ([#austinjb32](https://github.com/austinjb32))
### ⚠️ Breaking Changes
- No breaking changes.
### 📚 Documentation
- No documentation changes for this release.
### v8.2.18 (2026-10-22)
## Catalyst v8.2.18 — 2026-10-22
> ⚠️ **WARNING: Breaking Changes** — API token `description` is replaced by `name`. The legacy description generator is removed, and existing description values are not copied to the new field.
>
> 🔄 **WARNING: Migration Required** — Generate and run the API token name migration. It adds and backfills `name`, then drops `description` if present:
> ```bash
> rails generate cm_admin:add_name_to_api_token
> rails db:migrate
> ```
### 🚀 Features
- 🚀 API tokens now have a human-readable `name`, shown in the index and token details. The index supports searching by name, and tokens can be renamed from a new edit page. ([#1697](https://github.com/commutatus/cm-admin/pull/1697)) ([#anbublacky](https://github.com/anbublacky))
- 🚀 Markdown documentation is bundled with the gem so coding agents can access documentation that matches the installed version. ([#1695](https://github.com/commutatus/cm-admin/pull/1695)) ([#mikevic](https://github.com/mikevic))
### 🐛 Bug Fixes
- No bug fixes in this release.
### ✨ Improvements
- ✨ The documentation site now publishes `/llms.txt`, `/llms-full.txt`, and Markdown versions of its pages for coding agents. ([#1695](https://github.com/commutatus/cm-admin/pull/1695)) ([#mikevic](https://github.com/mikevic))
### ⚠️ Breaking Changes
- ⚠️ API tokens replace the v8.2.14 `description` field with `name`. The name is limited to 100 characters and is required for personal access tokens. The old `cm_admin:add_description_to_api_token` generator has been removed; the new migration backfills existing tokens with `Unnamed Token` and drops `description` without carrying its values over. Add a name wherever your application creates tokens directly. ([#1697](https://github.com/commutatus/cm-admin/pull/1697)) ([#anbublacky](https://github.com/anbublacky))
### 📚 Documentation
- 📚 Added a v8.2.18 upgrade guide with migration steps and compatibility notes; updated the generator reference and MCP token example for the new `name` field. ([#1697](https://github.com/commutatus/cm-admin/pull/1697)) ([#anbublacky](https://github.com/anbublacky))
- 📚 Updated the documentation introduction with guidance for AI coding agents, including how to find the gem-bundled Markdown docs and the website’s LLM-friendly files. ([#1695](https://github.com/commutatus/cm-admin/pull/1695)) ([#mikevic](https://github.com/mikevic))
### v8.2.17 (2026-10-20)
## Catalyst v8.2.17 — 2026-10-20
### 🚀 Features
- No new features in this release.
### 🐛 Bug Fixes
- No bug fixes in this release.
### ✨ Improvements
- ✨ Updated the gemspec to accurately require Rails 8.0.2 or later, clarifying the minimum version supported by Catalyst’s dependencies. ([#1688](https://github.com/commutatus/cm-admin/pull/1688)) ([#mikevic](https://github.com/mikevic))
- ✨ Streamlined release automation: version and lockfile updates are committed together, and tests are skipped for the docs-only changelog commit. ([#1690](https://github.com/commutatus/cm-admin/pull/1690)) ([#mikevic](https://github.com/mikevic))
- ✨ Updated the gemspec’s author list and email addresses. ([#1687](https://github.com/commutatus/cm-admin/pull/1687)) ([#mikevic](https://github.com/mikevic))
### ⚠️ Breaking Changes
- No breaking changes.
### 📚 Documentation
- 📚 Updated the DSL reference to list valid field types and use supported `cm-badge-*` classes in examples. The status badge guide now documents the `:badge` and `badge_class` names, includes a usage example, and clarifies that the former `:tag` name and legacy badge classes are no longer supported. ([#1691](https://github.com/commutatus/cm-admin/pull/1691)) ([#mikevic](https://github.com/mikevic))
- 📚 Corrected the `column` method’s YARD comment to match the field types accepted by the code. ([#1692](https://github.com/commutatus/cm-admin/pull/1692)) ([#mikevic](https://github.com/mikevic))
### v8.2.16 (2026-10-19)
## Catalyst v8.2.16 — 2026-10-19
### 🚀 Features
- No new features in this release.
### 🐛 Bug Fixes
- 🐛 Fixed the default datetime display format to show a consistent 12-hour time with AM/PM, matching the flatpickr time picker. ([#1686](https://github.com/commutatus/cm-admin/pull/1686)) ([#austinjb32](https://github.com/austinjb32))
### ✨ Improvements
- No other improvements in this release.
### ⚠️ Breaking Changes
- No breaking changes.
### 📚 Documentation
- 📚 Updated the October 2026 changelog with the v8.2.15 release notes and refreshed the releases index. ([604b1306b](https://github.com/commutatus/cm-admin/commit/604b1306b))
### v8.2.15 (2026-10-19)
## Catalyst v8.2.15 — 2026-10-19
### 🚀 Features
- 🚀 Added the `card_title: true` column option to choose the title displayed in card and kanban views. If none is configured, the first displayable column is used; remaining columns appear as subtitles. ([#1685](https://github.com/commutatus/cm-admin/pull/1685)) ([#austinjb32](https://github.com/austinjb32))
### 🐛 Bug Fixes
- 🐛 Fixed the index toolbar so `view_type: :kanban` correctly enables the kanban view toggle. ([#1685](https://github.com/commutatus/cm-admin/pull/1685)) ([#austinjb32](https://github.com/austinjb32))
### ✨ Improvements
- No other improvements in this release.
### ⚠️ Breaking Changes
- No breaking changes.
### 📚 Documentation
- 📚 Documented the `card_title` option in the column DSL reference, including its default behavior when no title column is configured. ([#1685](https://github.com/commutatus/cm-admin/pull/1685)) ([#austinjb32](https://github.com/austinjb32))
### v8.2.14 (2026-10-19)
## Catalyst v8.2.14 — 2026-10-19
🔄 **WARNING: Migration Required** — This release adds a `description` column to API tokens. Generate and run the migration before using the new field:
```bash
rails generate cm_admin:add_description_to_api_token
rails db:migrate
```
> ⚠️ **Superseded in a later release**: the `description` column was replaced by a mandatory `name` column, and the `cm_admin:add_description_to_api_token` generator was removed. If you are upgrading past v8.2.14, run `rails generate cm_admin:add_name_to_api_token` instead — its migration adds `name` and drops `description`.
### 🚀 Features
- 🚀 API tokens can now have a human-readable description, displayed on the token show page and available on the new-token form. ([#1684](https://github.com/commutatus/cm-admin/pull/1684)) ([#anbublacky](https://github.com/anbublacky))
### 🐛 Bug Fixes
- No user-facing bug fixes in this release.
### ✨ Improvements
- ✨ Restored the automated test suite and improved CI reliability, including independent Rails-version jobs and a longer test timeout. ([#1677](https://github.com/commutatus/cm-admin/pull/1677)) ([#mikevic](https://github.com/mikevic))
### ⚠️ Breaking Changes
- No breaking changes.
### 📚 Documentation
- 📚 Added the API-token description generator to the generator reference, including its command, purpose, and migration step. ([#1684](https://github.com/commutatus/cm-admin/pull/1684)) ([#anbublacky](https://github.com/anbublacky))
- 📚 Updated the October 2026 changelog and release registry with v8.2.13 release information. ([4fd0459fd](https://github.com/commutatus/cm-admin/commit/4fd0459fd))
### v8.2.13 (2026-10-19)
## Catalyst v8.2.13 — 2026-10-19
### 🚀 Features
- 🚀 Advanced filters now apply when the dropdown closes—whether by clicking outside, pressing Esc, or selecting Apply—so configured filters take effect without requiring a separate Apply click. ([#1681](https://github.com/commutatus/cm-admin/pull/1681)) [@Mahaveer1141](https://github.com/Mahaveer1141)
### ✨ Improvements
- ✨ Improved saved-view buttons on smaller screens: below the 991px breakpoint, the button shows a view-type icon instead of the name and label. View-name width now scales responsively, and buttons have a consistent minimum height. ([#1681](https://github.com/commutatus/cm-admin/pull/1681)) [@Mahaveer1141](https://github.com/Mahaveer1141)
### 📚 Documentation
- 📚 Updated the October 2026 changelog with the v8.2.12 release notes and added that release to the releases registry. ([4fd0459fd](https://github.com/commutatus/cm-admin/commit/4fd0459fd))
### v8.2.12 (2026-10-19)
## Catalyst v8.2.12 — 2026-10-19
### 🐛 Bug Fixes
- 🐛 Fixed built-in `filter_with` scopes that raised an `ArgumentError` because they received an unexpected second argument. ([#1676](https://github.com/commutatus/cm-admin/pull/1676)) [@mikevic](https://github.com/mikevic)
- 🐛 Fixed partial range filters so a range such as `30 to ` matches values at or above 30, rather than only values equal to 30. ([#1676](https://github.com/commutatus/cm-admin/pull/1676)) [@mikevic](https://github.com/mikevic)
- 🐛 Fixed single-option select filter chips to show the option label instead of its raw ID. ([#1676](https://github.com/commutatus/cm-admin/pull/1676)) [@mikevic](https://github.com/mikevic)
- 🐛 Fixed sorting with an unknown column to fall back to the default sort, and made malformed sort parameters safely ignored. ([#1676](https://github.com/commutatus/cm-admin/pull/1676)) [@mikevic](https://github.com/mikevic)
- 🐛 Fixed multi-word search so every search term must match, rather than allowing results that match only one term. ([#1676](https://github.com/commutatus/cm-admin/pull/1676)) [@mikevic](https://github.com/mikevic)
### 📚 Documentation
- 📚 Updated the October 2026 changelog with the v8.2.11 release entry and added that release to the releases registry. ([629689839](https://github.com/commutatus/cm-admin/commit/629689839))
### v8.2.11 (2026-10-19)
## Catalyst v8.2.11 — 2026-10-19
> ⚠️ **WARNING: Breaking Changes** — Deployment notifications no longer use the webhook URL embedded in the Hatchbox config. Set `SLACK_WEBHOOK_URL` in your Hatchbox environment to continue receiving notifications. No database migration or rake task is required.
### 🚀 Features
- ✅ Added a `(Select All)` option to standard and advanced `multi_select` filters. It toggles all options, including `(Empty)`, and can be disabled with `allow_select_all_option: false`. The control is UI-only; only selected filter values are submitted. ([#1663](https://github.com/commutatus/cm-admin/pull/1663)) [@Mahaveer1141](https://github.com/Mahaveer1141)
### 🐛 Bug Fixes
- 🐛 Fixed handling of an unset Hatchbox Slack webhook: the scripts now warn that `SLACK_WEBHOOK_URL` is missing and safely skip sending a notification. ([#1678](https://github.com/commutatus/cm-admin/pull/1678)) [@austinjb32](https://github.com/austinjb32)
### ✨ Improvements
- 🛡️ Removed the hardcoded Slack webhook secret and the config-unavailable alert and related code. Deployment notifications now use only the `SLACK_WEBHOOK_URL` environment variable. ([#1678](https://github.com/commutatus/cm-admin/pull/1678)) [@austinjb32](https://github.com/austinjb32)
### ⚠️ Breaking Changes
- ⚠️ Hatchbox deployments that relied on the embedded webhook URL must now set `SLACK_WEBHOOK_URL`; without it, notifications are skipped. No database migration or rake task is required. ([#1678](https://github.com/commutatus/cm-admin/pull/1678)) [@austinjb32](https://github.com/austinjb32)
### 📚 Documentation
- 📚 Updated the filter DSL reference with the `allow_select_all_option` parameter and an example. ([#1663](https://github.com/commutatus/cm-admin/pull/1663)) [@Mahaveer1141](https://github.com/Mahaveer1141)
- 📚 Updated Hatchbox deployment documentation to describe environment-only webhook configuration and remove obsolete config-alert and environment-variable details. ([#1678](https://github.com/commutatus/cm-admin/pull/1678)) [@austinjb32](https://github.com/austinjb32)
- 📚 Updated the October 2026 changelog with the v8.2.10 release entry. ([9f12e37ef](https://github.com/commutatus/cm-admin/commit/9f12e37ef))
### v8.2.10 (2026-10-16)
- Merge pull request #1671 from commutatus/fix/CT-324-papertrail-argument-error (acfe25f2d)
- docs: update changelog for v8.2.9 (07bcc2cc6)
- fix(papertrail): support positional options in has_paper_trail wrapper (f8344d812)
### v8.2.9 (2026-10-16)
- Merge pull request #1669 from commutatus/fix/release-notes-context (a071e5794)
- feat(release): render Slack highlights as bullet points (216aa59c2)
- feat(release): credit PR contributors in generated release notes (45f250d06)
- fix(release): anchor docs insertion to page title and exclude prerelease tags (8ac94ebf7)
- docs(changelog): correct October 2026 release entries (e804aa376)
- fix(release): scope PR context to tag range and pass release date to notes prompt (2ed87033d)
- docs: update changelog for v8.2.8 (9151b4959)
### v8.2.8 (2026-10-15)
## Catalyst v8.2.8 — October 15, 2026
### 🐛 Bug Fixes
- 📤 Export modals are now limited to the appropriate relationship pages. On `has_one` tabs, neither export modal is rendered; index and associated export modals are mutually exclusive. [#1668](https://github.com/commutatus/cm-admin/pull/1668) ([@anbublacky](https://github.com/anbublacky))
### ✨ Improvements
_No improvements in this release._
### 📚 Documentation
_No documentation changes in this release._
### v8.2.7 (2026-10-15)
## Catalyst v8.2.7 — October 15, 2026
### 🐛 Bug Fixes
- 🐛 Skip rendering the associated export modal on `has_one` tab pages, where there is no export trigger and the modal could show an invalid column list. The index and associated export modals are now mutually exclusive. [#1667](https://github.com/commutatus/cm-admin/pull/1667) ([@anbublacky](https://github.com/anbublacky))
### 📚 Documentation
- 📝 Updated the changelog and release listing with the v8.2.6 release notes. [5922e34f3](https://github.com/commutatus/cm-admin/commit/5922e34f3)
### v8.2.6 (2026-10-15)
## Catalyst v8.2.6 — October 15, 2026
### 🐛 Bug Fixes
_No bug fixes in this release._
### ✨ Improvements
- 🧹 Update the filters partial path used by the dashboard view. [#1665](https://github.com/commutatus/cm-admin/pull/1665) ([@Kishore-Girivasan](https://github.com/Kishore-Girivasan))
### 📚 Documentation
- 📝 Updated the changelog and release listing. [4139554ec](https://github.com/commutatus/cm-admin/commit/4139554ec)
### v8.2.5 (2026-10-15)
## Catalyst v8.2.5 — October 15, 2026
### 🚀 Features
- 📜 **Associated record history** — Record history pages can now show the version history of nested and associated records together with the parent record's changes, so all related activity appears in one place. To enable it, include `CmAdmin::PaperTrailAssociations` in `ApplicationRecord` and declare the associations to track on each model:
```ruby
has_paper_trail
paper_trail_associations :po_items, :po_parts, :attachments
```
History entries render each record's display name, and unattributed changes are credited to "System user" instead of "Anonymous". Works with `has_many` and `has_many :through` associations, and falls back to the previous behaviour when `all_versions_including_associations` is unavailable. [#1087](https://github.com/commutatus/cm-admin/pull/1087) ([@anbublacky](https://github.com/anbublacky))
[Watch the walkthrough](https://videos.commutatus.com/videos/cmadmin-nested-table-history)
### 🐛 Bug Fixes
_No bug fixes in this release._
### ✨ Improvements
- 📦 **Dependency updates** — Bumped `activestorage` to 8.1.3.1 in the feature app, `colord` to 2.10.0, and `@ai-sdk/provider-utils`, `@ai-sdk/react`, and `ai` in the docs site. [#1585](https://github.com/commutatus/cm-admin/pull/1585) ([@dependabot[bot]](https://github.com/apps/dependabot)) [#1589](https://github.com/commutatus/cm-admin/pull/1589) ([@dependabot[bot]](https://github.com/apps/dependabot)) [#1590](https://github.com/commutatus/cm-admin/pull/1590) ([@dependabot[bot]](https://github.com/apps/dependabot))
### ⚠️ Breaking Changes
_No breaking changes in this release._
### 📚 Documentation
- 📖 **Associated record history guide** — Added `AssociatedRecordHistory.md` covering `CmAdmin::PaperTrailAssociations` and `paper_trail_associations` setup. [#1087](https://github.com/commutatus/cm-admin/pull/1087) ([@anbublacky](https://github.com/anbublacky))
- 📝 Updated the changelog and release listing with the v8.2.4 release notes. [dd6769432](https://github.com/commutatus/cm-admin/commit/dd6769432)
### v8.2.4 (2026-10-15)
## Catalyst v8.2.4 — October 15, 2026
### 🚀 Features
_No feature changes in this release._
### 🐛 Bug Fixes
- 🔐 **Respect record access when rendering links:** Association fields and exported “Record ID” values now render as plain text when the user cannot access the associated record. The access check also accounts for record-level policy scopes and fails closed if policy resolution encounters an error. [#1646](https://github.com/commutatus/cm-admin/pull/1646) ([@anbublacky](https://github.com/anbublacky))
### ✨ Improvements
_No improvements in this release._
### ⚠️ Breaking Changes
_No breaking changes in this release._
### 📚 Documentation
- 📝 Updated the v8.2.3 changelog. [9fc6c7064](https://github.com/commutatus/cm-admin/commit/9fc6c7064)
### v8.2.3 (2026-10-14)
## Catalyst v8.2.3 — October 14, 2026
### 🚀 Features
_No feature changes in this release._
### 🐛 Bug Fixes
_No bug fixes in this release._
### ✨ Improvements
- 💬 **Hatchbox deployment notifications:** Restored a compact Slack layout with a status title, one-line message, and action buttons. Added fallback notification text for mobile previews; failure notifications retain their visual emphasis. [#1658](https://github.com/commutatus/cm-admin/pull/1658) ([@austinjb32](https://github.com/austinjb32))
### ⚠️ Breaking Changes
_No breaking changes in this release._
### 📚 Documentation
- 📖 Updated the HatchboxScripts guide to describe the compact Slack notification format and renamed “View on Hatchbox” button. [#1658](https://github.com/commutatus/cm-admin/pull/1658) ([@austinjb32](https://github.com/austinjb32))
### v8.2.2 (2026-10-13)
## Catalyst v8.2.2
**Date:** October 13, 2026
### 🚀 Features
- 🔐 Redesigned OTP verification with six individual digit boxes, paste and autofill support, keyboard navigation, and a resend countdown for sign-in and password-reset flows. [#1656](https://github.com/commutatus/cm-admin/pull/1656) ([@Mahaveer1141](https://github.com/Mahaveer1141))
### 🐛 Bug Fixes
_No bug fixes in this release._
### ✨ Improvements
- ⌨️ OTP entry supports SMS one-time-code autofill, paste across digit boxes, and keyboard navigation. [#1656](https://github.com/commutatus/cm-admin/pull/1656) ([@Mahaveer1141](https://github.com/Mahaveer1141))
### ⚠️ Breaking Changes
_No breaking changes in this release._
### 📚 Documentation
- 📚 Updated the changelog with the v8.2.1 release notes. [f33ebeb82](https://github.com/commutatus/cm-admin/commit/f33ebeb82)
### v8.2.1 (2026-10-13)
## Catalyst v8.2.1
**Date:** October 13, 2026
### 🚀 Features
_No feature changes in this release._
### 🐛 Bug Fixes
- 🧱 Fixed show-page `card-info` grids overflowing when descriptions contain long, unbreakable content such as URLs or long IDs. Descriptions now wrap, and embedded images scale to fit their container. [#1657](https://github.com/commutatus/cm-admin/pull/1657) ([@BrijeshSajeev](https://github.com/BrijeshSajeev))
### ✨ Improvements
_No improvements in this release._
### ⚠️ Breaking Changes
_No breaking changes in this release._
### 📚 Documentation
- 📚 Updated the changelog with the v8.2.0 release notes. [98c9c7937](https://github.com/commutatus/cm-admin/commit/98c9c7937)
### v8.2.0 (2026-10-13)
## Catalyst v8.2.0
**Date:** October 13, 2026
⚠️ **WARNING: Breaking Changes** — `filter_with` scopes for `:single_select`, `:multi_select`, `:date`, and `:range` filters now receive `(value, condition)`. Update these scopes to accept the new arguments; date and range scopes now receive the raw value string instead of separate `from` and `to` values. [Upgrade guide](../5.%20Upgrade%20Guide/v8.2.0.md)
🔄 **WARNING: Migration Required** — Saved Views replace Index Preferences. Generate and run the migration to create `ct_page_views`; it drops `cm_index_preferences` without migrating its data. Back up that table first if you need to retain its data.
```bash
rails generate cm_admin:ct_page_view
rails db:migrate
```
### 🚀 Features
- 🔎 **Advanced Filters:** Build nested condition groups using AND/OR logic and field-specific operators such as `is_not`, `greater_than`, and `is_empty`. Advanced conditions work with supported select, date, and range filters. [#1637](https://github.com/commutatus/cm-admin/pull/1637) ([@Mahaveer1141](https://github.com/Mahaveer1141))
- 💾 **Saved Views:** Save and reuse per-user index configurations, including filters, sort order, visible columns, page size, and view type. Views can be duplicated, set as defaults, and shared through links. This replaces Index Preferences. [#1637](https://github.com/commutatus/cm-admin/pull/1637) ([@Mahaveer1141](https://github.com/Mahaveer1141))
### 🐛 Bug Fixes
_No bug fixes in this release._
### ✨ Improvements
- 🔗 Filter parameters are now compressed in URLs using zlib and base64url-encoded JSON. Existing nested filter hashes and plain JSON remain supported; use `CmAdmin::Utils.decode_filter_params` when reading filter parameters in application code. [#1637](https://github.com/commutatus/cm-admin/pull/1637) ([@Mahaveer1141](https://github.com/Mahaveer1141))
- 🎛️ Advanced filters support serialized and restorable filter state, dynamic operator options, and preservation of URL parameters when saved views are applied or cleared. [#1637](https://github.com/commutatus/cm-admin/pull/1637) ([@Mahaveer1141](https://github.com/Mahaveer1141))
### ⚠️ Breaking Changes
- 🛠️ **`filter_with` scope signatures changed:** Select, date, and range filter scopes now receive `(value, condition = nil)`. Date and range scopes receive the raw value string (for example, `"18 to 30"`) instead of separate `from` and `to` arguments. Update lambda scopes to accept the new signature; strict-arity lambdas can raise `ArgumentError` otherwise. Search scopes are unchanged. [#1637](https://github.com/commutatus/cm-admin/pull/1637) ([@Mahaveer1141](https://github.com/Mahaveer1141))
- 🗂️ **Index Preferences were removed:** `CmIndexPreference` and `cm_index_preferences` are replaced by Saved Views and `CtPageView`. The generated migration drops the old table, and its data is not migrated. [#1637](https://github.com/commutatus/cm-admin/pull/1637) ([@Mahaveer1141](https://github.com/Mahaveer1141))
### 📚 Documentation
- 📚 Added guides for Advanced Filters and Saved Views, expanded custom filter scope documentation, documented the `ct_page_view` generator, and added a v8.2.0 upgrade guide covering breaking changes, migration steps, and filter URL handling. [c58bc0fcd](https://github.com/commutatus/cm-admin/commit/c58bc0fcd)
### v8.1.15 (2026-10-13)
## Catalyst v8.1.15
**Date:** October 13, 2026
### 🚀 Features
_No feature changes in this release._
### 🐛 Bug Fixes
- 🔎 Single- and multi-select filters now respect `allow_empty_option: false`. Single-option filters also display the selected option’s label in the chip instead of its raw value. [#1650](https://github.com/commutatus/cm-admin/pull/1650) ([@austinjb32](https://github.com/austinjb32))
### ✨ Improvements
_No improvements in this release._
### ⚠️ Breaking Changes
_No breaking changes in this release._
### 📚 Documentation
- 📚 Updated the monthly changelog and release metadata with the v8.1.14 release notes. [e301a8196](https://github.com/commutatus/cm-admin/commit/e301a8196)
### v8.1.14 (2026-10-13)
## Catalyst v8.1.14
**Date:** October 13, 2026
### 🚀 Features
_No feature changes in this release._
### 🐛 Bug Fixes
- 🥧 Pie charts now show labels by default, with a formatter displaying each slice’s name, value, and percentage. [#1655](https://github.com/commutatus/cm-admin/pull/1655) ([@Kishore-Girivasan](https://github.com/Kishore-Girivasan))


### ✨ Improvements
_No improvements in this release._
### ⚠️ Breaking Changes
_No breaking changes in this release._
### 📚 Documentation
- 📚 Updated the monthly changelog and release metadata with v8.1.13 release notes. [`9929c567a`](https://github.com/commutatus/cm-admin/commit/9929c567a)
### v8.1.13 (2026-10-13)
## Catalyst v8.1.13
**Date:** October 13, 2026
### 🚀 Features
_No feature changes in this release._
### 🐛 Bug Fixes
- 🧭 Custom button and link actions in dropdown and popup options now navigate outside Turbo Frames, ensuring navigation loads the full page. [#1654](https://github.com/commutatus/cm-admin/pull/1654) ([@Kishore-Girivasan](https://github.com/Kishore-Girivasan))
### ✨ Improvements
_No improvements in this release._
### ⚠️ Breaking Changes
_No breaking changes in this release._
### 📚 Documentation
_No documentation changes in this release._
### v8.1.12 (2026-10-09)
## Catalyst v8.1.12 — October 9, 2026
### 🚀 Features
- 🔎 Global search now includes configured sidebar links, including nested links. Sidebar labels and model names remain searchable, and matching suggestions use configured labels and icons without duplicate URLs. [#1643](https://github.com/commutatus/cm-admin/pull/1643) ([@anbublacky](https://github.com/anbublacky))
[Watch the video explanation](https://www.loom.com/share/555c5a336bec41b8931d376028f18491)
### 🐛 Bug Fixes
_No bug fixes in this release._
### ✨ Improvements
- 📦 Consolidated 16 dependency updates across the gem, feature app, docs site, and root npm workspace. This includes Devise 4.9.4 → 5.0.4, a major-version upgrade, along with Ruby and JavaScript dependency updates. [#1651](https://github.com/commutatus/cm-admin/pull/1651) ([@anbublacky](https://github.com/anbublacky))
### ⚠️ Breaking Changes
_No breaking changes in this release._
### 📚 Documentation
- 📚 Clarified when `@policy_model` is reset during sidebar link search. [Commit `49cb47bb6`](https://github.com/commutatus/cm-admin/commit/49cb47bb6)
- 📚 Updated the docs-site changelog for v8.1.11. [Commit `a19d122ba`](https://github.com/commutatus/cm-admin/commit/a19d122ba)
### v8.1.11 (2026-10-09)
## Catalyst v8.1.11 — October 9, 2026
### 🚀 Features
_No feature changes in this release._
### 🐛 Bug Fixes
- 🐛 Updated the dashboard table widget layout so descriptions wrap beneath the heading at full width, while the sort and export buttons remain aligned beside the title. This prevents long descriptions from being clipped or crowding the action buttons. [#1647](https://github.com/commutatus/cm-admin/pull/1647) ([@Kishore-Girivasan](https://github.com/Kishore-Girivasan))
**Before**

**After**

### ✨ Improvements
- ✨ Updated the Lexxy rich text editor dependency to stable version 1.0.0, moving Catalyst from the 0.x beta series. Existing Action Text integration remains compatible. [#1649](https://github.com/commutatus/cm-admin/pull/1649) ([@barath46](https://github.com/barath46))
### ⚠️ Breaking Changes
_No breaking changes in this release._
### 📚 Documentation
_No documentation changes in this release._
### v8.1.10 (2026-10-09)
## Catalyst v8.1.10
**Date:** October 9, 2026
### 🚀 Features
_No feature changes in this release._
### 🐛 Bug Fixes
_No bug fixes in this release._
### ✨ Improvements
- 🎨 **Navigation icons** — Updated the `CmRole` and `CmCronJob` icons so they are defined consistently in the models’ admin configuration. [#1645](https://github.com/commutatus/cm-admin/pull/1645) ([@venky-21509](https://github.com/venky-21509))
### ⚠️ Breaking Changes
_No breaking changes in this release._
### 📚 Documentation
- 📖 **Changelog** — Updated the documentation changelog with the v8.1.9 release notes, covering that release’s improvements and documentation updates. [05f3e912b](https://github.com/commutatus/cm-admin/commit/05f3e912b)
### v8.1.9 (2026-10-08)
## Catalyst v8.1.9
**Date:** October 8, 2026
### 🚀 Features
_No feature changes in this release._
### 🐛 Bug Fixes
_No bug fixes in this release._
### ✨ Improvements
- 🧾 **MCP field schemas and serialization** — Improve enum handling, include `nil` for nullable enum values, and add explicit string casting and type conversion for field values. [#1644](https://github.com/commutatus/cm-admin/pull/1644) ([@Mahaveer1141](https://github.com/Mahaveer1141))
### ⚠️ Breaking Changes
_No breaking changes in this release._
### 📚 Documentation
- 📖 **Changelog** — Added the v8.1.8 release notes to the documentation changelog, summarizing that release’s features, fixes, improvements, and documentation updates. [af79b70ee](https://github.com/commutatus/cm-admin/commit/af79b70ee)
### v8.1.8 (2026-10-08)
## Catalyst v8.1.8
**Date:** October 8, 2026
### 🚀 Features
_No feature changes in this release._
### 🐛 Bug Fixes
_No bug fixes in this release._
### ✨ Improvements
- 🧮 **MCP decimal serialization** — Normalize `BigDecimal` field values to floats for JSON compatibility, including values returned through the `serializable_hash` fallback. [#1642](https://github.com/commutatus/cm-admin/pull/1642) ([@Mahaveer1141](https://github.com/Mahaveer1141))
### ⚠️ Breaking Changes
_No breaking changes in this release._
### 📚 Documentation
_No documentation changes in this release._
### v8.1.7 (2026-10-08)
## Catalyst v8.1.7
**Date:** October 8, 2026
### 🚀 Features
_No feature changes in this release._
### 🐛 Bug Fixes
_No bug fixes in this release._
### ✨ Improvements
- 🧩 **MCP field serialization** — Normalized `BigDecimal` field values to floats for JSON compatibility, including values returned through the `serializable_hash` fallback. [#1641](https://github.com/commutatus/cm-admin/pull/1641) ([@Mahaveer1141](https://github.com/Mahaveer1141))
### ⚠️ Breaking Changes
_No breaking changes in this release._
### 📚 Documentation
- 📖 **Changelog** — Added the v8.1.6 release notes to the documentation changelog, including summaries of that release’s features, fixes, improvements, and documentation updates. [1317b17d3](https://github.com/commutatus/cm-admin/commit/1317b17d3)
### v8.1.6 (2026-10-08)
## Catalyst v8.1.6
**Date:** October 8, 2026
### 🚀 Features
_No feature changes in this release._
### 🐛 Bug Fixes
_No bug fixes in this release._
### ✨ Improvements
- 🧩 **MCP field schemas** — Improved enum handling by mapping explicit enum fields to strings and using enum values in the schema. Field schemas now consistently support nullable types. [#1640](https://github.com/commutatus/cm-admin/pull/1640) ([@Mahaveer1141](https://github.com/Mahaveer1141))
### ⚠️ Breaking Changes
_No breaking changes in this release._
### 📚 Documentation
- 📖 **Changelog** — Added v8.1.5 release notes to the documentation changelog, covering its features, bug fixes, improvements, and webhook setup details. [be3427d6d](https://github.com/commutatus/cm-admin/commit/be3427d6d)
### v8.1.5 (2026-10-08)
## Catalyst v8.1.5
**Date:** October 8, 2026
### 🐛 Bug Fixes
_No bug fixes in this release._
### ✨ Improvements
- 🎫 **Support-ticket validation and migration templates** — Scoped `platform_ticket_id` uniqueness validation to the Catalyst installation, improved type checking and code consistency, and changed foreign key columns in the migration template from integer to bigint. The template change applies to migrations generated in the future; no database migration is required for this release. [#1639](https://github.com/commutatus/cm-admin/pull/1639) ([@Mahaveer1141](https://github.com/Mahaveer1141))
### 📚 Documentation
- 📖 **Changelog** — Added the v8.1.4 release notes to the documentation changelog, including feature, bug-fix, improvement, and webhook setup details. [baad7e483](https://github.com/commutatus/cm-admin/commit/baad7e483)
### ⚠️ Breaking Changes
_No breaking changes in this release._
### v8.1.4 (2026-10-08)
## Catalyst v8.1.4
**Released:** October 8, 2026
### 🚀 Features
- 🪝 **Webhook event logging and Postmark delivery tracking** — Added a webhook logging system with asynchronous processing, an admin UI for inspecting events, and Postmark delivery tracking with recipient-level statuses. Webhook payloads are stored through ActiveStorage, and events can be associated with records for debugging and auditing. [#1507](https://github.com/commutatus/cm-admin/pull/1507) ([@Kishore-Girivasan](https://github.com/Kishore-Girivasan))





- 📝 **Page descriptions in the index navbar** — Index pages can now display a page description in the top navbar, with styling for secondary text and links. [#1632](https://github.com/commutatus/cm-admin/pull/1632) ([@Kishore-Girivasan](https://github.com/Kishore-Girivasan))

### 🐛 Bug Fixes
- 🛠️ **Release workflow SIGPIPE failures** — Fixed release context collection failing when `git log` output was piped through `head`. The workflow now passes context using file paths, avoiding command-line size limits and allowing larger docs diffs. [#1638](https://github.com/commutatus/cm-admin/pull/1638) ([@austinjb32](https://github.com/austinjb32))
### ✨ Improvements
- ⚙️ **Devise OTP initialization** — Register Devise modules through `config.to_prepare`, add an autoload for `CmAdminAuthenticatable`, and avoid OTP checks when a custom authentication method is configured. [#1633](https://github.com/commutatus/cm-admin/pull/1633) ([@Mahaveer1141](https://github.com/Mahaveer1141))
- ✉️ **Email log display titles** — Email log pages now use a display title in the `[id] subject` format. [#1630](https://github.com/commutatus/cm-admin/pull/1630) ([@Kishore-Girivasan](https://github.com/Kishore-Girivasan))
- 🪝 **Webhook processing and event records** — Webhook handling now uses a generic router and service-based processing, with payload attachments, UUIDs, and event associations. [#1507](https://github.com/commutatus/cm-admin/pull/1507) ([@Kishore-Girivasan](https://github.com/Kishore-Girivasan))
### 📚 Documentation
- 📖 **Webhook setup and generator guides** — Added a webhook logging guide covering configuration, controller overrides, routing, processing, and the admin UI. Updated the generator reference with `cm_admin:add_webhook_events`, its migration details, and the ActiveStorage setup requirement. [#1636](https://github.com/commutatus/cm-admin/pull/1636) ([@Kishore-Girivasan](https://github.com/Kishore-Girivasan))
- 🧾 **Release documentation** — Updated the release changelog documentation with the current default notes-generation model. [#1638](https://github.com/commutatus/cm-admin/pull/1638) ([@austinjb32](https://github.com/austinjb32))
### ⚠️ Breaking Changes
_No breaking changes in this release._
> **Optional webhook setup:** To enable webhook event logging, generate the table and run the migration. Webhook payload storage also requires ActiveStorage to be installed and configured.
>
> ```bash
> rails generate cm_admin:add_webhook_events
> rails db:migrate
> rails active_storage:install
> ```
### v8.0.16 (2026-10-05)
## v8.0.16
**Released:** October 5, 2026
---
### ✨ Improvements
- ♻️ **OTP generation and verification logic consolidated** — The OTP flow has been refactored into a single, self-contained path on `OtpRequest`, removing the previous dependency on a `user` object. This makes OTP handling easier to reason about and reuse across email- and non-email-based delivery. — [#1626](https://github.com/commutatus/cm-admin/pull/1626) ([@Mahaveer1141](https://github.com/Mahaveer1141))
- 🧮 **New `OTP_EXPIRY_IN_MINUTES` constant** — Extracted the expiry window into a single constant, now used by both the `timed_out` and `active` scopes so expiry logic can't drift between them.
- 🏗️ **New `OtpRequest.generate!` class method** — Handles OTP creation end-to-end, including automatic cleanup of expired and active requests before generating a new one.
- ✅ **New `OtpRequest.verify?` class method** — Validates a submitted OTP and updates the relevant statuses in one call.
- 📨 **`deliver_otp` simplified** — Now always calls `send_otp` instead of branching on whether a user or a raw email was supplied.
- ✉️ **`deliver_via_email` signature updated** — Accepts the email address directly as a parameter rather than resolving it from a user.
> ℹ️ These are internal API changes. If your application overrides or calls `deliver_otp` / `deliver_via_email` directly, update the call sites to pass the email explicitly.
### 📚 Documentation
- 📝 **Changelog updated for v8.0.15** — Added the `v8.0.15` entry to `docs-site/docs/Changelog/2026-10.md`, covering the lazy-loaded dashboard widgets, the nested-forms view-toggle fix, and the env-driven Hatchbox deploy scripts (including the `deployment_alerts` breaking change and the `deploy_config:sync` migration steps). — [dc6bad29f](https://github.com/commutatus/cm-admin/commit/dc6bad29f)
- 🗂️ **Release metadata refreshed** — `docs-site/static/releases.json` updated so the new changelog entry is picked up by the docs site. — [dc6bad29f](https://github.com/commutatus/cm-admin/commit/dc6bad29f)
### v8.0.15 (2026-10-01)
## v8.0.15
**Released:** October 1, 2026
---
### 🚀 Features
_No feature changes in this release._
### 🐛 Bug Fixes
- 🩹 **Nested forms now initialize and toggle the configured view correctly** — The active state is kept in sync between the nested table and grid view controls, and nested forms initialize in grid mode when `default_view_type` is configured as `:grid`. Previously the selected grid icon did not match the rendered table layout, and switching views left the previous control active. Fixed by adding the selector attribute expected by the Stimulus view-toggle handler to both controls and applying the `grid-table` class during server-side rendering when grid is the configured default. — [#1623](https://github.com/commutatus/cm-admin/pull/1623) ([@anbublacky](https://github.com/anbublacky))
### ✨ Improvements
- 🔄 **Project workflow rules and lockfile platform entries synced** — Refreshed the project's AI workflow rules and `yarn.lock` platform-specific optional package entries. — [#1623](https://github.com/commutatus/cm-admin/pull/1623) ([@anbublacky](https://github.com/anbublacky))
### ⚠️ Breaking Changes
_No breaking changes in this release._
### 📚 Documentation
- 📝 **Changelog and release metadata** — Added the `2026-10` changelog entry, updated the changelog index, and refreshed `static/releases.json`. — [fad7db117](https://github.com/commutatus/cm-admin/commit/fad7db117)
### v8.0.14 (2026-10-01)
## v8.0.14
**Released:** October 1, 2026
---
> ⚠️ **WARNING: Breaking Changes**
>
> The `deployment_alerts` cm-admin config accessor has been **removed**. Hatchbox deployment notifications are now controlled by the `DEPLOYMENT_ALERTS` environment variable. Any initializer that sets `config.deployment_alerts` will raise `NoMethodError` and must be updated.
> 🔄 **WARNING: Migration Required**
>
> The `.hatchbox` deploy scripts are now fully environment-driven, so the `deploy-config/` directory checksum has changed. Sync the updated config and set the new environment variables before your next deploy.
>
> bash
> bundle update cm-admin
> rake cm_admin:deploy_config:sync
>
>
> Then **commit the updated `deploy-config/` folder** before deploying — Hatchbox picks it up from git, not from a boot-time copy. Also set `APP_NAME` and `APP_URL` (and optionally `DEPLOYMENT_ALERTS=false`) in your Hatchbox environment variables.
---
### 🚀 Features
_No feature changes in this release._
### 🐛 Bug Fixes
- 🩹 **Misconfigured environments no longer send broken deploy notifications** — Previously a missing app name or URL produced a Slack notification with a blank app name and broken buttons. The scripts now detect blank required values (`APP_NAME`, `APP_URL`, `SLACK_WEBHOOK_URL`) as soon as the config loads and post a single, explicit "config not available" notice naming the Hatchbox app, environment, and server, then exit cleanly. — [#1608](https://github.com/commutatus/cm-admin/pull/1608) ([@austinjb32](https://github.com/austinjb32))
### ✨ Improvements
- 🔧 **Hatchbox deploy scripts are now fully env-driven** — `app_name`, `app_url`, `env_name`, `slack_webhook`, `deployment_alerts?`, and the new `server_name` are read from environment variables instead of `CmAdmin.config`, `Rails.env`, or app credentials. This decouples the copied `.hatchbox` scripts from gem internals and removes their dependency on values like the `be_url` credential and `project_name`. — [#1608](https://github.com/commutatus/cm-admin/pull/1608) ([@austinjb32](https://github.com/austinjb32))
- 🐚 **Deploy hooks run under plain `ruby`** — Hook scripts no longer boot Rails, and they source `~/.asdf-vars` when present so `HATCHBOX_SERVER_NAME` / `HATCHBOX_SERVER_ID` are available per Hatchbox docs. — [#1608](https://github.com/commutatus/cm-admin/pull/1608) ([@austinjb32](https://github.com/austinjb32))
- ⏱️ **HTTP timeouts on the Slack POST** — Open/read timeouts were added so a hung webhook can no longer stall a deploy step. — [#1608](https://github.com/commutatus/cm-admin/pull/1608) ([@austinjb32](https://github.com/austinjb32))
- 💬 **Clearer config-unavailable notification** — The notice now includes environment and server lines plus an **Environment Variables** button that deep-links to the app's Hatchbox env-vars page (`https://hatchbox.io/apps//env_vars`). — [#1608](https://github.com/commutatus/cm-admin/pull/1608) ([@austinjb32](https://github.com/austinjb32))
- 🪵 **Simplified notification script error handling and logging** — Reduced branching and clearer log output in `hatchbox_notification.rb`. — [#1608](https://github.com/commutatus/cm-admin/pull/1608) ([@austinjb32](https://github.com/austinjb32))
- 🧹 **Removed duplicate deployment config directory** — Cleaned up a redundant `deploy-config/` copy in the gem source. — [#1608](https://github.com/commutatus/cm-admin/pull/1608) ([@austinjb32](https://github.com/austinjb32))
### ⚠️ Breaking Changes
- 💥 **`CmAdmin.config.deployment_alerts` removed** — The toggle is now the `DEPLOYMENT_ALERTS` environment variable. Remove the accessor from your initializer:
ruby
# config/initializers/cm_admin.rb — DELETE THIS
CmAdmin.configure do |config|
config.deployment_alerts = false
end
and instead set it in your Hatchbox environment variables:
DEPLOYMENT_ALERTS=false
— [#1608](https://github.com/commutatus/cm-admin/pull/1608) ([@austinjb32](https://github.com/austinjb32))
### 📚 Documentation
- 📘 **`4. Deployment/HatchboxScripts.md` rewritten** — Documents the new environment-variable table (`APP_NAME`, `APP_URL`, `SLACK_WEBHOOK_URL`, `HATCHBOX_APP_ID` + `LOG_ID`, `HATCHBOX_SERVER_NAME`, `RAILS_ENV`, `DEPLOYMENT_ALERTS`), the env-driven `lib/config.rb`, the **Config Not Available** flow, the updated `ruby`-based file layout, and how to skip notifications via `DEPLOYMENT_ALERTS=false`. — [#1608](https://github.com/commutatus/cm-admin/pull/1608) ([@austinjb32](https://github.com/austinjb32))
- 📘 **`3. Features/CmAdminConfiguration.md` updated** — Removed the `deployment_alerts` config row and its section, replacing it with a pointer to the `DEPLOYMENT_ALERTS` environment variable. — [#1608](https://github.com/commutatus/cm-admin/pull/1608) ([@austinjb32](https://github.com/austinjb32))
- 📝 **Changelog and release metadata** — Added the `2026-10` changelog entry, updated the changelog index, and refreshed `static/releases.json`. — [d5b1f948d](https://github.com/commutatus/cm-admin/commit/d5b1f948d)
### v8.0.13 (2026-10-01)
## Catalyst v8.0.13 — October 1, 2026
### 🚀 Features
- ⚡ **Lazy-loaded dashboard widgets via Turbo Frames** — Dashboard pages now render instantly with skeleton placeholders, and each widget (chart, metric card, table) fetches its content asynchronously from a dedicated endpoint (`GET /:dashboard_name/widget/:widget_index`). Adds a `cm_dashboard_widget` controller action, `collect_dashboard_widgets` / `collect_section_widgets` / `widget_name` view helpers, a new `_dashboard_widget` partial, and a `.cm-dashboard-skeleton` pulse animation. Section widgets on show pages remain backward compatible. [#1579](https://github.com/commutatus/cm-admin/pull/1579) ([@austinjb32](https://github.com/austinjb32))

### 🐛 Bug Fixes
- 🧾 **Release-notes generation** — Raised the OpenRouter output budget and capped per-section prompt input (git log, PR bodies, docs diff) so large releases no longer truncate the JSON response and silently fall back to the raw git log. [#1620](https://github.com/commutatus/cm-admin/pull/1620) ([@austinjb32](https://github.com/austinjb32))
### ✨ Improvements
- 💬 **Slack release notifications** — Restructured the release announcement into a Block Kit layout: a `✨ Highlights` paragraph, an always-present `⚠️ Breaking Changes` section, a `📋 Details` bullet list with PR links, renamed action buttons, and a release-date footer. [#1622](https://github.com/commutatus/cm-admin/pull/1622) ([@austinjb32](https://github.com/austinjb32))
### ⚠️ Breaking Changes
_No breaking changes in this release._
### 📚 Documentation
- 📝 Updated the changelog with the v8.0.12 release notes. [02a809040](https://github.com/commutatus/cm-admin/commit/02a809040)
---
## Release Notes — November 2026
### v9.0.1 (2026-11-06)
## Catalyst v9.0.1 — 2026-11-06
🔄 **WARNING: Migration Required** — The optional User Groups feature needs its tables created before use. Installations that do not enable User Groups do not need to run these commands.
```sh
bin/rails generate cm_admin:install_user_groups
bin/rails db:migrate
```
### 🚀 Features
- 🚀 Added optional User Groups support for role selection on user forms. Admins can group roles, and user form role dropdowns can filter to the selected group. The feature is disabled by default. [#1807](https://github.com/commutatus/cm-admin/pull/1807) ([#Kishore-Girivasan](https://github.com/Kishore-Girivasan))
### 🐛 Bug Fixes
- 🐛 Fixed switch fields so string values such as `"1"` and `"true"` render as checked. [#1807](https://github.com/commutatus/cm-admin/pull/1807) ([#Kishore-Girivasan](https://github.com/Kishore-Girivasan))
- 🐛 Corrected the unique constraint syntax in the User Groups migration. [#1807](https://github.com/commutatus/cm-admin/pull/1807) ([#Kishore-Girivasan](https://github.com/Kishore-Girivasan))
### ✨ Improvements
- ✨ Added the `cm_admin:install_user_groups` generator to create the User Groups tables and seed the default setting when opting into the feature. [#1807](https://github.com/commutatus/cm-admin/pull/1807) ([#Kishore-Girivasan](https://github.com/Kishore-Girivasan))
### ⚠️ Breaking Changes
No breaking changes in this release.
### 📚 Documentation
- 📚 Renumbered the platform-settings upgrade guide from v8.6.0 to v9.0.0 and updated the upgrade-guide index. [#1825](https://github.com/commutatus/cm-admin/pull/1825) ([#austinjb32](https://github.com/austinjb32))
- 📚 Expanded contributor and agent guidance with the local lint and GitHub signoff process, contribution workflow, and Stylelint instructions. [#1800](https://github.com/commutatus/cm-admin/pull/1800) ([#app/devin-ai-integration](https://github.com/app/devin-ai-integration))
- 📚 Added the v9.0.0 entry to the November 2026 changelog and updated the release registry. [94b23459a](https://github.com/commutatus/cm-admin/commit/94b23459a)
### v9.0.0 (2026-11-05)
## Catalyst v9.0.0 — 2026-11-05
> ⚠️ **WARNING: Breaking Changes** — Project settings are now separate from platform settings, and `cm_custom_method` has been renamed to `cm_custom_action`. Move project values into `config/application.rb` and update affected references. No database migration or rake task is required.
### 🚀 Features
- 🚀 Adopted `ar_lazy_preload` for admin index and filtering associations. Associations are batch-loaded on first access rather than eagerly at query time, while preserving N+1 protection. [#1764](https://github.com/commutatus/cm-admin/pull/1764) ([#austinjb32](https://github.com/austinjb32))
### 🐛 Bug Fixes
- 🐛 Regenerated stale root and Rails 8.0/8.1 appraisal lockfiles so frozen Bundler installs can resolve the current gem version and dependencies. [#1823](https://github.com/commutatus/cm-admin/pull/1823) ([#austinjb32](https://github.com/austinjb32))
### ✨ Improvements
- ✨ Platform settings now use a `Rails.cache`-backed reader, with cache invalidation on changes so updates apply across processes without a restart. Missing settings raise `CmAdmin::PlatformSettings::NotFound`, and `fetch` supports explicit fallbacks. [#1803](https://github.com/commutatus/cm-admin/pull/1803) ([#mikevic](https://github.com/mikevic))
- 🛡️ Reworked MCP authentication and tool registration around a per-request Rails controller flow and class-based `MCP::Tool` tools. Authorization now fails closed when a policy scope is missing. [#1805](https://github.com/commutatus/cm-admin/pull/1805) ([#Mahaveer1141](https://github.com/Mahaveer1141))
- ✨ MCP tools now set the current user's permissions before each tool execution, keeping permission setup with authorization logic. [#1824](https://github.com/commutatus/cm-admin/pull/1824) ([#Mahaveer1141](https://github.com/Mahaveer1141))
- ♻️ Refactored resource-controller request handling into declarative action services and shared helpers, making response behavior more explicit and testable. [#1818](https://github.com/commutatus/cm-admin/pull/1818) ([#Mahaveer1141](https://github.com/Mahaveer1141)) [#1801](https://github.com/commutatus/cm-admin/pull/1801) ([#mikevic](https://github.com/mikevic))
### ⚠️ Breaking Changes
- ⚠️ Project settings are no longer overridden by platform-setting rows. Move any admin-managed project name, slug, logo URL, favicon URL, or default email into `config/application.rb`. The old `PLATFORM_SETTINGS` constant is deprecated; file settings now return a path string rather than a lambda, and missing slugs raise an error instead of returning `nil`. Replace `cm_custom_method` references with `cm_custom_action`. [#1803](https://github.com/commutatus/cm-admin/pull/1803) ([#mikevic](https://github.com/mikevic)) [#1818](https://github.com/commutatus/cm-admin/pull/1818) ([#Mahaveer1141](https://github.com/Mahaveer1141))
To preserve project values, set the relevant values in `config/application.rb`, for example:
```ruby
config.x.project_settings.name = "Acme"
config.x.project_settings.slug = "acme"
config.x.project_settings.logo_url = "https://cdn.example.com/logo.png"
config.x.project_settings.favicon_url = "https://cdn.example.com/favicon.png"
config.x.project_settings.default_from_email = "no-reply@example.com"
```
If legacy project-setting rows exist, remove them from a Rails console:
```ruby
CmPlatformSetting.where(slug: %w[project-name project-slug logo-url favicon-url default-email]).destroy_all
```
Replace `PLATFORM_SETTINGS[...]` with `CmAdmin::PlatformSettings[...]` or `.fetch(slug, default)` where a fallback is expected. Remove `.call` from file-setting access, and remove calls to `CmPlatformSetting.sync_project_settings_to_config`. No database migration or rake task is required.
### 📚 Documentation
- 📚 Updated the DSL guide to explain lazy association preloading and the optional `ArLazyPreload.config.auto_preload = true` initializer setting. [#1764](https://github.com/commutatus/cm-admin/pull/1764) ([#austinjb32](https://github.com/austinjb32))
- 📚 Updated MCP documentation with the controller-based authentication and tool architecture, Rails console tool usage, and the fields returned by list and get tools. [#1805](https://github.com/commutatus/cm-admin/pull/1805) ([#Mahaveer1141](https://github.com/Mahaveer1141))
- 📚 Added platform-settings guidance covering the distinction between project and platform settings, cache behavior, lookup methods, fallbacks, and file-setting paths. [#1803](https://github.com/commutatus/cm-admin/pull/1803) ([#mikevic](https://github.com/mikevic))
- 📚 Updated auditing documentation to use the renamed `cm_custom_action` method. [#1818](https://github.com/commutatus/cm-admin/pull/1818) ([#Mahaveer1141](https://github.com/Mahaveer1141))
### v8.5.4 (2026-11-05)
## Catalyst v8.5.4 — 2026-11-05
> ⚠️ **WARNING: Breaking Changes** — Platform settings are now separate from project settings, and `cm_custom_method` has been renamed to `cm_custom_action`. Move project values into `config/application.rb`, update references to the renamed action and deprecated settings API, and remove obsolete project-setting rows as described below. No database migration or rake task is required.
### 🚀 Features
- 🚀 Adopted `ar_lazy_preload` for admin index and filtering associations. Associations are batch-loaded on first access rather than eagerly at query time, while preserving N+1 protection. [#1764](https://github.com/commutatus/cm-admin/pull/1764) ([#austinjb32](https://github.com/austinjb32))
### 🐛 Bug Fixes
- 🐛 Regenerated stale root and Rails 8.0/8.1 appraisal lockfiles so frozen Bundler installs can resolve the current gem version and dependencies. [#1823](https://github.com/commutatus/cm-admin/pull/1823) ([#austinjb32](https://github.com/austinjb32))
### ✨ Improvements
- ✨ Platform settings now use a `Rails.cache`-backed reader, with cache invalidation on changes so updates apply across processes without a restart. Missing settings raise `CmAdmin::PlatformSettings::NotFound`, and `fetch` supports explicit fallbacks. [#1803](https://github.com/commutatus/cm-admin/pull/1803) ([#mikevic](https://github.com/mikevic))
- 🛡️ Reworked MCP authentication and tool registration around a per-request Rails controller flow and class-based `MCP::Tool` tools. Authorization now fails closed when a policy scope is missing. [#1805](https://github.com/commutatus/cm-admin/pull/1805) ([#Mahaveer1141](https://github.com/Mahaveer1141))
- ✨ MCP tools now set the current user's permissions before each tool execution, keeping permission setup with authorization logic. [#1824](https://github.com/commutatus/cm-admin/pull/1824) ([#Mahaveer1141](https://github.com/Mahaveer1141))
- ♻️ Refactored resource-controller request handling into declarative action services and shared helpers, making response behavior more explicit and testable. [#1818](https://github.com/commutatus/cm-admin/pull/1818) ([#Mahaveer1141](https://github.com/Mahaveer1141)) [#1801](https://github.com/commutatus/cm-admin/pull/1801) ([#mikevic](https://github.com/mikevic))
### ⚠️ Breaking Changes
- ⚠️ Project settings are no longer overridden by platform-setting rows. Move any admin-managed project name, slug, logo URL, favicon URL, or default email into `config/application.rb`. The old `PLATFORM_SETTINGS` constant is deprecated; file settings now return a path string rather than a lambda, and missing slugs raise an error instead of returning `nil`. Replace `cm_custom_method` references with `cm_custom_action`. [#1803](https://github.com/commutatus/cm-admin/pull/1803) ([#mikevic](https://github.com/mikevic)) [#1818](https://github.com/commutatus/cm-admin/pull/1818) ([#Mahaveer1141](https://github.com/Mahaveer1141))
To preserve project values, set the relevant values in `config/application.rb`, for example:
```ruby
config.x.project_settings.name = "Acme"
config.x.project_settings.slug = "acme"
config.x.project_settings.logo_url = "https://cdn.example.com/logo.png"
config.x.project_settings.favicon_url = "https://cdn.example.com/favicon.png"
config.x.project_settings.default_from_email = "no-reply@example.com"
```
If the legacy rows exist, remove them from a Rails console:
```ruby
CmPlatformSetting.where(slug: %w[project-name project-slug logo-url favicon-url default-email]).destroy_all
```
Replace `PLATFORM_SETTINGS[...]` with `CmAdmin::PlatformSettings[...]` or `.fetch(slug, default)` where a fallback is expected. Remove `.call` from file-setting access, and remove calls to `CmPlatformSetting.sync_project_settings_to_config`. No migration or rake command is required.
### 📚 Documentation
- 📚 Updated the DSL guide to explain lazy association preloading and the optional `ArLazyPreload.config.auto_preload = true` initializer setting. [#1764](https://github.com/commutatus/cm-admin/pull/1764) ([#austinjb32](https://github.com/austinjb32))
- 📚 Updated MCP documentation with the controller-based authentication and tool architecture, Rails console tool usage, and the fields returned by list and get tools. [#1805](https://github.com/commutatus/cm-admin/pull/1805) ([#Mahaveer1141](https://github.com/Mahaveer1141))
- 📚 Added platform-settings guidance covering the distinction between project and platform settings, cache behavior, lookup methods, fallbacks, and file-setting paths. [#1803](https://github.com/commutatus/cm-admin/pull/1803) ([#mikevic](https://github.com/mikevic))
- 📚 Updated auditing documentation to use the renamed `cm_custom_action` method. [#1818](https://github.com/commutatus/cm-admin/pull/1818) ([#Mahaveer1141](https://github.com/Mahaveer1141))
### v8.5.3 (2026-11-04)
## Catalyst v8.5.3 — 2026-11-04
### 🚀 Features
- 🚀 File fields now support Active Storage direct uploads for `single_file_upload` and `multi_file_upload`. Files upload directly to the storage service, with per-file progress feedback, while preserving normal form-upload behavior when direct uploads are unavailable. [#1814](https://github.com/commutatus/cm-admin/pull/1814) ([#austinjb32](https://github.com/austinjb32))
### 🐛 Bug Fixes
- 🐛 Restored appends to the engine’s `eager_load_paths` in the initializer. [dfc4635](https://github.com/commutatus/cm-admin/commit/dfc46355b)
### ✨ Improvements
No additional improvements in this release.
### ⚠️ Breaking Changes
No breaking changes in this release.
### 📚 Documentation
- 📚 Updated review-app deployment guidance to note that the Tigris bucket needs CORS rules for browser-based direct uploads, and updated the November changelog. [9ebd550](https://github.com/commutatus/cm-admin/commit/9ebd550de)
### v8.5.2 (2026-11-04)
## Catalyst v8.5.2 — 2026-11-04
### 🚀 Features
- 🚀 Large index pages can use PostgreSQL planner row estimates instead of running an exact count query. Estimated totals display a `+` suffix, and `fast_count` is available on ActiveRecord models and relations. [#1767](https://github.com/commutatus/cm-admin/pull/1767) ([#austinjb32](https://github.com/austinjb32))
- 🚀 File fields now support Active Storage direct uploads, with per-file progress and previews, so large files can upload outside the form submission request. [#1745](https://github.com/commutatus/cm-admin/pull/1745) ([#austinjb32](https://github.com/austinjb32))
### 🐛 Bug Fixes
- 🐛 Aborted direct uploads now reject with `AbortError` and prevent pending requests from being sent, avoiding uploads that otherwise remained unsettled or continued after cancellation. [#1813](https://github.com/commutatus/cm-admin/pull/1813) ([#mikevic](https://github.com/mikevic))
- 🐛 Required single- and multi-file upload fields now validate correctly when no file is selected, including nested form fields. [#1808](https://github.com/commutatus/cm-admin/pull/1808) ([#BrijeshSajeev](https://github.com/BrijeshSajeev))
- 🐛 Improved direct-upload handling across review-app domains and chat uploads, including URL resolution and upload guards. [1bcc808](https://github.com/commutatus/cm-admin/commit/1bcc80872) · [d6583d7](https://github.com/commutatus/cm-admin/commit/d6583d77a) · [b38d8e8](https://github.com/commutatus/cm-admin/commit/b38d8e867)
- 🐛 Added a tooltip during form submission. [f3cea83](https://github.com/commutatus/cm-admin/commit/f3cea8324)
### ✨ Improvements
- ✨ Updated Bootstrap JavaScript, Popper, and vendored SCSS to compatible Bootstrap 5.3.8 versions, retaining Catalyst’s existing SCSS customizations. [#1804](https://github.com/commutatus/cm-admin/pull/1804) ([#mikevic](https://github.com/mikevic))
- ✨ File uploads now block form submission while uploading and are aborted when the drawer closes. [77c65d5](https://github.com/commutatus/cm-admin/commit/77c65d5bb)
### ⚠️ Breaking Changes
No breaking changes in this release.
### 📚 Documentation
- 📚 Documented the `count_threshold` pagination setting and `fast_count`, including configuration examples, PostgreSQL behavior, and how to disable estimates. [#1767](https://github.com/commutatus/cm-admin/pull/1767) ([#austinjb32](https://github.com/austinjb32))
- 📚 Updated the November changelog with the v8.5.1 release notes. [24f1883](https://github.com/commutatus/cm-admin/commit/24f188323)
### v8.5.1 (2026-11-02)
## Catalyst v8.5.1 — 2026-11-02
### 🚀 Features
- 🚀 Added a `:string` filter for text columns, with `is`, `is_not`, `contains`, `does_not_contain`, `is_empty`, and `is_not_empty` conditions. String matches are case-insensitive, treat wildcard characters literally, and support custom filter scopes. [#1751](https://github.com/commutatus/cm-admin/pull/1751) ([#anbublacky](https://github.com/anbublacky))
### 🐛 Bug Fixes
- 🐛 Corrected default-value handling for `:range` filters and made empty dropdown selections behave as no filter. [#1751](https://github.com/commutatus/cm-admin/pull/1751) ([#anbublacky](https://github.com/anbublacky))
### ✨ Improvements
- ✨ Standardized filter popup behavior: multi-select, range, and string filters now apply changes with the Apply button, Enter, or when the popup closes. Clear and Apply actions are shared across these popups. [#1751](https://github.com/commutatus/cm-admin/pull/1751) ([#anbublacky](https://github.com/anbublacky))
- ✨ String filter conditions handle empty strings and `NULL` rows as appropriate, with parameterized, case-insensitive matching. [#1751](https://github.com/commutatus/cm-admin/pull/1751) ([#anbublacky](https://github.com/anbublacky))
### ⚠️ Breaking Changes
No breaking changes in this release.
### 📚 Documentation
- 📚 Updated the filter DSL reference and Advanced Filters and Custom Filter Method guides to document the `:string` type, its supported conditions, and custom-scope usage. [#1751](https://github.com/commutatus/cm-admin/pull/1751) ([#anbublacky](https://github.com/anbublacky))
### v8.5.0 (2026-11-02)
## Catalyst v8.5.0 — 2026-11-02
> ⚠️ **WARNING: Breaking Changes** — API token status is now derived from `expires_at`, and the `api_tokens.status` database column is removed. Generate and run the migration, and update custom `ApiToken` models to derive status from `expires_at`.
>
> 🔄 **WARNING: Migration Required** — Run these commands to generate and apply the migration:
> ```bash
> rails generate cm_admin:remove_status_from_api_token
> rails db:migrate
> ```
### 🚀 Features
- 🔐 API tokens now respect `expires_at` for authentication and are reported as expired as soon as their expiry passes. Admins can set or extend token validity and expire live tokens from the API Token page. [#1715](https://github.com/commutatus/cm-admin/pull/1715) ([#anbublacky](https://github.com/anbublacky))
### 🐛 Bug Fixes
- 🛡️ Token verification now rejects tokens whose expiry has passed, without depending on a background task to update a stored status. [#1715](https://github.com/commutatus/cm-admin/pull/1715) ([#anbublacky](https://github.com/anbublacky))
### ✨ Improvements
- ✨ `expires_at` is now the single source of truth for token expiry. The `status`, `live?`, `expired?`, `live` scope, and `expired` scope are derived from it, keeping model behavior and database queries consistent. [#1787](https://github.com/commutatus/cm-admin/pull/1787) ([#mikevic](https://github.com/mikevic))
- ✨ The API Token “Expire” action now updates only `expires_at`; expiry no longer needs a status-sync rake task or callbacks. [#1787](https://github.com/commutatus/cm-admin/pull/1787) ([#mikevic](https://github.com/mikevic))
### ⚠️ Breaking Changes
- ⚠️ Removed the `api_tokens.status` column. Use the `cm_admin:remove_status_from_api_token` generator, apply its migration, and update any custom `ApiToken` model to derive status and expiry scopes from `expires_at`. The migration preserves manually expired tokens before dropping the column. [#1787](https://github.com/commutatus/cm-admin/pull/1787) ([#mikevic](https://github.com/mikevic))
### 📚 Documentation
- 📚 Updated the MCP setup example to omit the removed `status` column and use an expiry-based `live` scope. Added generator documentation for removing API token status, including migration behavior and the model changes apps need to make. [#1787](https://github.com/commutatus/cm-admin/pull/1787) ([#mikevic](https://github.com/mikevic))
### v8.4.3 (2026-11-02)
## Catalyst v8.4.3 — 2026-11-02
### 🚀 Features
- 🚀 Added job listings and an application flow with job-specific questions, answer validation, and safeguards against duplicate applications. [#1779](https://github.com/commutatus/cm-admin/pull/1779) ([#BrijeshSajeev](https://github.com/BrijeshSajeev))
- 🚀 Added selected-record counts and refreshed bulk-action controls on index and associated-index tables, with counts updating as selections change. [#1753](https://github.com/commutatus/cm-admin/pull/1753) ([#anbublacky](https://github.com/anbublacky))
### 🐛 Bug Fixes
- 🐛 Bulk-action modals now show success and error banners together for partial success. Error details are escaped, failure counts are accurate, and custom error bodies replace the default message as intended. [#1718](https://github.com/commutatus/cm-admin/pull/1718) ([#anbublacky](https://github.com/anbublacky))
- 🐛 Fixed fresh Rails 8.1 PostgreSQL app installation: generated migrations now create the expected tables with unique versions, and the installer completes database setup before copying the initializer. [#1754](https://github.com/commutatus/cm-admin/pull/1754) ([#mikevic](https://github.com/mikevic))
- 🐛 Fixed filter dropdown errors by pinning Popper to its production ESM build and removing the global `window.process` shim. [#1798](https://github.com/commutatus/cm-admin/pull/1798) ([#mikevic](https://github.com/mikevic))
### ✨ Improvements
- 🛡️ Permission scopes are now enforced for record mutations as well as reads. Out-of-scope records cannot be changed through member actions, and create/update operations are checked against the applicable scope. [#1775](https://github.com/commutatus/cm-admin/pull/1775) ([#BrijeshSajeev](https://github.com/BrijeshSajeev))
- ✨ Added the `ct_path` helper to centralize per-model admin route generation and replace repeated dynamic route-helper calls. [#1799](https://github.com/commutatus/cm-admin/pull/1799) ([#mikevic](https://github.com/mikevic))
- ✨ Consolidated repeated controller model lookups into a memoized helper without changing behavior. [#1797](https://github.com/commutatus/cm-admin/pull/1797) ([#mikevic](https://github.com/mikevic))
- ✨ Aligned test and review-app Ruby versions with the repository’s Ruby version file, updated the test workflow’s Node version source, and upgraded GitHub cache actions to v6. [#1773](https://github.com/commutatus/cm-admin/pull/1773) ([#mikevic](https://github.com/mikevic)) [#1790](https://github.com/commutatus/cm-admin/pull/1790) ([#mikevic](https://github.com/mikevic)) [#1789](https://github.com/commutatus/cm-admin/pull/1789) ([#mikevic](https://github.com/mikevic))
- ✨ Added Claude Code session setup and edit-formatting hooks, and a command allowlist to make cloud development and routine checks easier. [#1791](https://github.com/commutatus/cm-admin/pull/1791) ([#mikevic](https://github.com/mikevic)) [#1793](https://github.com/commutatus/cm-admin/pull/1793) ([#mikevic](https://github.com/mikevic))
- ✨ Added `bin/ci` to run local lint checks and support the required signoff process. [#1755](https://github.com/commutatus/cm-admin/pull/1755) ([#BrijeshSajeev](https://github.com/BrijeshSajeev))
### ⚠️ Breaking Changes
_No breaking changes._
### 📚 Documentation
- 📚 Added a repository setup and testing guide for agents, documented signoff and linting steps, corrected stale workflow and spec references, and added a `CLAUDE.md` pointer to `AGENTS.md`. [#1772](https://github.com/commutatus/cm-admin/pull/1772) ([#git-pranavr](https://github.com/git-pranavr)) [#1785](https://github.com/commutatus/cm-admin/pull/1785) ([#mikevic](https://github.com/mikevic)) [#1786](https://github.com/commutatus/cm-admin/pull/1786) ([#mikevic](https://github.com/mikevic)) [#1784](https://github.com/commutatus/cm-admin/pull/1784) ([#mikevic](https://github.com/mikevic)) [#1792](https://github.com/commutatus/cm-admin/pull/1792) ([#mikevic](https://github.com/mikevic))
- 📚 Updated the installer documentation to list the tables it creates, and updated the October 2026 changelog with the v8.4.2 release notes. [#1754](https://github.com/commutatus/cm-admin/pull/1754) ([#mikevic](https://github.com/mikevic)) [decf0f8c0](https://github.com/commutatus/cm-admin/commit/decf0f8c0)
---
## Changelog
Monthly release notes for Catalyst gem. Each page covers releases within that month, with AI-generated summaries of features, bug fixes, and improvements.
### Recent Months
- [November 2026](./Changelog/2026-11)
- [October 2026](./Changelog/2026-10)
- [September 2026](./Changelog/2026-09)
---
## CM Admin Generators
CM Admin provides a set of Rails generators to help you quickly set up and extend your admin panel functionality.
### Overview
Generators automate the setup process by creating necessary migrations, configurations, and boilerplate code. They save time and ensure consistency across projects.
### Quick Reference
| Command | Description |
| -------------------------------------------------------------- | --------------------------------------------------------------------------- |
| `rails generate cm_admin:install` | Initial setup - creates migrations, initializer, and mounts the engine |
| `rails generate cm_admin:install_role` | Sets up role-based access control with permissions |
| `rails generate cm_admin:add_authentication` | Adds Devise authentication if no auth system exists |
| `rails generate cm_admin:add_graphql` | Installs GraphQL API with types, mutations, and queries |
| `rails generate cm_admin:add_scheduler` | Creates cron job and job log tables for scheduled tasks |
| `rails generate cm_admin:add_comments_with_mentions` | Adds commenting system with @mentions support |
| `rails generate cm_admin:add_cm_support_ticket` | Sets up support ticket system for customer requests |
| `rails generate cm_admin:add_cm_platform_setting` | Creates platform settings table for app configuration |
| `rails generate cm_admin:add_cm_index_preference` | Adds user-customizable column preferences for lists |
| `rails generate cm_admin:add_cron_job_notifications` | Creates notification system for cron job alerts |
| `rails generate cm_admin:add_prompts_with_constants` | Adds Lexxy rich text templates with category support |
| `rails generate cm_admin:add_token_type_to_api_token` | Migration to add token types to API tokens |
| `rails generate cm_admin:add_name_to_api_token` | Migration to add a name field to API tokens |
| `rails generate cm_admin:remove_status_from_api_token` | Drops api_tokens.status; expiry is derived from expires_at |
| `rails generate cm_admin:add_unique_index_to_permissions` | Migration to prevent duplicate permissions |
| `rails generate cm_admin:file_import_improvement` | Migration to add tracking columns to file imports |
| `rails generate cm_admin:file_export_improvement` | Migration to upgrade file export functionality |
| `rails generate cm_admin:add_audit_metadata_to_versions` | Adds action_type and action_name columns to PaperTrail versions |
| `rails generate cm_admin:add_otp_requests` | Creates otp_requests table for OTP-based authentication |
| `rails generate cm_admin:add_email_logs` | Creates the cm_email_logs table for recording CmAdmin.send_email deliveries |
| `rails generate cm_admin:rename_file_import_and_export_tables` | Rename file_imports and file_exports tables |
| `rails generate cm_admin:add_webhook_events` | Creates the cm_webhook_events table for webhook logging |
| `rails generate cm_admin:add_webhook_event_recipient` | Adds recipient link and original_link columns to cm_webhook_events |
| `rails generate cm_admin:ct_page_view` | Creates ct_page_views table for Saved Views (replaces index preferences) |
| `rails generate cm_admin:add_bot_conversations` | Creates the tables for the Catalyst AI (CT Bot) chat feature |
| `rails generate cm_admin:add_bot_transcriptions` | Creates the ct_bot_transcriptions table for Catalyst AI voice input |
---
### Core Generators
#### 1. Install Generator
**Command:**
```bash
rails generate cm_admin:install
```
**What it does:**
- Installs Action Text for rich text editing
- Creates the CM Admin initializer (`config/initializers/cm_admin.rb`)
- Sets up custom JavaScript and CSS files
- Creates the application policy for authorization
- Mounts the CM Admin engine in routes
- Creates migrations for the `ct_file_imports`, `ct_file_exports` and `cm_index_preferences` tables
- Installs i18n ActiveRecord for translations
- Runs database migrations
**Why you need it:**
This is the **essential first step** when setting up CM Admin. It creates the foundation that all other features depend on.
---
#### 2. Install Role Generator
**Command:**
```bash
rails generate cm_admin:install_role
```
**What it does:**
- Creates migration for CmRole (roles table)
- Creates migration for CmPermission (permissions table)
- Creates a default "Admin" role
- Automatically assigns all available permissions to the Admin role for all configured models
**Why you need it:**
Enable **role-based access control (RBAC)** in your admin panel. This allows you to define different user roles with specific permissions.
---
#### 3. Add Authentication Generator
**Command:**
```bash
rails generate cm_admin:add_authentication
```
**What it does:**
- Adds Devise gem to your Gemfile
- Installs and configures Devise
- Prompts for the user model name (defaults to "user")
- Creates the user model with Devise
- Sets up application controller with authentication
- Creates authentication concern
- Creates Current model for thread-safe context
- Adds `super_admin?` method to the user model
**Why you need it:**
Use this when your application **doesn't have an authentication system** yet. It quickly sets up Devise-based authentication with CM Admin integration.
---
### Feature Generators
#### 4. Add GraphQL Generator
**Command:**
```bash
rails generate cm_admin:add_graphql
```
**What it does:**
- Adds GraphQL-related gems (graphql, graphql-errors, graphql-rails_logger)
- Installs GraphQL Rails
- Creates GraphQL schema
- Sets up type directories (inputs, enums, objects)
- Creates base mutation and query classes
- Sets up model concerns
- Creates exception handling
**Why you need it:**
Enable **GraphQL API** for your application. Useful when you need a flexible API for external integrations or modern frontend frameworks.
---
#### 5. Add Scheduler Generator
**Command:**
```bash
rails generate cm_admin:add_scheduler
```
**What it does:**
- Creates migration for CmCronJob (scheduled jobs)
- Creates migration for CmCronJobLog (job execution logs)
**Next steps:**
- Add `CmCronJob, CmCronJobLog` to `config.included_models` in `cm_admin.rb`
- Run `rails db:migrate`
- Start Sidekiq for background job processing
**Why you need it:**
Add **cron job management** to your admin panel. Schedule and monitor recurring background jobs directly from the admin interface.
---
#### 6. Add Comments with Mentions Generator
**Command:**
```bash
rails generate cm_admin:add_comments_with_mentions
```
**What it does:**
- Creates migration for CmComment (comments table)
- Creates migration for CmUserMentions (user mentions in comments)
**Next steps:**
- Run `rails db:migrate`
**Why you need it:**
Enable **commenting with @mentions** functionality. Users can comment on records and tag other users with @mentions for notifications.
---
#### 7. Add CM Support Ticket Generator
**Command:**
```bash
rails generate cm_admin:add_cm_support_ticket
```
**What it does:**
- Creates migration for CmSupportTicket
- Creates migration for CmSupportTicketEmployee (ticket assignments)
- Creates migration adding created_by to CmComment
**Next steps:**
- Add `CmSupportTicket` to `config.included_models` in `cm_admin.rb`
- Run `rails db:migrate`
**Why you need it:**
Set up a **support ticket system** within your admin panel. Manage customer support requests and assign them to team members.
---
#### 8. Add CM Platform Setting Generator
**Command:**
```bash
rails generate cm_admin:add_cm_platform_setting
```
**What it does:**
- Creates migration for CmPlatformSetting (platform-wide settings)
**Next steps:**
- Run `rails db:migrate`
**Why you need it:**
Add **platform settings management** to your admin panel. Store and manage application-wide configuration values.
---
#### 9. Add CM Index Preference Generator
**Command:**
```bash
rails generate cm_admin:add_cm_index_preference
```
**What it does:**
- Creates migration for CmIndexPreferences (user column preferences)
- Creates constants initializer
**Next steps:**
- Run `rails db:migrate`
**Why you need it:**
Allow users to **customize which columns** appear in list views. Each user can save their preferred column visibility and order.
---
#### 10. Add Cron Job Notifications Generator
**Command:**
```bash
rails generate cm_admin:add_cron_job_notifications
```
**What it does:**
- Creates migration for CmNotifiable (notification system)
**Next steps:**
- Run `rails db:migrate`
- Add to your models:
```ruby
has_many :notifiables, as: :notifiable, dependent: :destroy
accepts_nested_attributes_for :notifiables, allow_destroy: true
```
**Why you need it:**
Enable **unified notification system** for cron jobs. Send email and webhook notifications when scheduled jobs complete or fail.
---
#### 11. Add Prompts with Constants Generator
**Command:**
```bash
rails generate cm_admin:add_prompts_with_constants
```
**What it does:**
- Creates migration for CmPrompt (rich text templates)
- Creates migration for ConstantMap (polymorphic associations)
- Adds Lexxy template platform setting
- Enables AI-powered slash commands in rich text editors
**Next steps:**
- Add `CmPrompt` to `config.included_models` in `cm_admin.rb`
- Run `rails db:migrate`
- Ensure Lexxy gem is installed (~> 0.8)
**Why you need it:**
Add **AI-powered rich text templates** to your admin panel. Users can create reusable prompt templates with category-based filtering and access them via slash commands (`/`) in any rich text field.
---
### Migration Generators (Upgrades)
#### 12. Add Token Type to API Token Generator
**Command:**
```bash
rails generate cm_admin:add_token_type_to_api_token
```
**What it does:**
- Creates migration to add token_type column to API tokens
**Why you need it:**
**Upgrade existing installations** to support different token types (e.g., read-only, read-write) for API authentication.
---
#### 13. Add Unique Index to Permissions Generator
**Command:**
```bash
rails generate cm_admin:add_unique_index_to_permissions
```
**What it does:**
- Creates migration to add unique index on cm_permissions table
**Why you need it:**
**Database integrity upgrade** to prevent duplicate permission entries.
---
#### 14. File Import Improvement Generator
**Command:**
```bash
rails generate cm_admin:file_import_improvement
```
**What it does:**
- Creates migration to add `action_name`, `importer_class_name`, `import_type`, and `associated_model_id` columns to the file_imports table
**Next steps:**
- Run `rails db:migrate`
**Why you need it:**
**Upgrade existing installations** to store import metadata directly on the file import record. This enables the job processor to work without relying on runtime model lookups and provides better traceability in the file imports index and show views.
---
#### 15. File Export Improvement Generator
**Command:**
```bash
rails generate cm_admin:file_export_improvement
```
**What it does:**
- Creates migration to improve file export functionality
- Runs the migration automatically
**Why you need it:**
**Upgrade existing installations** to the improved file export system with better performance and features.
---
#### 16. Add Audit Metadata to Versions Generator
**Command:**
```bash
rails generate cm_admin:add_audit_metadata_to_versions
```
**What it does:**
- Creates a migration to add `action_type` and `action_name` columns to the PaperTrail `versions` table
- Adds indexes on both columns for fast audit filtering
- Checks that the `versions` table exists before generating the migration
- Only adds columns that are not already present
**Next steps:**
- Run `rails db:migrate`
**Why you need it:**
**Upgrade existing installations** to record queryable audit metadata on every PaperTrail version. cm-admin uses these columns to store which admin action (CRUD, custom action, or bulk action) triggered a change. Required for the auditing feature described in [Auditing](./Features/Auditing).
---
#### 17. Add OTP Requests Generator
**Command:**
```bash
rails generate cm_admin:add_otp_requests
```
**What it does:**
- Creates migration for `otp_requests` table with columns: `otp`, `email` (citext), `expired_at`, `status`, `verified_at`, `user_id` (optional foreign key), and timestamps
- Adds an index on the `email` column
- Prints post-install instructions reminding you to add `has_many :otp_requests` and `include OtpAuthenticatable` to your User model
**Next steps:**
- Run `rails db:migrate`
- Add to your User model:
```ruby
has_many :otp_requests, dependent: :destroy
include OtpAuthenticatable
```
- Enable in `cm_admin.rb`:
```ruby
config.use_catalyst_otp = true
config.auth_method = :otp # or :password, :custom
```
**Why you need it:**
Set up **OTP-based authentication** in your admin panel. The `otp_requests` table stores one-time codes with expiry and status tracking. Supports both user-associated OTPs and email-only OTPs (for signup flows without a user record). See [OtpAndAuth](./Features/OtpAndAuth) for the full setup guide.
#### 17. CT Page View Generator
**Command:**
```bash
rails generate cm_admin:ct_page_view
```
**What it does:**
- Enables the PostgreSQL `citext` extension if needed
- Drops the legacy `cm_index_preferences` table if it exists (data is not migrated)
- Creates the `ct_page_views` table with:
- `owner` as a required polymorphic reference
- `name` (`citext`, required)
- `ar_model_name`, `associated_ar_model_name`, `visible_columns`, `hidden_columns`, `records_per_page`
- `is_default`, plus a partial unique index allowing one default view per owner / model
- `view_type` (integer enum: table, card, kanban)
- `filter_params` (`jsonb`) and `sort_params` (`json`), both defaulting to `{}`
**Next steps:**
- Run `rails db:migrate`
**Why you need it:**
Powers [Saved Views](./3.%20Features/SavedViews.md): per-user saved filters (including [Advanced Filters](./3.%20Features/AdvancedFilters.md)), sort, columns, page size, and view type. Replaces Index Preference.
---
#### 18. Add Email Logs Generator
**Command:**
```bash
rails generate cm_admin:add_email_logs
```
**What it does:**
- Creates the `cm_email_logs` table used to record every `CmAdmin.send_email` delivery when `config.enable_email_logging` is `true`.
**Next steps:**
- Run `rails db:migrate`
**Why you need it:**
Enable **email delivery logging** for your admin panel. This stores delivery status, recipients, failure reasons, and record associations, which is useful for auditing and debugging outgoing emails. See [CmAdminEmail](./Features/CmAdminEmail) for the full email sending API.
---
#### 19. Rename File Import and Export Tables Generator
**Command:**
```bash
rails generate cm_admin:rename_file_import_and_export_tables
```
**What it does:**
- Creates a reversible migration that renames `file_imports` to `ct_file_imports` and `file_exports` to `ct_file_exports`
- Updates persisted model names from `FileImport` and `FileExport` to `CtFileImport` and `CtFileExport` in Active Storage, PaperTrail, permissions, and index preferences
**Next steps:**
- Run `rails db:migrate`
- Replace application references to `FileImport` and `FileExport` with `CtFileImport` and `CtFileExport`
**Why you need it:**
Use this generator when upgrading an existing installation to the `CtFileImport` and `CtFileExport` model names.
---
#### 20. Add Webhook Events Generator
**Command:**
```bash
rails generate cm_admin:add_webhook_events
```
**What it does:**
- Creates the `cm_webhook_events` table used to log incoming webhook events when `config.enable_webhook_processing` is `true`
- Adds columns for `cm_service` (gem-managed service enum, e.g. `postmark`), `host_service` (host-app service enum), `event_type`, `status`, `event_id`, `uuid` (auto-assigned via `SecureRandom`, unique index), `error_message`, `processed_at`, and a polymorphic `eventable` association
- Adds indexes on status, both services, event type, event ID, UUID (unique), created date, the `eventable` association, and composite service + event ID + event type lookups
**Next steps:**
- Run `rails db:migrate`
- Ensure ActiveStorage is installed and configured (`rails active_storage:install`) — webhook payloads are stored as `raw_payload` attachments
**Why you need it:**
Enable **webhook logging** for your admin panel. Incoming webhooks are persisted with their payload, status, and error information, and processed asynchronously via `ProcessWebhookEventJob`. See [CmAdminWebhooks](./Features/CmAdminWebhooks) for the full webhook setup guide.
---
#### 21. Add Webhook Event Recipient Generator
**Command:**
```bash
rails generate cm_admin:add_webhook_event_recipient
```
**What it does:**
- Adds a polymorphic `recipient` association (`recipient_type`/`recipient_id` + index) to `cm_webhook_events` — links each event to the recipient it belongs to, populated at webhook receipt by matching the payload's recipient email against the log's record
- Adds an `original_link` column — for Click events, the clicked URL, denormalized at receipt so clicks can be grouped and counted in SQL
**Next steps:**
- Run `rails db:migrate`
**Why you need it:**
For installations that already ran `add_webhook_events`. Per-recipient open/click tracking (recipient columns, Click Details, Events tab, campaign engagement metrics) depends on these columns. The migration is idempotent — it skips columns that already exist, so it is safe to run more than once.
---
#### 22. Add Description to API Token Generator
**Command:**
```bash
rails generate cm_admin:add_name_to_api_token
```
**What it does:**
- Creates a migration to add a `name` string column to the `api_tokens` table
- Backfills existing records with `'Unnamed Token'` so every token is identifiable
- Drops the legacy `description` column if it exists (added by v8.2.14's `add_description_to_api_token`)
**Next steps:**
- Run `rails db:migrate`
**Why you need it:**
**Upgrade existing installations** to give every API token a mandatory, human-readable name (max 100 characters), making it easier to identify what each token is used for and to search tokens by name.
---
#### 22. Add Bot Conversations Generator
**Command:**
```bash
rails generate cm_admin:add_bot_conversations
```
**What it does:**
- Creates migration for `ct_bot_conversations` (chat conversations per user)
- Creates migration for `ct_bot_messages` (user/assistant messages with rich text content and file attachments)
- Creates migration for `ct_bot_artifacts` (interactive elements such as clarifying-question cards)
- Creates migration for `ct_cost_trackings` (per-request token usage and cost)
- Creates migration adding `is_catalyst_ai_enabled` to `cm_roles` (per-role opt-in flag)
**Next steps:**
- Add `CtBotConversation, CtBotMessage, CtCostTracking` to `config.included_models` in `zcm_admin.rb`
- Run `rails db:migrate`
- Add your OpenRouter key to credentials: `catalyst_ai.openrouter_api_key`
- Ensure `config/cable.yml` exists (Redis adapter recommended in production) — replies stream over ActionCable
- Declare `mcp_actions` on the models the assistant may query
- Enable **Catalyst AI** on each `CmRole` that should see the widget
**Why you need it:**
Sets up **Catalyst AI (CT Bot)** — an in-app chat widget that answers admin users' questions using your MCP-enabled models. See [Catalyst AI](./3.%20Features/CatalystAi.md) for the full feature guide.
---
#### 24. Add Bot Transcriptions Generator
**Command:**
```bash
rails generate cm_admin:add_bot_transcriptions
```
**What it does:**
- Creates migration for `ct_bot_transcriptions` (voice recordings with their transcribed text, status and cost, optionally linked to a conversation and message)
**Next steps:**
- Add `CtBotTranscription` to `config.included_models` in `zcm_admin.rb`
- Run `rails db:migrate`
**Why you need it:**
Enables **voice input** in the Catalyst AI chat widget (added in v8.4.1). Run it after `add_bot_conversations` — on existing Catalyst AI installs and fresh installs alike. See the [v8.4.1 upgrade guide](./5.%20Upgrade%20Guide/v8.4.1.md).
---
#### 25. Remove Status from API Token Generator
**Command:**
```bash
rails generate cm_admin:remove_status_from_api_token
```
**What it does:**
- Stamps `expires_at` with the current time on tokens stored as `expired` that would otherwise still be valid by date, so none are re-enabled
- Drops the `status` column from `api_tokens`
- The `down` migration re-adds `status` and marks tokens whose `expires_at` has passed as `expired`
**Next steps:**
- Replace `enum :status, %i[live expired]` in your `ApiToken` model with the derived scopes and methods (the cm-admin API Tokens pages call `status` and `live?`):
```ruby
scope :live, -> { where(expires_at: [nil, Time.current..]) }
scope :expired, -> { where(expires_at: ...Time.current) }
def live?
expires_at.nil? || expires_at.future?
end
def expired?
!live?
end
def status
live? ? "live" : "expired"
end
```
- Run `rails db:migrate`
**Why you need it:**
Whether an API token is live is now derived only from `expires_at`. `ApiToken#status`, `#live?`, `#expired?` and the `live` / `expired` scopes are computed from it.
---
### Recommended Setup Order
For new projects, run generators in this order:
1. `rails generate cm_admin:install` - Foundation setup
2. `rails generate cm_admin:add_authentication` - If no auth exists
3. `rails generate cm_admin:add_otp_requests` - For OTP-based auth
4. `rails generate cm_admin:install_role` - For role-based permissions
5. `rails generate cm_admin:file_import_improvement` - Add tracking columns to file imports
6. Feature generators as needed (scheduler, comments, support tickets, etc.)
### Troubleshooting
**Missing models:** After running feature generators, remember to add the new models to `config.included_models` in `cm_admin.rb`.
---
## Has One Tab
### Overview
The **Has One Tab** feature enables displaying a `has_one` associated record directly within a tab on a parent model's show page. Instead of a paginated list (used for `has_many`), it renders a single-record profile view with Edit, Delete, and custom action support.
When the associated record does not exist yet, an **empty state** is shown with a button to create one. When the user lacks permission to view the record, a **permission denied** state is shown.
---
### How It Works
Adding `relationship_type: :has_one` to a `tab` declaration changes the tab behaviour:
| State | What Renders |
| ----- | ------------ |
| Record exists and user has access | Profile view with fields, Edit/Delete/custom actions |
| Record does not exist | Empty state with an **Add** button that pre-fills the association |
| User lacks permission (Pundit scope) | Permission denied message |
The tab badge shows **0** or **1** instead of a record count.
---
### DSL
Pass `relationship_type: :has_one` alongside `layout_type: 'cm_association_show'` in the `tab` declaration.
#### Parameters
| Name | Type | Required | Default | Description |
| ------------------- | ------------------ | -------- | ------------ | ----------------------------------------------------------------------------- |
| `tab_name` | `String \| Symbol` | Yes | — | Tab label shown in the navigation. |
| `action_name` | `String` | Yes | — | Unique action name (used in routes and policy scopes). |
| `associated_model` | `String` | Yes | — | The `has_one` association name on the parent model. |
| `layout_type` | `String` | Yes | — | Must be `'cm_association_show'` for has-one tabs. |
| `relationship_type` | `Symbol` | Yes | `:has_many` | Set to `:has_one` to enable single-record display. |
| `show_count` | `Boolean` | No | `true` | Set to `false` to hide the badge, or leave `true` to show 0/1. |
| `display_if` | `Proc` | No | `-> { true }` | Lambda returning a boolean to conditionally show the tab. |
| `allow_create_action` | `Proc` | No | `-> { true }` | Lambda controlling whether the Add button appears on the empty state. |
| `eager_load_associations` | `Array` | No | `nil` | Associations to eager-load when fetching the record. |
#### Updated Method Signature
```ruby
tab(tab_name, action_name,
associated_model: nil,
layout_type: nil,
relationship_type: :has_many,
show_count: true,
display_if: nil,
allow_create_action: -> { true },
eager_load_associations: nil,
&block)
```
---
### Example
#### 1. Model Setup
```ruby
class User < ApplicationRecord
has_one :profile
end
class Profile < ApplicationRecord
belongs_to :user
end
```
#### 2. cm_admin Configuration
```ruby
# app/models/cm_user.rb
cm_admin do
cm_show do
tab :profile, 'profile',
associated_model: 'profile',
layout_type: 'cm_association_show',
relationship_type: :has_one,
show_count: false
end
end
# app/models/cm_profile.rb
cm_admin do
cm_show do
cm_section 'Personal Details' do
field :first_name
field :last_name
field :bio
end
end
end
```
#### 3. Permissions
Add the action to the Pundit policy and policy scope for the associated model. For example:
```ruby
# app/policies/cm_admin/profile_policy.rb
class CmAdmin::ProfilePolicy < CmAdmin::ApplicationPolicy
class ProfileScope < Scope
def resolve
scope.where(user: user)
end
end
def show? = true
def edit? = true
def destroy? = true
end
```
After adding the tab, update the permissions for the relevant roles in the **Permissions** tab inside cm_admin.
---
### Behaviour Details
#### Empty State
When no associated record exists, the tab renders an empty state with an **Add** button. Clicking the button opens the creation form for the associated model and pre-fills:
- `associated_id` — the parent record's ID
- `associated_class` — the parent model name
- `foreign_key` — the foreign key column
- `polymorphic_name` — set automatically for polymorphic associations
#### Permission Denied State
If a Pundit policy scope exists for the action and the record does not pass the scope check, the permission denied state is shown instead of the record. No record data is leaked.
#### Tab Badge
For `has_one` tabs, the badge displays **0** (no record) or **1** (record exists), instead of a full count query.
#### Actions Bar
The profile view renders an actions bar above the record fields with:
- **Edit** button — links to the associated model's edit page with a `referrer` param so the user is returned to the parent show page after saving.
- **Custom actions** dropdown — respects `display_type` (`:button`, `:modal`, `:form_modal`, `:link`).
- **Delete** button — triggers the destroy modal; shown only when the user has permission.
---
### Comparison: `has_many` vs `has_one` Tab
| Behaviour | `has_many` (default) | `has_one` |
| ----------------------- | -------------------------------------- | -------------------------------------- |
| `layout_type` | `cm_association_index` | `cm_association_show` |
| `relationship_type` | `:has_many` (default) | `:has_one` |
| Renders | Paginated table / card list | Single record profile view |
| No record state | Empty table | Empty state with Add button |
| Permission denied state | Standard Pundit scope on list | Dedicated permission denied view |
| Tab badge | Record count | 0 or 1 |
| `show_count` | Usually `true` | Usually `false` |
---
## EnumHelper
1. Create Enum from Rails Model Enum
Use `enum_from_model` to build a GraphQL Enum based on a model's enum attribute.
- first argument is ActiveRecord Model Name.
- second argument is string and name of the enum on model.
- An optional `graphql_enum_name:` can be provided to customize the GraphQL type name.
NOTE: GraphQL enum names are automatically generated in the format `Enum` but can be customized.
examples:
```ruby
field :listing_type, Types::EnumHelpers.enum_from_model(Listing, 'listing_type'), null: true
```
```ruby
field :listing_type, Types::EnumHelpers.enum_from_model(Listing, 'listing_type', graphql_enum_name: 'MyCustomName'), null: true
```
2. Create Enum from Array
Use `enum_from_array` to build a GraphQL Enum from an array of values.
- first argument is array of string containing enum values.
- second argument is graphql name for this.
```ruby
field :status, Types::EnumHelpers.enum_from_array(%w[draft published archived], 'StatusEnum'), null: true
```
---
## Ideal GraphQL Configuration
This guide shows the recommended setup for CM GraphQL using:
- **[EnumHelper](./EnumHelper)** for reusable enum types in sort inputs and filters
- **[PagingTypeHelper](./PagingTypeHelper)** for standardized list response types
> In all examples below, replace `ModelName` with the model where you want this implemented.
#### 1. Define input types for sort
Use `EnumHelper` for sort direction and model-specific sortable columns.
```ruby
module Types
module Inputs
class ModelNameSort < Types::BaseInputObject
graphql_name 'ModelNameSortInput'
argument :column, Types::EnumHelpers.enum_from_array(%w[created_at name],
'ModelNameSortColumn'), required: false
argument :direction, Types::Enums::Base::SortDirection, required: false
end
end
end
```
#### 2. Define input types for filters
NOTE: Use `Types::Inputs::Base::Filter` as the base class for filter inputs, this already have `q` field for search and `ids` field for filtering by ids.
```ruby
module Types
module Inputs
class ModelNameFilter < Types::Inputs::Base::Filter
graphql_name 'ModelNameFilterInput'
argument :some_fields, [String], required: false
end
end
end
```
#### 3. Define the list query using PagingTypeHelper
For list queries, use this structure:
```ruby
module Queries
module ModelName
class List < Queries::BaseQuery
description 'list records on a model'
argument :filters, Types::Inputs::ModelNameFilter, required: false
argument :sort, Types::Inputs::ModelNameSort, required: false
argument :pagination, Types::Inputs::Base::Paging, required: false
type Types::PagingTypeHelper.paging_type_for_model(model: ::ModelName), null: true
def resolve(filters: nil, pagination: nil, sort: nil)
per_page = pagination&.per_page || DEFAULT_PER_PAGE
page = pagination&.page_no || 1
::ModelName.filters(filters).order_by(sort&.column, sort&.direction).list(per_page, page)
end
end
end
end
```
#### 4. Add the query to your root `QueryType`
```ruby
module Types
class QueryType < Types::BaseObject
field :model_names, resolver: Queries::ModelName::List, description: 'List ModelName records'
end
end
```
#### 5. On Model this methods and scopes are required
- `filters` class method - for filtering records
- `order_by` scope - for sorting records
- fields filters scopes - for filtering by specific fields
example:
```ruby
class ModelName < ApplicationRecord
scope :search_filter, lambda { |query|
return all if query.blank?
where('model_names.name ILIKE ?', "%#{query}%")
}
scope :ids_filter, lambda { |ids|
return all if ids.blank?
where(id: ids)
}
scope :order_by, lambda { |column, direction = 'asc'|
return all if column.blank?
order(column => direction)
}
scope :some_fields_filter, lambda { |some_fields|
return all if some_fields.blank?
where(some_field: some_fields)
}
def self.filters(filters)
return all if filters.blank?
all
.search_filter(filters[:q])
.ids_filter(filters[:ids])
.some_fields_filter(filters[:some_fields])
end
end
```
---
## PagingTypeHelper
### Create Enum from Rails Model Enum
Use `paging_type_for_model` to build a GraphQL Paging Type.
Arguments:
- `model`: ActiveRecord Model Name.
- `record_data_type`: GraphQL Type for the record data.
- `graphql_type_name:` can be provided to customize the GraphQL type name.
NOTE: GraphQL Paging Type Name will be automatically generated in the format `PagingType` but can be customized.
NOTE: If Record Data Type is not provided, it will use the object type in the format `Types::Objects::Type`.
examples:
```ruby
Types::PagingTypeHelper.paging_type_for_model(model: Course, record_data_type: Types::Objects::Course)
```
```ruby
Types::PagingTypeHelper.paging_type_for_model(model: Course, record_data_type: Types::Objects::Course, graphql_type_name: 'CustomCoursePagingType')
```
- To create a reusable Type create a initializer in `config/initializers/graphql_paging_type_autogen.rb`
```ruby
Rails.application.config.to_prepare do
Types::CoursePagingType = Types::PagingTypeHelper.paging_type_for_model(model: Course, record_data_type: Types::Objects::Course)
end
```
---
## RecaptchaExtension
To use recaptcha extension in the graphql field, add the following line in the field:
```ruby
field :login,
mutation: Mutations::Auth::Login,
extensions: [
CmGraphql::Extensions::RecaptchaExtension
]
# With options
field :login,
mutation: Mutations::Auth::Login,
extensions: [
{ CmGraphql::Extensions::RecaptchaExtension => { action: 'login', minimum_score: 0.5 } }
]
```
### Sample request
```ruby
mutation Login {
login(input: {
email: "anbu+5@commutatus.com"
}, recaptchaToken: "recaptcha_token") {
accessToken
expiresAt
}
}
```
### Arguments:
- `action`: Action name. It will be mandatory unless field_name and action passed are not same. For eg: login is the field_name, so the action option can be ignored.
- `minimum_score`: Minimum score for recaptcha. It will be optional. If not provided, it will use the RECAPTCHA_MINIMUM_SCORE constant.
### Credentials
Please create google recaptcha account from [google console](https://www.google.com/recaptcha/admin)
Please update the credentials for recaptcha, format will be
```ruby
gcp:
recaptcha_site_key: 'your_site_key'
recaptcha_secret_key: 'your_secret_key'
```
To override the minimum score for recaptcha instead of using options, add the following line in `config/initializers/constants.rb`:
```ruby
RECAPTCHA_MINIMUM_SCORE = 0.5
```
### Note
- RecaptchaExtension verfier module is derived from the gem [recaptcha](https://github.com/ambethia/recaptcha)
---
## CM GraphQL
CM GraphQL is a companion library that provides GraphQL API support for Rails applications.
### Overview
CM GraphQL provides helper utilities that simplify common GraphQL patterns in Rails applications, enabling flexible and efficient data querying for your frontend applications.
### Available Helpers
- **[EnumHelper](./EnumHelper.md)** - Create GraphQL enums from Rails model enums or custom arrays
- **[PagingTypeHelper](./PagingTypeHelper.md)** - Generate reusable pagination types for GraphQL queries