Escalated for Rails

A full-featured, embeddable support ticket system for Rails. Drop it into any app — get a complete helpdesk with SLA tracking, escalation rules, agent workflows, and a customer portal. No external services required.

Three hosting modes. Run entirely self-hosted, sync to a central cloud for multi-app visibility, or proxy everything to the cloud. Switch modes with a single config change.

Features

  • Ticket lifecycle — Create, assign, reply, resolve, close, reopen with configurable status transitions
  • SLA engine — Per-priority response and resolution targets, business hours calculation, automatic breach detection
  • Escalation rules — Condition-based rules that auto-escalate, reprioritize, reassign, or notify
  • Agent dashboard — Ticket queue with filters, bulk actions, internal notes, canned responses
  • Customer portal — Self-service ticket creation, replies, and status tracking
  • Admin panel — Manage departments, SLA policies, escalation rules, tags, and view reports
  • File attachments — Drag-and-drop uploads with configurable storage and size limits
  • Activity timeline — Full audit log of every action on every ticket
  • Email notifications — Configurable per-event notifications with webhook support
  • Department routing — Organize agents into departments with auto-assignment (round-robin)
  • Tagging system — Categorize tickets with colored tags
  • Inertia.js + Vue 3 UI — Shared frontend via @escalated-dev/escalated

v0.4.0 — Advanced Features

  • Bulk actions — Assign, change status/priority, add tags, close, or delete multiple tickets at once
  • Macros — Reusable multi-step automations (set status + assign + add note in one click)
  • Ticket followers — Agents follow tickets and receive the same notifications as the assignee
  • Satisfaction ratings — 1-5 star CSAT ratings with optional comments after resolution
  • Pinned notes — Pin important internal notes to the top of the ticket thread
  • Keyboard shortcuts — Full keyboard navigation for power users
  • Quick filters — One-click filter chips (My Tickets, Unassigned, Urgent, SLA Breaching)
  • Presence indicators — See who else is viewing a ticket in real-time
  • Enhanced dashboard — CSAT metrics, resolution times, SLA breach tracking

Requirements

  • Ruby 3.1+
  • Rails 7.1+
  • Node.js 18+ (for frontend assets)

Quick Start

bundle add escalated
npm install @escalated-dev/escalated
rails generate escalated:install
rails db:migrate

Add the Ticketable concern to your User model:

class User < ApplicationRecord
  include Escalated::Ticketable
end

Define authorization in your ApplicationController or an initializer:

# config/initializers/escalated.rb
Escalated.configure do |config|
  config.admin_check = ->(user) { user.admin? }
  config.agent_check = ->(user) { user.agent? || user.admin? }
end

Visit /support — you're live.

Frontend Setup

Escalated uses Inertia.js with Vue 3. The frontend components are provided by the @escalated-dev/escalated npm package.

Tailwind Content

Add the Escalated package to your Tailwind content config so its classes aren't purged:

// tailwind.config.js
content: [
    // ... your existing paths
    './node_modules/@escalated-dev/escalated/src/**/*.vue',
],

Page Resolver

Add the Escalated pages to your Inertia page resolver:

// app/javascript/entrypoints/application.js
import { createApp, h } from 'vue'
import { createInertiaApp } from '@inertiajs/vue3'

createInertiaApp({
  resolve: name => {
    if (name.startsWith('Escalated/')) {
      const escalatedPages = import.meta.glob(
        '../../../node_modules/@escalated-dev/escalated/src/pages/**/*.vue',
        { eager: true }
      )
      const pageName = name.replace('Escalated/', '')
      return escalatedPages[`../../../node_modules/@escalated-dev/escalated/src/pages/${pageName}.vue`]
    }

    const pages = import.meta.glob('../pages/**/*.vue', { eager: true })
    return pages[`../pages/${name}.vue`]
  },
  setup({ el, App, props, plugin }) {
    createApp({ render: () => h(App, props) })
      .use(plugin)
      .mount(el)
  },
})

Theming (Optional)

Register the EscalatedPlugin to render Escalated pages inside your app's layout — no page duplication needed:

import { EscalatedPlugin } from '@escalated-dev/escalated'
import AppLayout from '@/layouts/AppLayout.vue'

