From eaadae77ea5d72966528c8be9ab4958b6b5fb158 Mon Sep 17 00:00:00 2001 From: Matt Gros <3311227+mpge@users.noreply.github.com> Date: Tue, 10 Feb 2026 11:42:42 -0500 Subject: [PATCH 1/3] feat: add WordPress-style plugin/extension system Implements HookManager with actions + filters (priority-based), Plugin model, PluginService (discovery, activation, lifecycle), PluginUIService (menus, widgets, page component slots), admin controller with routes, HookRegistry documenting 40+ hooks, and hello-world example plugin stub. --- .../escalated/admin/plugins_controller.rb | 93 ++++++ .../escalated/application_controller.rb | 14 +- app/models/escalated/plugin.rb | 16 + config/routes.rb | 9 + db/migrate/018_create_escalated_plugins.rb | 15 + escalated.gemspec | 2 +- lib/escalated.rb | 37 +++ lib/escalated/configuration.rb | 11 + lib/escalated/engine.rb | 21 ++ lib/escalated/services/hook_registry.rb | 300 ++++++++++++++++++ lib/escalated/services/plugin_service.rb | 263 +++++++++++++++ lib/escalated/services/plugin_ui_service.rb | 213 +++++++++++++ lib/escalated/support/hook_manager.rb | 166 ++++++++++ .../escalated/templates/initializer.rb | 10 + stubs/plugins/hello-world/plugin.json | 9 + stubs/plugins/hello-world/plugin.rb | 87 +++++ 16 files changed, 1262 insertions(+), 4 deletions(-) create mode 100644 app/controllers/escalated/admin/plugins_controller.rb create mode 100644 app/models/escalated/plugin.rb create mode 100644 db/migrate/018_create_escalated_plugins.rb create mode 100644 lib/escalated/services/hook_registry.rb create mode 100644 lib/escalated/services/plugin_service.rb create mode 100644 lib/escalated/services/plugin_ui_service.rb create mode 100644 lib/escalated/support/hook_manager.rb create mode 100644 stubs/plugins/hello-world/plugin.json create mode 100644 stubs/plugins/hello-world/plugin.rb diff --git a/app/controllers/escalated/admin/plugins_controller.rb b/app/controllers/escalated/admin/plugins_controller.rb new file mode 100644 index 0000000..b64afb6 --- /dev/null +++ b/app/controllers/escalated/admin/plugins_controller.rb @@ -0,0 +1,93 @@ +module Escalated + module Admin + class PluginsController < Escalated::ApplicationController + before_action :require_admin! + + def index + plugins = Escalated::Services::PluginService.all_plugins + + render inertia: "Escalated/Admin/Plugins/Index", props: { + plugins: plugins.map { |p| plugin_json(p) } + } + end + + def upload + unless params[:plugin].present? + redirect_back fallback_location: admin_plugins_path, + alert: "Please select a plugin ZIP file to upload." + return + end + + begin + result = Escalated::Services::PluginService.upload_plugin(params[:plugin]) + + redirect_to admin_plugins_path, + notice: "Plugin uploaded successfully. You can now activate it." + rescue StandardError => e + Rails.logger.error("[Escalated::PluginsController] Upload failed: #{e.message}") + redirect_back fallback_location: admin_plugins_path, + alert: "Failed to upload plugin: #{e.message}" + end + end + + def activate + begin + Escalated::Services::PluginService.activate_plugin(params[:id]) + + redirect_back fallback_location: admin_plugins_path, + notice: "Plugin activated successfully." + rescue StandardError => e + Rails.logger.error("[Escalated::PluginsController] Activation failed: #{e.message}") + redirect_back fallback_location: admin_plugins_path, + alert: "Failed to activate plugin: #{e.message}" + end + end + + def deactivate + begin + Escalated::Services::PluginService.deactivate_plugin(params[:id]) + + redirect_back fallback_location: admin_plugins_path, + notice: "Plugin deactivated successfully." + rescue StandardError => e + Rails.logger.error("[Escalated::PluginsController] Deactivation failed: #{e.message}") + redirect_back fallback_location: admin_plugins_path, + alert: "Failed to deactivate plugin: #{e.message}" + end + end + + def destroy + begin + Escalated::Services::PluginService.delete_plugin(params[:id]) + + redirect_back fallback_location: admin_plugins_path, + notice: "Plugin deleted successfully." + rescue StandardError => e + Rails.logger.error("[Escalated::PluginsController] Deletion failed: #{e.message}") + redirect_back fallback_location: admin_plugins_path, + alert: "Failed to delete plugin: #{e.message}" + end + end + + private + + def admin_plugins_path + escalated.admin_plugins_path + end + + def plugin_json(plugin) + { + slug: plugin[:slug], + name: plugin[:name], + description: plugin[:description], + version: plugin[:version], + author: plugin[:author], + author_url: plugin[:author_url], + requires: plugin[:requires], + is_active: plugin[:is_active], + activated_at: plugin[:activated_at]&.iso8601, + } + end + end + end +end diff --git a/app/controllers/escalated/application_controller.rb b/app/controllers/escalated/application_controller.rb index d51b307..131bd87 100644 --- a/app/controllers/escalated/application_controller.rb +++ b/app/controllers/escalated/application_controller.rb @@ -20,14 +20,15 @@ def apply_middleware end def set_inertia_shared_data - inertia_share( + shared = { current_user: current_user_data, escalated: { route_prefix: Escalated.configuration.route_prefix, allow_customer_close: Escalated.configuration.allow_customer_close, max_attachments: Escalated.configuration.max_attachments, max_attachment_size_kb: Escalated.configuration.max_attachment_size_kb, - guest_tickets_enabled: Escalated::EscalatedSetting.guest_tickets_enabled? + guest_tickets_enabled: Escalated::EscalatedSetting.guest_tickets_enabled?, + plugins_enabled: Escalated.configuration.plugins_enabled?, }, flash: { success: flash[:success], @@ -35,7 +36,14 @@ def set_inertia_shared_data notice: flash[:notice], alert: flash[:alert] } - ) + } + + # Share plugin UI data when plugin system is enabled + if Escalated.configuration.plugins_enabled? + shared[:plugin_ui] = Escalated.plugin_ui.to_shared_data + end + + inertia_share(shared) end def current_user_data diff --git a/app/models/escalated/plugin.rb b/app/models/escalated/plugin.rb new file mode 100644 index 0000000..1d5d36b --- /dev/null +++ b/app/models/escalated/plugin.rb @@ -0,0 +1,16 @@ +module Escalated + class Plugin < ApplicationRecord + self.table_name = Escalated.table_name("plugins") + + validates :slug, presence: true, uniqueness: true, + format: { with: /\A[a-z0-9]+(?:-[a-z0-9]+)*\z/, message: "must be a lowercase slug (e.g. my-plugin)" } + + scope :active, -> { where(is_active: true) } + scope :inactive, -> { where(is_active: false) } + scope :ordered, -> { order(:slug) } + + def active? + is_active + end + end +end diff --git a/config/routes.rb b/config/routes.rb index f25f59f..24d84ad 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -59,6 +59,15 @@ resources :tags, only: [:index, :create, :update, :destroy] resources :canned_responses, only: [:index, :create, :update, :destroy] resources :macros, only: [:index, :create, :update, :destroy] + resources :plugins, only: [:index, :destroy] do + member do + post :activate + post :deactivate + end + collection do + post :upload + end + end get :reports, to: "reports#index" get :settings, to: "settings#index" post :settings, to: "settings#update" diff --git a/db/migrate/018_create_escalated_plugins.rb b/db/migrate/018_create_escalated_plugins.rb new file mode 100644 index 0000000..94aa949 --- /dev/null +++ b/db/migrate/018_create_escalated_plugins.rb @@ -0,0 +1,15 @@ +class CreateEscalatedPlugins < ActiveRecord::Migration[7.0] + def change + create_table Escalated.table_name("plugins") do |t| + t.string :slug, null: false + t.boolean :is_active, null: false, default: false + t.datetime :activated_at + t.datetime :deactivated_at + + t.timestamps + end + + add_index Escalated.table_name("plugins"), :slug, unique: true + add_index Escalated.table_name("plugins"), :is_active + end +end diff --git a/escalated.gemspec b/escalated.gemspec index a372b85..acdd284 100644 --- a/escalated.gemspec +++ b/escalated.gemspec @@ -9,7 +9,7 @@ Gem::Specification.new do |spec| spec.license = "MIT" spec.required_ruby_version = ">= 3.1" - spec.files = Dir["lib/**/*", "app/**/*", "config/**/*", "db/**/*", "resources/**/*", "LICENSE", "README.md"] + spec.files = Dir["lib/**/*", "app/**/*", "config/**/*", "db/**/*", "resources/**/*", "stubs/**/*", "LICENSE", "README.md"] spec.add_dependency "rails", ">= 7.0" spec.add_dependency "inertia_rails", ">= 3.0" diff --git a/lib/escalated.rb b/lib/escalated.rb index 1aac3cf..8ffcbd0 100644 --- a/lib/escalated.rb +++ b/lib/escalated.rb @@ -1,6 +1,10 @@ require "escalated/engine" require "escalated/configuration" require "escalated/manager" +require "escalated/support/hook_manager" +require "escalated/services/hook_registry" +require "escalated/services/plugin_service" +require "escalated/services/plugin_ui_service" module Escalated class << self @@ -21,5 +25,38 @@ def driver def table_name(name) "#{configuration.table_prefix}#{name}" end + + # Global HookManager instance. + # + # Usage: + # Escalated.hooks.add_action('ticket_created') { |ticket| ... } + # Escalated.hooks.do_action('ticket_created', ticket) + # Escalated.hooks.add_filter('ticket_list_query') { |query| query.where(priority: :high) } + # filtered = Escalated.hooks.apply_filters('ticket_list_query', query) + # + # @return [Escalated::Support::HookManager] + def hooks + @hooks ||= Support::HookManager.new + end + + # Global PluginUIService instance for registering UI extensions. + # + # Usage: + # Escalated.plugin_ui.add_menu_item(label: 'Reports', route: '/reports') + # Escalated.plugin_ui.add_dashboard_widget(title: 'Stats', component: 'StatsWidget') + # Escalated.plugin_ui.add_page_component('ticket.show', 'sidebar', component: 'MyWidget') + # + # @return [Escalated::Services::PluginUIService] + def plugin_ui + @plugin_ui ||= Services::PluginUIService.new + end + + # Reset hooks and plugin UI (useful for testing). + # + # @return [void] + def reset_plugins! + @hooks = Support::HookManager.new + @plugin_ui = Services::PluginUIService.new + end end end diff --git a/lib/escalated/configuration.rb b/lib/escalated/configuration.rb index 6ea018f..4d46976 100644 --- a/lib/escalated/configuration.rb +++ b/lib/escalated/configuration.rb @@ -17,6 +17,9 @@ class Configuration :notification_channels, :webhook_url, :storage_service, + # Plugin system + :plugins_enabled, + :plugins_path, # Inbound email settings :inbound_email_enabled, :inbound_email_adapter, @@ -64,6 +67,10 @@ def initialize @webhook_url = nil @storage_service = :local + # Plugin system defaults + @plugins_enabled = false + @plugins_path = nil # Set at boot time if nil (defaults to Rails.root.join("plugins/escalated")) + # Inbound email defaults @inbound_email_enabled = false @inbound_email_adapter = nil # :mailgun, :postmark, :ses, :imap @@ -104,6 +111,10 @@ def business_hours sla[:business_hours] || {} end + def plugins_enabled? + plugins_enabled == true + end + def user_model user_class.constantize end diff --git a/lib/escalated/engine.rb b/lib/escalated/engine.rb index 9560b5f..f2597d8 100644 --- a/lib/escalated/engine.rb +++ b/lib/escalated/engine.rb @@ -37,6 +37,27 @@ class Engine < ::Rails::Engine end end + # Set default plugins_path to Rails.root/plugins/escalated when not + # explicitly configured. Must run after the host app has booted so + # Rails.root is available. + initializer "escalated.plugins_path", after: :load_config_initializers do |app| + if Escalated.configuration.plugins_path.nil? + Escalated.configuration.plugins_path = app.root.join("plugins", "escalated").to_s + end + end + + # Load active plugins after the host app has finished booting so all + # models, routes, and services are available to plugin code. + config.after_initialize do + if Escalated.configuration.plugins_enabled? + begin + Escalated::Services::PluginService.load_active_plugins + rescue StandardError => e + Rails.logger.error("[Escalated::Engine] Failed to load plugins: #{e.message}") + end + end + end + config.generators do |g| g.test_framework :rspec g.fixture_replacement :factory_bot, dir: "spec/factories" diff --git a/lib/escalated/services/hook_registry.rb b/lib/escalated/services/hook_registry.rb new file mode 100644 index 0000000..bc80b40 --- /dev/null +++ b/lib/escalated/services/hook_registry.rb @@ -0,0 +1,300 @@ +module Escalated + module Services + # Central registry of all available hooks and filters in Escalated. + # This class serves as documentation -- plugins can reference this to + # discover what extension points are available. + class HookRegistry + class << self + # Get all available action hooks. + # + # @return [Hash{String => Hash}] + def actions + { + # ============================================================== + # PLUGIN LIFECYCLE + # ============================================================== + "plugin_loaded" => { + description: "Fired when a plugin file is loaded", + parameters: %w[slug manifest], + example: <<~RUBY + Escalated.hooks.add_action('plugin_loaded') do |slug, manifest| + Rails.logger.info "Plugin loaded: \#{slug}" + end + RUBY + }, + "plugin_activated" => { + description: "Fired when any plugin is activated", + parameters: %w[slug], + example: <<~RUBY + Escalated.hooks.add_action('plugin_activated') do |slug| + Rails.logger.info "Plugin activated: \#{slug}" + end + RUBY + }, + "plugin_activated_{slug}" => { + description: "Fired when a specific plugin is activated (replace {slug} with your plugin slug)", + parameters: [], + example: <<~RUBY + Escalated.hooks.add_action('plugin_activated_my-plugin') { puts 'My plugin activated!' } + RUBY + }, + "plugin_deactivated" => { + description: "Fired when any plugin is deactivated", + parameters: %w[slug], + example: <<~RUBY + Escalated.hooks.add_action('plugin_deactivated') do |slug| + Rails.logger.info "Plugin deactivated: \#{slug}" + end + RUBY + }, + "plugin_deactivated_{slug}" => { + description: "Fired when a specific plugin is deactivated", + parameters: [], + example: <<~RUBY + Escalated.hooks.add_action('plugin_deactivated_my-plugin') { puts 'Bye!' } + RUBY + }, + "plugin_uninstalling" => { + description: "Fired before any plugin is deleted", + parameters: %w[slug], + example: <<~RUBY + Escalated.hooks.add_action('plugin_uninstalling') do |slug| + Rails.logger.info "Plugin uninstalling: \#{slug}" + end + RUBY + }, + "plugin_uninstalling_{slug}" => { + description: "Fired before a specific plugin is deleted", + parameters: [], + example: <<~RUBY + Escalated.hooks.add_action('plugin_uninstalling_my-plugin') { cleanup! } + RUBY + }, + + # ============================================================== + # TICKET LIFECYCLE + # ============================================================== + "ticket_before_create" => { + description: "Fired before a ticket is created", + parameters: %w[params], + example: <<~RUBY + Escalated.hooks.add_action('ticket_before_create') do |params| + # Modify or inspect params before creation + end + RUBY + }, + "ticket_created" => { + description: "Fired after a ticket is created", + parameters: %w[ticket], + example: <<~RUBY + Escalated.hooks.add_action('ticket_created') do |ticket| + Rails.logger.info "Ticket created: \#{ticket.reference}" + end + RUBY + }, + "ticket_updated" => { + description: "Fired after a ticket is updated", + parameters: %w[ticket actor], + example: <<~RUBY + Escalated.hooks.add_action('ticket_updated') do |ticket, actor| + Rails.logger.info "Ticket \#{ticket.reference} updated by \#{actor&.email}" + end + RUBY + }, + "ticket_status_changed" => { + description: "Fired when a ticket status changes", + parameters: %w[ticket old_status new_status actor], + example: <<~RUBY + Escalated.hooks.add_action('ticket_status_changed') do |ticket, old_status, new_status, actor| + # React to status transitions + end + RUBY + }, + "ticket_assigned" => { + description: "Fired when a ticket is assigned to an agent", + parameters: %w[ticket agent], + example: <<~RUBY + Escalated.hooks.add_action('ticket_assigned') do |ticket, agent| + Rails.logger.info "Ticket \#{ticket.reference} assigned to \#{agent.email}" + end + RUBY + }, + "ticket_closed" => { + description: "Fired when a ticket is closed", + parameters: %w[ticket actor], + example: <<~RUBY + Escalated.hooks.add_action('ticket_closed') do |ticket, actor| + # Clean up or notify + end + RUBY + }, + "ticket_reopened" => { + description: "Fired when a ticket is reopened", + parameters: %w[ticket actor], + example: <<~RUBY + Escalated.hooks.add_action('ticket_reopened') do |ticket, actor| + # Reassign or alert + end + RUBY + }, + "reply_added" => { + description: "Fired after a reply is added to a ticket", + parameters: %w[ticket reply], + example: <<~RUBY + Escalated.hooks.add_action('reply_added') do |ticket, reply| + Rails.logger.info "Reply on \#{ticket.reference}" + end + RUBY + }, + "ticket_priority_changed" => { + description: "Fired when ticket priority changes", + parameters: %w[ticket old_priority new_priority actor], + example: <<~RUBY + Escalated.hooks.add_action('ticket_priority_changed') do |ticket, old_p, new_p, actor| + # Alert if escalated to critical + end + RUBY + }, + "ticket_department_changed" => { + description: "Fired when a ticket is moved to another department", + parameters: %w[ticket old_department new_department actor], + example: <<~RUBY + Escalated.hooks.add_action('ticket_department_changed') do |ticket, old_dept, new_dept, actor| + # Auto-assign in new department + end + RUBY + }, + + # ============================================================== + # DASHBOARD / UI + # ============================================================== + "dashboard_viewed" => { + description: "Fired when the agent dashboard is viewed", + parameters: %w[user], + example: <<~RUBY + Escalated.hooks.add_action('dashboard_viewed') do |user| + # Track analytics + end + RUBY + }, + } + end + + # Get all available filter hooks. + # + # @return [Hash{String => Hash}] + def filters + { + # ============================================================== + # TICKET FILTERS + # ============================================================== + "ticket_create_params" => { + description: "Modify validated params before creating a ticket", + parameters: %w[params], + example: <<~RUBY + Escalated.hooks.add_filter('ticket_create_params') do |params| + params.merge(custom_field: 'value') + end + RUBY + }, + "ticket_list_query" => { + description: "Modify the ticket listing query", + parameters: %w[query request], + example: <<~RUBY + Escalated.hooks.add_filter('ticket_list_query') do |query, request| + query.where(priority: :high) + end + RUBY + }, + "ticket_show_data" => { + description: "Modify ticket data before rendering the show page", + parameters: %w[data ticket], + example: <<~RUBY + Escalated.hooks.add_filter('ticket_show_data') do |data, ticket| + data.merge(custom_widget: true) + end + RUBY + }, + + # ============================================================== + # DASHBOARD FILTERS + # ============================================================== + "dashboard_stats_data" => { + description: "Modify dashboard statistics before rendering", + parameters: %w[stats user], + example: <<~RUBY + Escalated.hooks.add_filter('dashboard_stats_data') do |stats, user| + stats.merge(custom_metric: 42) + end + RUBY + }, + "dashboard_page_data" => { + description: "Modify all data passed to the dashboard page", + parameters: %w[data user], + example: <<~RUBY + Escalated.hooks.add_filter('dashboard_page_data') do |data, user| + data.merge(announcements: fetch_announcements) + end + RUBY + }, + + # ============================================================== + # UI FILTERS + # ============================================================== + "navigation_menu" => { + description: "Add or modify navigation menu items", + parameters: %w[menu_items user], + example: <<~RUBY + Escalated.hooks.add_filter('navigation_menu') do |items, user| + items + [{ label: 'Reports', route: '/reports' }] + end + RUBY + }, + "sidebar_menu" => { + description: "Add or modify sidebar menu items", + parameters: %w[menu_items user], + example: <<~RUBY + Escalated.hooks.add_filter('sidebar_menu') do |items, user| + items + [{ label: 'Custom', icon: 'star' }] + end + RUBY + }, + + # ============================================================== + # SLA FILTERS + # ============================================================== + "sla_response_deadline" => { + description: "Modify the calculated SLA response deadline", + parameters: %w[deadline ticket sla_policy], + example: <<~RUBY + Escalated.hooks.add_filter('sla_response_deadline') do |deadline, ticket, policy| + ticket.priority.to_s == 'critical' ? deadline - 1.hour : deadline + end + RUBY + }, + + # ============================================================== + # NOTIFICATION FILTERS + # ============================================================== + "notification_recipients" => { + description: "Modify notification recipients before dispatch", + parameters: %w[recipients ticket event], + example: <<~RUBY + Escalated.hooks.add_filter('notification_recipients') do |recipients, ticket, event| + recipients + [admin_email] + end + RUBY + }, + } + end + + # Get all hooks (both actions and filters). + # + # @return [Hash{Symbol => Hash}] + def all_hooks + { actions: actions, filters: filters } + end + end + end + end +end diff --git a/lib/escalated/services/plugin_service.rb b/lib/escalated/services/plugin_service.rb new file mode 100644 index 0000000..6fb8d31 --- /dev/null +++ b/lib/escalated/services/plugin_service.rb @@ -0,0 +1,263 @@ +require "json" +require "fileutils" + +module Escalated + module Services + class PluginService + class << self + # ================================================================ + # Discovery + # ================================================================ + + # Get all installed plugins with their metadata, merged with + # database activation state. + # + # @return [Array] + def all_plugins + plugins = [] + + Dir.glob(File.join(plugins_path, "*")).select { |f| File.directory?(f) }.each do |directory| + slug = File.basename(directory) + manifest_path = File.join(directory, "plugin.json") + next unless File.exist?(manifest_path) + + manifest = parse_manifest(manifest_path) + next unless manifest + + db_plugin = Escalated::Plugin.find_by(slug: slug) + + plugins << { + slug: slug, + name: manifest["name"] || slug.titleize, + description: manifest["description"] || "", + version: manifest["version"] || "1.0.0", + author: manifest["author"] || "Unknown", + author_url: manifest["author_url"] || "", + requires: manifest["requires"] || "1.0.0", + main_file: manifest["main_file"] || "plugin.rb", + is_active: db_plugin&.is_active || false, + activated_at: db_plugin&.activated_at, + path: directory, + } + end + + plugins + end + + # Return slugs of all currently activated plugins. + # + # @return [Array] + def activated_plugins + Escalated::Plugin.active.pluck(:slug) + rescue ActiveRecord::StatementInvalid, ActiveRecord::NoDatabaseError => e + Rails.logger.debug("[Escalated::PluginService] Could not query plugins table: #{e.message}") + [] + end + + # ================================================================ + # Lifecycle + # ================================================================ + + # Activate a plugin by slug. + # + # Creates the database record if it doesn't exist, loads the plugin + # file, and fires activation hooks. + # + # @param slug [String] + # @return [Boolean] + def activate_plugin(slug) + validate_plugin_exists!(slug) + + plugin = Escalated::Plugin.find_or_create_by!(slug: slug) do |p| + p.is_active = false + end + + unless plugin.is_active + plugin.update!( + is_active: true, + activated_at: Time.current, + deactivated_at: nil + ) + + # Load the plugin so its hooks are registered + load_plugin(slug) + + # Fire activation hooks + Escalated.hooks.do_action("plugin_activated", slug) + Escalated.hooks.do_action("plugin_activated_#{slug}") + end + + true + end + + # Deactivate a plugin by slug. + # + # Fires deactivation hooks *before* flipping the flag so the + # plugin code can still run teardown logic. + # + # @param slug [String] + # @return [Boolean] + def deactivate_plugin(slug) + plugin = Escalated::Plugin.find_by(slug: slug) + + if plugin&.is_active + Escalated.hooks.do_action("plugin_deactivated", slug) + Escalated.hooks.do_action("plugin_deactivated_#{slug}") + + plugin.update!( + is_active: false, + deactivated_at: Time.current + ) + end + + true + end + + # Delete a plugin entirely. + # + # Fires uninstall hooks, deactivates, removes the database record, + # and deletes the plugin directory from disk. + # + # @param slug [String] + # @return [Boolean] + def delete_plugin(slug) + plugin_dir = File.join(plugins_path, slug) + return false unless File.directory?(plugin_dir) + + plugin = Escalated::Plugin.find_by(slug: slug) + + # Load plugin so its uninstall hooks can run + load_plugin(slug) if plugin&.is_active + + # Fire uninstall hooks + Escalated.hooks.do_action("plugin_uninstalling", slug) + Escalated.hooks.do_action("plugin_uninstalling_#{slug}") + + # Deactivate first if active + deactivate_plugin(slug) + + # Remove database record + plugin&.destroy + + # Remove directory + FileUtils.rm_rf(plugin_dir) + + true + end + + # Upload a plugin from a ZIP file. + # + # Extracts the archive into the plugins directory and validates + # the presence of a plugin.json manifest. + # + # @param file [ActionDispatch::Http::UploadedFile] + # @return [Hash] :slug and :path of the extracted plugin + def upload_plugin(file) + require "zip" + + temp_path = File.join(Dir.tmpdir, file.original_filename) + File.open(temp_path, "wb") { |f| f.write(file.read) } + + root_folder = nil + + Zip::File.open(temp_path) do |zip| + zip.each do |entry| + if entry.name.include?("/") + root_folder = entry.name.split("/").first + break + end + end + + raise "Invalid plugin structure" if root_folder.blank? + + extract_path = File.join(plugins_path, root_folder) + raise "Plugin already exists" if File.directory?(extract_path) + + zip.each do |entry| + dest = File.join(plugins_path, entry.name) + FileUtils.mkdir_p(File.dirname(dest)) + entry.extract(dest) + end + + manifest_path = File.join(extract_path, "plugin.json") + unless File.exist?(manifest_path) + FileUtils.rm_rf(extract_path) + raise "Invalid plugin: missing plugin.json" + end + end + + FileUtils.rm_f(temp_path) + + { + slug: root_folder, + path: File.join(plugins_path, root_folder), + } + end + + # ================================================================ + # Boot + # ================================================================ + + # Load all active plugins. Called once during engine initialization. + # + # @return [void] + def load_active_plugins + activated_plugins.each { |slug| load_plugin(slug) } + end + + # Load a specific plugin by requiring its main file. + # + # @param slug [String] + # @return [void] + def load_plugin(slug) + plugin_dir = File.join(plugins_path, slug) + manifest_path = File.join(plugin_dir, "plugin.json") + return unless File.exist?(manifest_path) + + manifest = parse_manifest(manifest_path) + return unless manifest + + main_file = manifest["main_file"] || "plugin.rb" + plugin_file = File.join(plugin_dir, main_file) + + if File.exist?(plugin_file) + load plugin_file + Escalated.hooks.do_action("plugin_loaded", slug, manifest) + end + rescue StandardError => e + Rails.logger.error("[Escalated::PluginService] Failed to load plugin #{slug}: #{e.message}") + end + + # ================================================================ + # Helpers + # ================================================================ + + # Absolute path to the plugins directory. + # + # @return [String] + def plugins_path + path = Escalated.configuration.plugins_path + FileUtils.mkdir_p(path) unless File.directory?(path) + path + end + + private + + def parse_manifest(path) + JSON.parse(File.read(path)) + rescue JSON::ParserError => e + Rails.logger.error("[Escalated::PluginService] Invalid plugin.json at #{path}: #{e.message}") + nil + end + + def validate_plugin_exists!(slug) + plugin_dir = File.join(plugins_path, slug) + manifest = File.join(plugin_dir, "plugin.json") + + raise "Plugin directory not found: #{slug}" unless File.directory?(plugin_dir) + raise "Plugin manifest not found: #{slug}/plugin.json" unless File.exist?(manifest) + end + end + end + end +end diff --git a/lib/escalated/services/plugin_ui_service.rb b/lib/escalated/services/plugin_ui_service.rb new file mode 100644 index 0000000..8467bd2 --- /dev/null +++ b/lib/escalated/services/plugin_ui_service.rb @@ -0,0 +1,213 @@ +module Escalated + module Services + # Service for plugins to register custom UI elements. + # + # Plugins use this to inject menus, dashboard widgets, and + # slot-based components into existing Escalated pages. + class PluginUIService + def initialize + @menu_items = [] + @dashboard_widgets = [] + @page_components = {} + end + + # ================================================================ + # Menu Items + # ================================================================ + + # Register a custom menu item. + # + # @param item [Hash] Menu item configuration + # @option item [String] :label Display label + # @option item [String] :route Named route (nil for external URL) + # @option item [String] :url External URL (nil for named route) + # @option item [String] :icon Icon identifier (SVG path or icon name) + # @option item [String] :permission Required permission (nil = visible to all) + # @option item [Integer] :position Sort order (lower = higher, default 100) + # @option item [String] :parent Parent menu label for sub-items + # @option item [String] :badge Badge text + # @option item [Array] :active_routes Route names that mark this item active + # @option item [Array] :submenu Array of submenu item hashes + # @return [void] + def add_menu_item(item) + defaults = { + label: "Custom Item", + route: nil, + url: nil, + icon: nil, + permission: nil, + position: 100, + parent: nil, + badge: nil, + active_routes: [], + submenu: [], + } + + @menu_items << defaults.merge(item) + end + + # Register multiple menu items at once. + # + # @param items [Array] + # @return [void] + def add_menu_items(items) + items.each { |item| add_menu_item(item) } + end + + # Add a submenu item to an existing parent menu item. + # + # @param parent_label [String] The label of the parent menu item + # @param submenu_item [Hash] Submenu item configuration + # @return [void] + def add_submenu_item(parent_label, submenu_item) + defaults = { + label: "Submenu Item", + route: nil, + url: nil, + icon: nil, + permission: nil, + active_routes: [], + } + + merged = defaults.merge(submenu_item) + + parent = @menu_items.find { |m| m[:label] == parent_label } + if parent + parent[:submenu] ||= [] + parent[:submenu] << merged + end + end + + # Get all registered menu items, sorted by position. + # + # @return [Array] + def menu_items + @menu_items.sort_by { |m| m[:position] } + end + + # ================================================================ + # Dashboard Widgets + # ================================================================ + + # Register a dashboard widget. + # + # @param widget [Hash] Widget configuration + # @option widget [String] :id Unique identifier + # @option widget [String] :title Widget title + # @option widget [String] :component Vue component name + # @option widget [Hash] :data Static data passed as props + # @option widget [Integer] :position Sort order (default 100) + # @option widget [String] :width 'full', 'half', 'third', 'quarter' + # @option widget [String] :permission Required permission + # @return [void] + def add_dashboard_widget(widget) + defaults = { + id: "widget_#{SecureRandom.hex(4)}", + title: "Custom Widget", + component: nil, + data: {}, + position: 100, + width: "full", + permission: nil, + } + + @dashboard_widgets << defaults.merge(widget) + end + + # Get all registered dashboard widgets, sorted by position. + # + # @return [Array] + def dashboard_widgets + @dashboard_widgets.sort_by { |w| w[:position] } + end + + # ================================================================ + # Page Components (Slots) + # ================================================================ + + # Register a component to be injected into an existing page slot. + # + # @param page [String] Page identifier (e.g. 'ticket.show', 'dashboard') + # @param slot [String] Slot name (e.g. 'sidebar', 'header', 'footer', 'tabs') + # @param component [Hash] Component configuration + # @option component [String] :component Vue component name + # @option component [String] :plugin Plugin slug that registered this + # @option component [Hash] :data Static data passed as props + # @option component [Integer] :position Sort order (default 100) + # @option component [String] :permission Required permission + # @return [void] + def add_page_component(page, slot, component) + defaults = { + component: nil, + plugin: nil, + data: {}, + position: 100, + permission: nil, + } + + @page_components[page] ||= {} + @page_components[page][slot] ||= [] + @page_components[page][slot] << defaults.merge(component) + end + + # Get components for a specific page and slot, sorted by position. + # + # @param page [String] + # @param slot [String] + # @return [Array] + def page_components(page, slot) + components = @page_components.dig(page, slot) || [] + components.sort_by { |c| c[:position] } + end + + # Get all components registered for a given page. + # + # @param page [String] + # @return [Hash{String => Array}] + def all_page_components(page) + @page_components[page] || {} + end + + # ================================================================ + # Serialization (for Inertia shared data) + # ================================================================ + + # Serialize all plugin UI data for sharing with the frontend. + # + # @return [Hash] + def to_shared_data + { + menu_items: menu_items, + dashboard_widgets: dashboard_widgets, + page_components: serialized_page_components, + } + end + + # ================================================================ + # Housekeeping + # ================================================================ + + # Clear all registered UI elements (useful for testing). + # + # @return [void] + def clear! + @menu_items = [] + @dashboard_widgets = [] + @page_components = {} + end + + private + + def serialized_page_components + result = {} + @page_components.each do |page, slots| + result[page] = {} + slots.each do |slot, components| + result[page][slot] = components.sort_by { |c| c[:position] } + end + end + result + end + end + end +end diff --git a/lib/escalated/support/hook_manager.rb b/lib/escalated/support/hook_manager.rb new file mode 100644 index 0000000..3e5f67f --- /dev/null +++ b/lib/escalated/support/hook_manager.rb @@ -0,0 +1,166 @@ +module Escalated + module Support + class HookManager + def initialize + @actions = {} + @filters = {} + end + + # ================================================================ + # Actions + # ================================================================ + + # Add an action hook. + # + # @param tag [String] The action name + # @param callback [#call, nil] A callable, or nil when using a block + # @param priority [Integer] Lower numbers run first (default 10) + # @yield Optional block used as callback when no callable is given + # @return [void] + def add_action(tag, callback = nil, priority: 10, &block) + cb = callback || block + raise ArgumentError, "add_action requires a callback or block" unless cb + + @actions[tag] ||= {} + @actions[tag][priority] ||= [] + @actions[tag][priority] << cb + end + + # Execute all callbacks registered for an action. + # + # @param tag [String] The action name + # @param args [Array] Arguments forwarded to each callback + # @return [void] + def do_action(tag, *args) + return unless @actions.key?(tag) + + @actions[tag].sort.each do |_priority, callbacks| + callbacks.each { |cb| cb.call(*args) } + end + end + + # Check whether an action has any registered callbacks. + # + # @param tag [String] + # @return [Boolean] + def has_action?(tag) + @actions.key?(tag) && !@actions[tag].empty? + end + + # Remove callbacks for an action. + # + # When +callback+ is nil every callback for the tag is removed. + # When +callback+ is given only that specific callable is removed. + # + # @param tag [String] + # @param callback [#call, nil] + # @return [void] + def remove_action(tag, callback = nil) + if callback.nil? + @actions.delete(tag) + return + end + + return unless @actions.key?(tag) + + @actions[tag].each do |priority, callbacks| + callbacks.reject! { |cb| cb == callback } + @actions[tag].delete(priority) if callbacks.empty? + end + + @actions.delete(tag) if @actions[tag]&.empty? + end + + # ================================================================ + # Filters + # ================================================================ + + # Add a filter hook. + # + # Filters are identical to actions except the first argument is the + # *value* being filtered and the return value of each callback + # replaces it for the next callback in the chain. + # + # @param tag [String] + # @param callback [#call, nil] + # @param priority [Integer] + # @yield Optional block used as callback + # @return [void] + def add_filter(tag, callback = nil, priority: 10, &block) + cb = callback || block + raise ArgumentError, "add_filter requires a callback or block" unless cb + + @filters[tag] ||= {} + @filters[tag][priority] ||= [] + @filters[tag][priority] << cb + end + + # Apply all filter callbacks to a value. + # + # @param tag [String] The filter name + # @param value [Object] The value to filter + # @param args [Array] Additional arguments forwarded to callbacks + # @return [Object] The filtered value + def apply_filters(tag, value, *args) + return value unless @filters.key?(tag) + + @filters[tag].sort.each do |_priority, callbacks| + callbacks.each { |cb| value = cb.call(value, *args) } + end + + value + end + + # Check whether a filter has any registered callbacks. + # + # @param tag [String] + # @return [Boolean] + def has_filter?(tag) + @filters.key?(tag) && !@filters[tag].empty? + end + + # Remove callbacks for a filter. + # + # @param tag [String] + # @param callback [#call, nil] + # @return [void] + def remove_filter(tag, callback = nil) + if callback.nil? + @filters.delete(tag) + return + end + + return unless @filters.key?(tag) + + @filters[tag].each do |priority, callbacks| + callbacks.reject! { |cb| cb == callback } + @filters[tag].delete(priority) if callbacks.empty? + end + + @filters.delete(tag) if @filters[tag]&.empty? + end + + # ================================================================ + # Introspection + # ================================================================ + + # @return [Hash] all registered actions + def actions + @actions + end + + # @return [Hash] all registered filters + def filters + @filters + end + + # Reset all hooks (useful for testing). + # + # @return [void] + def clear! + @actions = {} + @filters = {} + end + end + end +end diff --git a/lib/generators/escalated/templates/initializer.rb b/lib/generators/escalated/templates/initializer.rb index 24bd3f8..670abcb 100644 --- a/lib/generators/escalated/templates/initializer.rb +++ b/lib/generators/escalated/templates/initializer.rb @@ -81,6 +81,16 @@ # Webhook URL for external integrations (nil to disable) config.webhook_url = nil + # ============================================================ + # Plugin System + # ============================================================ + # Enable the plugin system (default: false) + config.plugins_enabled = false + + # Directory where plugins are installed + # Defaults to Rails.root.join("plugins/escalated") when nil + # config.plugins_path = Rails.root.join("plugins", "escalated").to_s + # ============================================================ # Cloud Configuration (only for :synced and :cloud modes) # ============================================================ diff --git a/stubs/plugins/hello-world/plugin.json b/stubs/plugins/hello-world/plugin.json new file mode 100644 index 0000000..9c556dc --- /dev/null +++ b/stubs/plugins/hello-world/plugin.json @@ -0,0 +1,9 @@ +{ + "name": "Hello World Plugin", + "description": "A friendly example plugin that demonstrates the Escalated plugin system. Shows how to use hooks, filters, and UI extensions. Feel free to delete it anytime!", + "version": "1.0.0", + "author": "Escalated Team", + "author_url": "https://escalated.dev", + "requires": "0.4.0", + "main_file": "plugin.rb" +} diff --git a/stubs/plugins/hello-world/plugin.rb b/stubs/plugins/hello-world/plugin.rb new file mode 100644 index 0000000..c23e599 --- /dev/null +++ b/stubs/plugins/hello-world/plugin.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +# Hello World Plugin for Escalated +# +# This plugin demonstrates the Escalated plugin system. It registers +# a few harmless hooks, logs activity, and injects a banner component +# on the dashboard. Delete it whenever you like! +# +# Demonstrates: +# - Action hooks (lifecycle + domain events) +# - Filter hooks (modifying data in the pipeline) +# - UI extensions (dashboard widget, page component) +# - Plugin lifecycle events (activate, deactivate, uninstall) + +# ======================================== +# LIFECYCLE HOOKS +# ======================================== + +# Runs when the plugin is activated +Escalated.hooks.add_action("plugin_activated_hello-world") do + Rails.logger.info "[HelloWorld] Plugin activated! Ready to do... nothing useful." +end + +# Runs when the plugin is deactivated +Escalated.hooks.add_action("plugin_deactivated_hello-world") do + Rails.logger.info "[HelloWorld] Plugin deactivated. We had a good run!" +end + +# Runs when the plugin is being deleted +Escalated.hooks.add_action("plugin_uninstalling_hello-world") do + Rails.logger.info "[HelloWorld] Plugin is being deleted. Goodbye!" +end + +# ======================================== +# REGULAR PLUGIN CODE +# ======================================== + +# Log when this plugin file is loaded +Escalated.hooks.add_action("plugin_loaded") do |slug, manifest| + if slug == "hello-world" + Rails.logger.info "[HelloWorld] Loaded v#{manifest['version'] || 'unknown'}." + + # Register a banner component on the dashboard header slot + Escalated.plugin_ui.add_page_component("dashboard", "header", + component: "HelloWorldBanner", + plugin: "hello-world", + position: 1, + ) + end +end + +# ======================================== +# EXAMPLES (uncomment to try!) +# ======================================== + +# Example 1: Log when tickets are created +# Escalated.hooks.add_action("ticket_created") do |ticket| +# Rails.logger.info "[HelloWorld] A ticket was born! #{ticket.reference}" +# end + +# Example 2: Add custom data to dashboard stats +# Escalated.hooks.add_filter("dashboard_stats_data") do |stats, _user| +# stats.merge(hello_world_counter: rand(1..100)) +# end + +# Example 3: Add a custom menu item +# Escalated.plugin_ui.add_menu_item( +# label: "Hello World", +# route: "dashboard", +# icon: "hand-wave", +# position: 999, +# ) + +# Example 4: Add a dashboard widget +# Escalated.plugin_ui.add_dashboard_widget( +# id: "hello_world_widget", +# title: "Hello World", +# component: "HelloWorldWidget", +# data: { message: "Hello from the plugin system!" }, +# position: 999, +# width: "half", +# ) + +# Example 5: React to status changes +# Escalated.hooks.add_action("ticket_status_changed") do |ticket, old_status, new_status, _actor| +# Rails.logger.info "[HelloWorld] Ticket #{ticket.reference}: #{old_status} -> #{new_status}" +# end From ed09dc55b3d0a11ac2beb5d3daa19ffe2413f69e Mon Sep 17 00:00:00 2001 From: Matt Gros <3311227+mpge@users.noreply.github.com> Date: Wed, 11 Feb 2026 00:48:28 -0500 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20plugin=20system=20refactor=20?= =?UTF-8?q?=E2=80=94=20lib/escalated/plugins,=20gem=20discovery,=20source?= =?UTF-8?q?=20badges?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move default plugin path to lib/escalated/plugins (Rails convention) - Add gem-based plugin discovery via Gem::Specification - Add source field (local/composer) to all plugin data - Guard deletion of gem-sourced plugins in service and controller - Add resolve_plugin_path for dual-source path resolution - Bump version to 0.5.0 --- .../escalated/admin/plugins_controller.rb | 11 ++ escalated.gemspec | 2 +- lib/escalated/configuration.rb | 2 +- lib/escalated/engine.rb | 4 +- lib/escalated/services/plugin_service.rb | 135 +++++++++++++----- .../escalated/templates/initializer.rb | 4 +- 6 files changed, 117 insertions(+), 41 deletions(-) diff --git a/app/controllers/escalated/admin/plugins_controller.rb b/app/controllers/escalated/admin/plugins_controller.rb index b64afb6..9ae06a2 100644 --- a/app/controllers/escalated/admin/plugins_controller.rb +++ b/app/controllers/escalated/admin/plugins_controller.rb @@ -58,6 +58,16 @@ def deactivate def destroy begin + # Check if plugin is gem-sourced before attempting delete + all_plugins = Escalated::Services::PluginService.all_plugins + plugin_data = all_plugins.find { |p| p[:slug] == params[:id] } + + if plugin_data && plugin_data[:source] == :composer + redirect_back fallback_location: admin_plugins_path, + alert: "Gem plugins cannot be deleted. Remove the gem via Bundler instead." + return + end + Escalated::Services::PluginService.delete_plugin(params[:id]) redirect_back fallback_location: admin_plugins_path, @@ -86,6 +96,7 @@ def plugin_json(plugin) requires: plugin[:requires], is_active: plugin[:is_active], activated_at: plugin[:activated_at]&.iso8601, + source: (plugin[:source] || :local).to_s, } end end diff --git a/escalated.gemspec b/escalated.gemspec index acdd284..390b9cc 100644 --- a/escalated.gemspec +++ b/escalated.gemspec @@ -1,6 +1,6 @@ Gem::Specification.new do |spec| spec.name = "escalated" - spec.version = "0.4.0" + spec.version = "0.5.0" spec.authors = ["Escalated Dev"] spec.email = ["hello@escalated.dev"] spec.summary = "Embeddable support ticket system for Rails" diff --git a/lib/escalated/configuration.rb b/lib/escalated/configuration.rb index 4d46976..d6e9e88 100644 --- a/lib/escalated/configuration.rb +++ b/lib/escalated/configuration.rb @@ -69,7 +69,7 @@ def initialize # Plugin system defaults @plugins_enabled = false - @plugins_path = nil # Set at boot time if nil (defaults to Rails.root.join("plugins/escalated")) + @plugins_path = nil # Set at boot time if nil (defaults to Rails.root.join("lib/escalated/plugins")) # Inbound email defaults @inbound_email_enabled = false diff --git a/lib/escalated/engine.rb b/lib/escalated/engine.rb index f2597d8..6d0ac9e 100644 --- a/lib/escalated/engine.rb +++ b/lib/escalated/engine.rb @@ -37,12 +37,12 @@ class Engine < ::Rails::Engine end end - # Set default plugins_path to Rails.root/plugins/escalated when not + # Set default plugins_path to Rails.root/lib/escalated/plugins when not # explicitly configured. Must run after the host app has booted so # Rails.root is available. initializer "escalated.plugins_path", after: :load_config_initializers do |app| if Escalated.configuration.plugins_path.nil? - Escalated.configuration.plugins_path = app.root.join("plugins", "escalated").to_s + Escalated.configuration.plugins_path = app.root.join("lib", "escalated", "plugins").to_s end end diff --git a/lib/escalated/services/plugin_service.rb b/lib/escalated/services/plugin_service.rb index 6fb8d31..c84707c 100644 --- a/lib/escalated/services/plugin_service.rb +++ b/lib/escalated/services/plugin_service.rb @@ -10,38 +10,12 @@ class << self # ================================================================ # Get all installed plugins with their metadata, merged with - # database activation state. + # database activation state. Combines local (filesystem) plugins + # and gem-based plugins. # # @return [Array] def all_plugins - plugins = [] - - Dir.glob(File.join(plugins_path, "*")).select { |f| File.directory?(f) }.each do |directory| - slug = File.basename(directory) - manifest_path = File.join(directory, "plugin.json") - next unless File.exist?(manifest_path) - - manifest = parse_manifest(manifest_path) - next unless manifest - - db_plugin = Escalated::Plugin.find_by(slug: slug) - - plugins << { - slug: slug, - name: manifest["name"] || slug.titleize, - description: manifest["description"] || "", - version: manifest["version"] || "1.0.0", - author: manifest["author"] || "Unknown", - author_url: manifest["author_url"] || "", - requires: manifest["requires"] || "1.0.0", - main_file: manifest["main_file"] || "plugin.rb", - is_active: db_plugin&.is_active || false, - activated_at: db_plugin&.activated_at, - path: directory, - } - end - - plugins + local_plugins + gem_plugins end # Return slugs of all currently activated plugins. @@ -116,11 +90,18 @@ def deactivate_plugin(slug) # Delete a plugin entirely. # # Fires uninstall hooks, deactivates, removes the database record, - # and deletes the plugin directory from disk. + # and deletes the plugin directory from disk. Gem-sourced plugins + # cannot be deleted -- remove them via Bundler instead. # # @param slug [String] # @return [Boolean] def delete_plugin(slug) + all = all_plugins + plugin_data = all.find { |p| p[:slug] == slug } + if plugin_data && plugin_data[:source] == :composer + raise "Gem plugins cannot be deleted. Remove the gem via Bundler instead." + end + plugin_dir = File.join(plugins_path, slug) return false unless File.directory?(plugin_dir) @@ -207,10 +188,14 @@ def load_active_plugins # Load a specific plugin by requiring its main file. # + # Resolves the plugin path from both local and gem sources. + # # @param slug [String] # @return [void] def load_plugin(slug) - plugin_dir = File.join(plugins_path, slug) + plugin_dir = resolve_plugin_path(slug) + return unless plugin_dir + manifest_path = File.join(plugin_dir, "plugin.json") return unless File.exist?(manifest_path) @@ -251,11 +236,91 @@ def parse_manifest(path) end def validate_plugin_exists!(slug) - plugin_dir = File.join(plugins_path, slug) - manifest = File.join(plugin_dir, "plugin.json") + plugin_dir = resolve_plugin_path(slug) + raise "Plugin not found: #{slug}" unless plugin_dir + raise "Plugin manifest not found: #{slug}/plugin.json" unless File.exist?(File.join(plugin_dir, "plugin.json")) + end + + def local_plugins + plugins = [] + + Dir.glob(File.join(plugins_path, "*")).select { |f| File.directory?(f) }.each do |directory| + slug = File.basename(directory) + manifest_path = File.join(directory, "plugin.json") + next unless File.exist?(manifest_path) + + manifest = parse_manifest(manifest_path) + next unless manifest + + db_plugin = Escalated::Plugin.find_by(slug: slug) - raise "Plugin directory not found: #{slug}" unless File.directory?(plugin_dir) - raise "Plugin manifest not found: #{slug}/plugin.json" unless File.exist?(manifest) + plugins << { + slug: slug, + name: manifest["name"] || slug.titleize, + description: manifest["description"] || "", + version: manifest["version"] || "1.0.0", + author: manifest["author"] || "Unknown", + author_url: manifest["author_url"] || "", + requires: manifest["requires"] || "1.0.0", + main_file: manifest["main_file"] || "plugin.rb", + is_active: db_plugin&.is_active || false, + activated_at: db_plugin&.activated_at, + path: directory, + source: :local, + } + end + + plugins + end + + def gem_plugins + plugins = [] + Gem::Specification.each do |spec| + manifest_path = File.join(spec.gem_dir, "plugin.json") + next unless File.exist?(manifest_path) + + manifest = parse_manifest(manifest_path) + next unless manifest + + slug = spec.name + db_plugin = Escalated::Plugin.find_by(slug: slug) + + plugins << { + slug: slug, + name: manifest["name"] || slug.titleize, + description: manifest["description"] || "", + version: manifest["version"] || "1.0.0", + author: manifest["author"] || "Unknown", + author_url: manifest["author_url"] || "", + requires: manifest["requires"] || "1.0.0", + main_file: manifest["main_file"] || "plugin.rb", + is_active: db_plugin&.is_active || false, + activated_at: db_plugin&.activated_at, + path: spec.gem_dir, + source: :composer, # Use :composer for consistency with frontend + } + end + plugins + rescue => e + Rails.logger.debug("[Escalated::PluginService] Could not scan gems: #{e.message}") + [] + end + + def resolve_plugin_path(slug) + # Check local plugins first + local_path = File.join(plugins_path, slug) + return local_path if File.exist?(File.join(local_path, "plugin.json")) + + # Check gem plugins + begin + spec = Gem::Specification.find_by_name(slug) + gem_path = spec.gem_dir + return gem_path if File.exist?(File.join(gem_path, "plugin.json")) + rescue Gem::MissingSpecError + # Not a gem plugin + end + + nil end end end diff --git a/lib/generators/escalated/templates/initializer.rb b/lib/generators/escalated/templates/initializer.rb index 670abcb..3ad1eb1 100644 --- a/lib/generators/escalated/templates/initializer.rb +++ b/lib/generators/escalated/templates/initializer.rb @@ -88,8 +88,8 @@ config.plugins_enabled = false # Directory where plugins are installed - # Defaults to Rails.root.join("plugins/escalated") when nil - # config.plugins_path = Rails.root.join("plugins", "escalated").to_s + # Defaults to Rails.root.join("lib/escalated/plugins") when nil + # config.plugins_path = Rails.root.join("lib", "escalated", "plugins").to_s # ============================================================ # Cloud Configuration (only for :synced and :cloud modes) From 30c62811c6296f0a54e216775b62373a6d4dafdf Mon Sep 17 00:00:00 2001 From: Matt Gros <3311227+mpge@users.noreply.github.com> Date: Wed, 11 Feb 2026 00:59:30 -0500 Subject: [PATCH 3/3] docs: add plugin authoring guide --- docs/plugins.md | 343 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 343 insertions(+) create mode 100644 docs/plugins.md diff --git a/docs/plugins.md b/docs/plugins.md new file mode 100644 index 0000000..be8d999 --- /dev/null +++ b/docs/plugins.md @@ -0,0 +1,343 @@ +# Building Plugins + +Plugins extend Escalated with custom functionality using a WordPress-style hook system. Plugins can be distributed as ZIP files (uploaded via the admin panel) or as Ruby gems. + +## Plugin Structure + +A minimal plugin needs two files: + +``` +my-plugin/ + plugin.json # Manifest (required) + plugin.rb # Entry point (required) +``` + +### plugin.json + +```json +{ + "name": "My Plugin", + "slug": "my-plugin", + "description": "A short description of what this plugin does.", + "version": "1.0.0", + "author": "Your Name", + "author_url": "https://example.com", + "requires": "1.0.0", + "main_file": "plugin.rb" +} +``` + +| Field | Required | Description | +|-------|----------|-------------| +| `name` | Yes | Human-readable plugin name | +| `slug` | Yes | Unique identifier (lowercase, hyphens only) | +| `description` | No | Short description shown in the admin panel | +| `version` | Yes | Semver version string | +| `author` | No | Author name | +| `author_url` | No | Author website URL | +| `requires` | No | Minimum Escalated version required | +| `main_file` | No | Entry point filename (defaults to `plugin.rb`) | + +### plugin.rb + +The main file is loaded via `require` when the plugin is activated. Use it to register hooks: + +```ruby +# Runs every time a ticket is created +Escalated.hooks.add_action("ticket_created") do |ticket| + # Send a Slack notification, create a Jira issue, etc. + Rails.logger.info("New ticket: #{ticket.reference}") +end + +# Modify ticket data before it's saved +Escalated.hooks.add_filter("ticket_data") do |data| + data[:custom_field] = "value" + data +end +``` + +## Distribution Methods + +### ZIP Upload (Local Plugins) + +1. Create a ZIP file containing your plugin folder at the root: + ``` + my-plugin.zip + └── my-plugin/ + ├── plugin.json + └── plugin.rb + ``` +2. Go to **Admin > Plugins** and upload the ZIP file. +3. Click **Inactive** to activate the plugin. + +Uploaded plugins are stored in `lib/escalated/plugins/`. + +### Gem Package + +Any gem that includes a `plugin.json` at its root is automatically detected: + +``` +gem install escalated-billing +``` + +Or add to your Gemfile: + +```ruby +gem "escalated-billing" +``` + +The gem just needs a `plugin.json` alongside its gemspec: + +``` +gems/escalated-billing/ + escalated-billing.gemspec + plugin.json # ← Escalated detects this + lib/ + escalated/ + billing/ + plugin.rb + ... +``` + +Gem plugins appear in the admin panel with a **composer** badge. They cannot be deleted from the UI — use `bundle remove` instead. + +**Gem plugin slugs** are derived from the gem name: `escalated-billing` stays `escalated-billing`. + +## Hook API + +### Action Hooks + +Actions let you run code when something happens. They don't return a value. + +```ruby +# Register an action +Escalated.hooks.add_action(tag, priority: 10) { |*args| ... } + +# Fire an action (used internally by Escalated) +Escalated.hooks.do_action(tag, *args) + +# Check if an action has callbacks +Escalated.hooks.has_action?(tag) + +# Remove an action +Escalated.hooks.remove_action(tag, callback = nil) +``` + +### Filter Hooks + +Filters let you modify data as it passes through the system. Callbacks receive the current value and must return the modified value. + +```ruby +# Register a filter +Escalated.hooks.add_filter(tag, priority: 10) { |value, *args| value } + +# Apply filters (used internally by Escalated) +Escalated.hooks.apply_filters(tag, value, *args) + +# Check if a filter has callbacks +Escalated.hooks.has_filter?(tag) + +# Remove a filter +Escalated.hooks.remove_filter(tag, callback = nil) +``` + +### Priority + +Lower numbers run first. The default priority is `10`. Use lower values (e.g. `5`) to run before other callbacks, or higher values (e.g. `20`) to run after. + +```ruby +# This runs first +Escalated.hooks.add_action("ticket_created", priority: 5) do |ticket| + # early processing +end + +# This runs second +Escalated.hooks.add_action("ticket_created", priority: 20) do |ticket| + # later processing +end +``` + +## Available Hooks + +### Plugin Lifecycle + +| Hook | Args | When | +|------|------|------| +| `plugin_loaded` | `slug, manifest` | Plugin file is loaded | +| `plugin_activated` | `slug` | Plugin is activated | +| `plugin_activated_{slug}` | — | Your specific plugin is activated | +| `plugin_deactivated` | `slug` | Plugin is deactivated | +| `plugin_deactivated_{slug}` | — | Your specific plugin is deactivated | +| `plugin_uninstalling` | `slug` | Plugin is about to be deleted | +| `plugin_uninstalling_{slug}` | — | Your specific plugin is about to be deleted | + +Use the `{slug}` variants to run code only for your own plugin: + +```ruby +Escalated.hooks.add_action("plugin_activated_my-plugin") do + # Run migrations, seed data, etc. +end + +Escalated.hooks.add_action("plugin_uninstalling_my-plugin") do + # Clean up database tables, cached files, etc. +end +``` + +## UI Helpers + +Plugins can register UI elements that appear in the Escalated interface. + +### Menu Items + +```ruby +Escalated.plugin_ui.add_menu_item({ + label: "Billing", + url: "/support/admin/billing", + icon: "M2.25 8.25h19.5M2.25 9h19.5m-16.5...", # Heroicon SVG path + section: "admin", # 'admin', 'agent', or 'customer' + priority: 50 +}) +``` + +### Custom Pages + +```ruby +Escalated.plugin_ui.register_page( + "admin/billing", # Route path + "Escalated/Admin/Billing", # Inertia component + { middleware: ["auth"] } # Options +) +``` + +### Dashboard Widgets + +```ruby +Escalated.plugin_ui.add_dashboard_widget({ + id: "billing-summary", + label: "Billing Summary", + component: "BillingSummaryWidget", + section: "agent", + priority: 10 +}) +``` + +### Page Components (Slots) + +Inject components into existing pages: + +```ruby +Escalated.plugin_ui.add_page_component( + "ticket-detail", # Page identifier + "sidebar", # Slot name + { + component: "BillingInfo", + props: { show_total: true }, + priority: 10 + } +) +``` + +## Full Example: Slack Notifier Plugin + +``` +slack-notifier/ + plugin.json + plugin.rb +``` + +**plugin.json:** +```json +{ + "name": "Slack Notifier", + "slug": "slack-notifier", + "description": "Posts a message to Slack when a new ticket is created.", + "version": "1.0.0", + "author": "Acme Corp", + "main_file": "plugin.rb" +} +``` + +**plugin.rb:** +```ruby +require "net/http" +require "json" + +Escalated.hooks.add_action("plugin_activated_slack-notifier") do + Rails.logger.info("Slack Notifier plugin activated") +end + +Escalated.hooks.add_action("ticket_created") do |ticket| + webhook_url = Rails.application.credentials.dig(:slack, :webhook_url) + next unless webhook_url + + uri = URI(webhook_url) + Net::HTTP.post(uri, { text: "New ticket *#{ticket.reference}*: #{ticket.subject}" }.to_json, "Content-Type" => "application/json") +end + +Escalated.hooks.add_action("plugin_uninstalling_slack-notifier") do + Rails.logger.info("Slack Notifier plugin uninstalled") +end +``` + +## Full Example: Gem Package + +A gem-distributed plugin follows the same conventions. Your gemspec and `plugin.json` live side by side: + +**escalated-billing.gemspec:** +```ruby +Gem::Specification.new do |spec| + spec.name = "escalated-billing" + spec.version = "2.0.0" + spec.authors = ["Acme Corp"] + spec.email = ["dev@acme.com"] + spec.summary = "Billing integration for Escalated" + spec.description = "Adds billing and invoicing to Escalated." + spec.homepage = "https://github.com/acme/escalated-billing" + spec.license = "MIT" + + spec.files = Dir["{lib}/**/*", "plugin.json", "plugin.rb", "LICENSE", "README.md"] + spec.require_paths = ["lib"] + + spec.add_dependency "rails", ">= 7.0" +end +``` + +**plugin.json:** +```json +{ + "name": "Billing Integration", + "slug": "escalated-billing", + "description": "Adds billing and invoicing to Escalated.", + "version": "2.0.0", + "author": "Acme Corp", + "main_file": "plugin.rb" +} +``` + +**plugin.rb:** +```ruby +require "escalated/billing" + +Escalated.hooks.add_action("ticket_created") do |ticket| + Escalated::Billing::Service.new.track_ticket(ticket) +end + +Escalated.plugin_ui.add_menu_item({ + label: "Billing", + url: "/support/admin/billing", + icon: "M2.25 18.75a60.07 60.07 0 0115.797 2.101c.727.198 1.453-.342 1.453-1.096V18.75M3.75 4.5v.75A.75.75 0 013 6h-.75m0 0v-.375c0-.621.504-1.125 1.125-1.125H20.25M2.25 6v9m18-10.5v.75c0 .414.336.75.75.75h.75m-1.5-1.5h.375c.621 0 1.125.504 1.125 1.125v9.75c0 .621-.504 1.125-1.125 1.125h-.375m1.5-1.5H21a.75.75 0 00-.75.75v.75m0 0H3.75m0 0h-.375a1.125 1.125 0 01-1.125-1.125V15m1.5 1.5v-.75A.75.75 0 003 15h-.75M15 10.5a3 3 0 11-6 0 3 3 0 016 0zm3 0h.008v.008H18V10.5zm-12 0h.008v.008H6V10.5z", + section: "admin", + priority: 50 +}) +``` + +Since the gem handles autoloading, your `plugin.rb` can use classes from `lib/` without any manual `require` statements. + +## Tips + +- **Keep plugin.rb lightweight.** Register hooks and delegate to service classes. +- **Use activation hooks** to run migrations or seed data on first activation. +- **Use uninstall hooks** to clean up database tables when your plugin is removed. +- **Namespace your hooks** to avoid collisions: `myplugin_custom_action`. +- **Test locally** by placing your plugin folder in `lib/escalated/plugins/` and activating it from the admin panel. +- **Gem plugins** benefit from Bundler's dependency management, testing infrastructure, and version management via RubyGems.