createInertiaApp({
  setup({ el, App, props, plugin }) {
    createApp({ render: () => h(App, props) })
      .use(plugin)
      .use(EscalatedPlugin, {
        layout: AppLayout,
        theme: {
          primary: '#3b82f6',
          radius: '0.75rem',
        }
      })
      .mount(el)
  },
})

Your layout component must accept a #header slot and a default slot. Escalated will render its sub-navigation in the header and page content in the default slot. Without the plugin, Escalated uses its own standalone layout.

See the @escalated-dev/escalated README for full theming documentation and CSS custom properties.

Hosting Modes

Self-Hosted (default)

Everything stays in your database. No external calls. Full autonomy.

Escalated.configure do |config|
  config.mode = :self_hosted
end

Synced

Local database + automatic sync to cloud.escalated.dev for unified inbox across multiple apps. If the cloud is unreachable, your app keeps working — events queue and retry.

Escalated.configure do |config|
  config.mode = :synced
  config.hosted_api_url = "https://cloud.escalated.dev/api/v1"
  config.hosted_api_key = ENV["ESCALATED_API_KEY"]
end

Cloud

All ticket data proxied to the cloud API. Your app handles auth and renders UI, but storage lives in the cloud.

Escalated.configure do |config|
  config.mode = :cloud
  config.hosted_api_url = "https://cloud.escalated.dev/api/v1"
  config.hosted_api_key = ENV["ESCALATED_API_KEY"]
end

All three modes share the same controllers, UI, and business logic. The driver pattern handles the rest.

Configuration

Create or edit config/initializers/escalated.rb:

Escalated.configure do |config|
  config.mode = :self_hosted
  config.user_class = "User"
  config.table_prefix = "escalated_"
  config.route_prefix = "support"
  config.default_priority = :medium

  # Middleware
  config.middleware = [:authenticate_user!]
  config.admin_middleware = nil

  # Tickets
  config.allow_customer_close = true
  config.auto_close_resolved_after_days = 7
  config.max_attachments_per_reply = 5
  config.max_attachment_size_kb = 10240

  # SLA
  config.sla = {
    enabled: true,
    business_hours_only: true,
    business_hours: {
      start: 9, end: 17,
      timezone: "UTC",
      working_days: [1, 2, 3, 4, 5]
    }
  }

  # Notifications
  config.notification_channels = [:email]
  config.webhook_url = nil

  # Storage (ActiveStorage)
  config.storage_service = :local
end

Scheduling

Add these to your scheduler for SLA and escalation automation:

# config/schedule.rb (whenever gem) or use solid_queue/sidekiq-cron
every 1.minute do
  runner "Escalated::CheckSlaJob.perform_now"
end

every 5.minutes do
  runner "Escalated::EvaluateEscalationsJob.perform_now"
end

every 1.day do
  runner "Escalated::CloseResolvedJob.perform_now"
end

every 1.week do
  runner "Escalated::PurgeActivitiesJob.perform_now"
end

Routes

Routes are automatically mounted when the engine loads. By default they mount at /support.

Route Method Description
/support GET Customer ticket list
/support/create GET New ticket form
/support/{ticket} GET Ticket detail
/support/agent GET Agent dashboard
/support/agent/tickets GET Agent ticket queue
/support/agent/tickets/{ticket} GET Agent ticket view
/support/admin/reports GET Admin reports
/support/admin/departments GET Department management
/support/admin/sla-policies GET SLA policy management
/support/admin/escalation-rules GET Escalation rule management
/support/admin/tags GET Tag management
/support/admin/canned-responses GET Canned response management
/support/agent/tickets/bulk POST Bulk actions on multiple tickets
/support/agent/tickets/{ticket}/follow POST Follow/unfollow a ticket
/support/agent/tickets/{ticket}/macro POST Apply a macro to a ticket
/support/agent/tickets/{ticket}/presence POST Update presence on a ticket
/support/agent/tickets/{ticket}/pin/{reply} POST Pin/unpin an internal note
/support/{ticket}/rate POST Submit satisfaction rating

Events

Connect to ticket lifecycle events via ActiveSupport::Notifications:

ActiveSupport::Notifications.subscribe("escalated.ticket_created") do |event|
  ticket = event.payload[:ticket]
  # Handle new ticket
end

Also Available For

Same architecture, same Vue UI, same three hosting modes — for every major backend framework.

Testing

bundle exec rspec

License

MIT