diff --git a/config/escalated.ts b/config/escalated.ts index aa867c2..bddaaaa 100644 --- a/config/escalated.ts +++ b/config/escalated.ts @@ -154,6 +154,20 @@ const escalatedConfig: EscalatedConfig = { }, }, + /* + |-------------------------------------------------------------------------- + | Plugins + |-------------------------------------------------------------------------- + | + | Enable the WordPress-style plugin/extension system. Plugins are + | discovered from the configured path relative to the app root. + | + */ + plugins: { + enabled: !!env.get('ESCALATED_PLUGINS_ENABLED', true), + path: 'app/plugins/escalated', + }, + /* |-------------------------------------------------------------------------- | Activity Log diff --git a/database/migrations/0018_create_escalated_plugins_table.ts b/database/migrations/0018_create_escalated_plugins_table.ts new file mode 100644 index 0000000..4172e0a --- /dev/null +++ b/database/migrations/0018_create_escalated_plugins_table.ts @@ -0,0 +1,21 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' + +export default class CreateEscalatedPlugins extends BaseSchema { + protected tableName = 'escalated_plugins' + + async up() { + this.schema.createTable(this.tableName, (table) => { + table.increments('id') + table.string('slug').unique().notNullable() + table.boolean('is_active').defaultTo(false) + table.timestamp('activated_at', { useTz: true }).nullable() + table.timestamp('deactivated_at', { useTz: true }).nullable() + table.timestamp('created_at', { useTz: true }).notNullable() + table.timestamp('updated_at', { useTz: true }).notNullable() + }) + } + + async down() { + this.schema.dropTableIfExists(this.tableName) + } +} diff --git a/docs/plugins.md b/docs/plugins.md new file mode 100644 index 0000000..2ca2946 --- /dev/null +++ b/docs/plugins.md @@ -0,0 +1,348 @@ +# 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 npm packages. + +## Plugin Structure + +A minimal plugin needs two files: + +``` +my-plugin/ + plugin.json # Manifest (required) + plugin.ts # 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.ts" +} +``` + +| 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.ts`) | + +### plugin.ts + +The main file is loaded when the plugin is activated. Use it to register hooks: + +```typescript +import { escalated_addAction, escalated_addFilter } from '@escalated-dev/escalated-adonis/support/helpers' + +// Runs every time a ticket is created +escalated_addAction('ticket_created', async (ticket) => { + // Send a Slack notification, create a Jira issue, etc. + console.log(`New ticket: ${ticket.reference}`) +}) + +// Modify ticket data before it's saved +escalated_addFilter('ticket_data', async (data) => { + data.custom_field = 'value' + return data +}) +``` + +## 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.ts + ``` +2. Go to **Admin > Plugins** and upload the ZIP file. +3. Click **Inactive** to activate the plugin. + +Uploaded plugins are stored in `app/plugins/escalated/`. + +### npm Package + +Any npm package that includes a `plugin.json` at its root is automatically detected, including scoped packages: + +``` +npm install @acme/escalated-billing +``` + +The package just needs a `plugin.json` alongside its `package.json`: + +``` +node_modules/@acme/escalated-billing/ + package.json + plugin.json # ← Escalated detects this + plugin.ts + src/ + ... +``` + +npm plugins appear in the admin panel with a **composer** badge. They cannot be deleted from the UI — use `npm uninstall` instead. + +**npm plugin slugs** are derived from the package name: +- `escalated-billing` stays as `escalated-billing` +- `@acme/escalated-billing` becomes `@acme--escalated-billing` + +## Hook API + +### Action Hooks + +Actions let you run code when something happens. They don't return a value. + +```typescript +import { + escalated_addAction, + escalated_hasAction, + escalated_removeAction, +} from '@escalated-dev/escalated-adonis/support/helpers' + +// Register an action +escalated_addAction(tag: string, callback: (...args: any[]) => Promise, priority?: number): void + +// Check if an action has callbacks +escalated_hasAction(tag: string): boolean + +// Remove an action +escalated_removeAction(tag: string, callback?: Function): void +``` + +### Filter Hooks + +Filters let you modify data as it passes through the system. Callbacks receive the current value and must return the modified value. + +```typescript +import { + escalated_addFilter, + escalated_hasFilter, + escalated_removeFilter, +} from '@escalated-dev/escalated-adonis/support/helpers' + +// Register a filter +escalated_addFilter(tag: string, callback: (value: any, ...args: any[]) => Promise, priority?: number): void + +// Check if a filter has callbacks +escalated_hasFilter(tag: string): boolean + +// Remove a filter +escalated_removeFilter(tag: string, callback?: Function): void +``` + +### 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. + +```typescript +// This runs first +escalated_addAction('ticket_created', async (ticket) => { + // early processing +}, 5) + +// This runs second +escalated_addAction('ticket_created', async (ticket) => { + // later processing +}, 20) +``` + +## 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: + +```typescript +escalated_addAction('plugin_activated_my-plugin', async () => { + // Run migrations, seed data, etc. +}) + +escalated_addAction('plugin_uninstalling_my-plugin', async () => { + // Clean up database tables, cached files, etc. +}) +``` + +## UI Helpers + +Plugins can register UI elements that appear in the Escalated interface. + +### Menu Items + +```typescript +import { escalated_registerMenuItem } from '@escalated-dev/escalated-adonis/support/helpers' + +escalated_registerMenuItem({ + 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, +}) +``` + +### Dashboard Widgets + +```typescript +import { escalated_registerDashboardWidget } from '@escalated-dev/escalated-adonis/support/helpers' + +escalated_registerDashboardWidget({ + id: 'billing-summary', + label: 'Billing Summary', + component: 'BillingSummaryWidget', + section: 'agent', + priority: 10, +}) +``` + +### Page Components (Slots) + +Inject components into existing pages: + +```typescript +import { escalated_addPageComponent } from '@escalated-dev/escalated-adonis/support/helpers' + +escalated_addPageComponent( + '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.ts +``` + +**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.ts" +} +``` + +**plugin.ts:** +```typescript +import { escalated_addAction } from '@escalated-dev/escalated-adonis/support/helpers' +import env from '#start/env' + +escalated_addAction('plugin_activated_slack-notifier', async () => { + console.log('Slack Notifier plugin activated') +}) + +escalated_addAction('ticket_created', async (ticket) => { + const webhookUrl = env.get('SLACK_WEBHOOK_URL') + + if (!webhookUrl) { + return + } + + await fetch(webhookUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + text: `New ticket *${ticket.reference}*: ${ticket.subject}`, + }), + }) +}) + +escalated_addAction('plugin_uninstalling_slack-notifier', async () => { + console.log('Slack Notifier plugin uninstalled') +}) +``` + +## Full Example: npm Package + +An npm-distributed plugin follows the same conventions. Your `package.json` and `plugin.json` live side by side: + +**package.json:** +```json +{ + "name": "@acme/escalated-billing", + "version": "2.0.0", + "description": "Billing integration for Escalated", + "main": "plugin.ts", + "type": "module", + "engines": { + "node": ">=20.0.0" + }, + "dependencies": { + "@adonisjs/core": "^6.0.0" + } +} +``` + +**plugin.json:** +```json +{ + "name": "Billing Integration", + "slug": "@acme--escalated-billing", + "description": "Adds billing and invoicing to Escalated.", + "version": "2.0.0", + "author": "Acme Corp", + "main_file": "plugin.ts" +} +``` + +**plugin.ts:** +```typescript +import { escalated_addAction, escalated_registerMenuItem } from '@escalated-dev/escalated-adonis/support/helpers' +import { BillingService } from './src/billing_service.js' + +escalated_addAction('ticket_created', async (ticket) => { + const billingService = new BillingService() + await billingService.trackTicket(ticket) +}) + +escalated_registerMenuItem({ + 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', +}) +``` + +Since npm handles module resolution, your `plugin.ts` can import classes from `src/` using standard ES module imports. + +## Tips + +- **Keep plugin.ts 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 `app/plugins/escalated/` and activating it from the admin panel. +- **npm plugins** benefit from the Node.js ecosystem, TypeScript support, testing infrastructure, and version management via npm registry. diff --git a/index.ts b/index.ts index b16e8f6..d19ea6a 100644 --- a/index.ts +++ b/index.ts @@ -16,5 +16,27 @@ export * from './src/types.js' // Re-export events export * from './src/events/index.js' +// Plugin system +export { default as HookManager } from './src/support/hook_manager.js' +export { default as HookRegistry } from './src/services/hook_registry.js' +export { default as PluginService } from './src/services/plugin_service.js' +export { default as PluginUIService } from './src/services/plugin_ui_service.js' +export { default as PluginModel } from './src/models/plugin.js' + +// Global helper functions (prefixed with escalated_ to avoid conflicts) +export { + escalated_addAction, + escalated_doAction, + escalated_hasAction, + escalated_removeAction, + escalated_addFilter, + escalated_applyFilters, + escalated_hasFilter, + escalated_removeFilter, + escalated_registerMenuItem, + escalated_registerDashboardWidget, + escalated_addPageComponent, + escalated_getPageComponents, +} from './src/support/helpers.js' // Re-export i18n support export { t, setLocale, getLocale } from './src/support/i18n.js' diff --git a/package.json b/package.json index da8a9bd..5bf3f4c 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@escalated-dev/escalated-adonis", "description": "An embeddable support ticket system for AdonisJS v6 applications", - "version": "0.4.0", + "version": "0.5.0", "type": "module", "license": "MIT", "main": "build/index.js", @@ -12,6 +12,7 @@ "./models/*": "./build/src/models/*.js", "./middleware/*": "./build/src/middleware/*.js", "./controllers/*": "./build/src/controllers/*.js", + "./support/*": "./build/src/support/*.js", "./events": "./build/src/events/index.js", "./types": "./build/src/types.js", "./providers/*": "./build/providers/*.js", @@ -38,7 +39,8 @@ "tickets", "helpdesk", "customer-support", - "inertia" + "inertia", + "plugins" ], "peerDependencies": { "@adonisjs/core": "^6.0.0", diff --git a/providers/escalated_provider.ts b/providers/escalated_provider.ts index 63c04d1..0e9cace 100644 --- a/providers/escalated_provider.ts +++ b/providers/escalated_provider.ts @@ -9,6 +9,30 @@ export default class EscalatedProvider { * Register bindings to the container */ register() { + // Register HookManager as a singleton (core of the plugin system) + this.app.container.singleton('escalated.hookManager', async () => { + const { default: HookManager } = await import('../src/support/hook_manager.js') + const instance = new HookManager() + // Also store on globalThis so controllers and helpers can access it + ;(globalThis as any).__escalated_hooks = instance + return instance + }) + + // Register PluginUIService as a singleton + this.app.container.singleton('escalated.pluginUIService', async () => { + const { default: PluginUIService } = await import('../src/services/plugin_ui_service.js') + const instance = new PluginUIService() + ;(globalThis as any).__escalated_pluginUI = instance + return instance + }) + + // Register PluginService as a singleton + this.app.container.singleton('escalated.pluginService', async () => { + const { default: PluginService } = await import('../src/services/plugin_service.js') + const hookManager = await this.app.container.make('escalated.hookManager') + return new PluginService(hookManager) + }) + // Register services as singletons this.app.container.singleton('escalated.ticketService', async () => { const { default: TicketService } = await import('../src/services/ticket_service.js') @@ -58,11 +82,18 @@ export default class EscalatedProvider { // Load config and store globally for services to access await this.loadConfig() + // Initialize the HookManager and PluginUIService singletons eagerly + await this.app.container.make('escalated.hookManager') + await this.app.container.make('escalated.pluginUIService') + // Register routes await this.registerRoutes() // Share Inertia data await this.shareInertiaData() + + // Load active plugins (must happen after config is loaded) + await this.loadPlugins() } /** @@ -145,11 +176,44 @@ export default class EscalatedProvider { } } + /** + * Load active plugins if the plugin system is enabled. + */ + protected async loadPlugins() { + const config: EscalatedConfig = (globalThis as any).__escalated_config ?? {} + + if ((config as any).plugins?.enabled === false) { + return + } + + try { + const pluginService = await this.app.container.make('escalated.pluginService') + await pluginService.loadActivePlugins() + } catch (error) { + // Don't crash the app if plugins fail to load (e.g. table doesn't exist yet) + console.warn('[Escalated] Could not load plugins:', (error as Error).message) + } + } + /** * Shutdown hook */ async shutdown() { + // Clean up HookManager + const hookManager = (globalThis as any).__escalated_hooks + if (hookManager && typeof hookManager.clear === 'function') { + hookManager.clear() + } + + // Clean up PluginUIService + const pluginUI = (globalThis as any).__escalated_pluginUI + if (pluginUI && typeof pluginUI.clear === 'function') { + pluginUI.clear() + } + delete (globalThis as any).__escalated_config delete (globalThis as any).__escalated_presence + delete (globalThis as any).__escalated_hooks + delete (globalThis as any).__escalated_pluginUI } } diff --git a/src/controllers/admin_plugins_controller.ts b/src/controllers/admin_plugins_controller.ts new file mode 100644 index 0000000..17a9b1d --- /dev/null +++ b/src/controllers/admin_plugins_controller.ts @@ -0,0 +1,128 @@ +/* +|-------------------------------------------------------------------------- +| AdminPluginsController +|-------------------------------------------------------------------------- +| +| Admin CRUD for the plugin system. Lists installed plugins, handles +| upload/activation/deactivation/deletion. +| +*/ + +import type { HttpContext } from '@adonisjs/core/http' +import PluginService from '../services/plugin_service.js' +import HookManager from '../support/hook_manager.js' + +export default class AdminPluginsController { + protected getPluginService(): PluginService { + const hookManager: HookManager = (globalThis as any).__escalated_hooks + return new PluginService(hookManager) + } + + /** + * GET /support/admin/plugins — List all installed plugins + */ + async index({ inertia }: HttpContext) { + const pluginService = this.getPluginService() + const plugins = await pluginService.getAllPlugins() + + return inertia.render('Escalated/Admin/Plugins/Index', { + plugins, + }) + } + + /** + * POST /support/admin/plugins/upload — Upload a plugin ZIP + */ + async upload(ctx: HttpContext) { + const file = ctx.request.file('plugin', { + extnames: ['zip'], + size: '50mb', + }) + + if (!file || file.hasErrors) { + ctx.session.flash('error', file?.errors?.[0]?.message ?? 'Invalid plugin file.') + return ctx.response.redirect().back() + } + + try { + const pluginService = this.getPluginService() + await pluginService.uploadPlugin(file) + + ctx.session.flash('success', 'Plugin uploaded successfully. You can now activate it.') + return ctx.response.redirect().toRoute('escalated.admin.plugins.index') + } catch (error: any) { + console.error('[Escalated] Plugin upload failed:', error) + ctx.session.flash('error', `Failed to upload plugin: ${error.message}`) + return ctx.response.redirect().back() + } + } + + /** + * POST /support/admin/plugins/:slug/activate — Activate a plugin + */ + async activate(ctx: HttpContext) { + const slug = ctx.params.slug + + try { + const pluginService = this.getPluginService() + await pluginService.activatePlugin(slug) + + ctx.session.flash('success', 'Plugin activated successfully.') + } catch (error: any) { + console.error('[Escalated] Plugin activation failed:', error) + ctx.session.flash('error', `Failed to activate plugin: ${error.message}`) + } + + return ctx.response.redirect().back() + } + + /** + * POST /support/admin/plugins/:slug/deactivate — Deactivate a plugin + */ + async deactivate(ctx: HttpContext) { + const slug = ctx.params.slug + + try { + const pluginService = this.getPluginService() + await pluginService.deactivatePlugin(slug) + + ctx.session.flash('success', 'Plugin deactivated successfully.') + } catch (error: any) { + console.error('[Escalated] Plugin deactivation failed:', error) + ctx.session.flash('error', `Failed to deactivate plugin: ${error.message}`) + } + + return ctx.response.redirect().back() + } + + /** + * DELETE /support/admin/plugins/:slug — Delete a plugin + */ + async destroy(ctx: HttpContext) { + const slug = ctx.params.slug + + // Check if plugin is npm-sourced before attempting delete + const allPlugins = await this.getPluginService().getAllPlugins() + const pluginData = allPlugins.find((p: any) => p.slug === ctx.params.slug) + if (pluginData && pluginData.source === 'composer') { + ctx.session.flash('error', 'npm plugins cannot be deleted. Remove the package via npm instead.') + return ctx.response.redirect().back() + } + + try { + const pluginService = this.getPluginService() + const deleted = await pluginService.deletePlugin(slug) + + if (deleted) { + ctx.session.flash('success', 'Plugin deleted successfully.') + } else { + ctx.session.flash('error', 'Plugin not found.') + } + } catch (error: any) { + console.error('[Escalated] Plugin deletion failed:', error) + ctx.session.flash('error', `Failed to delete plugin: ${error.message}`) + } + + return ctx.response.redirect().back() + } +} diff --git a/src/models/plugin.ts b/src/models/plugin.ts new file mode 100644 index 0000000..05cec18 --- /dev/null +++ b/src/models/plugin.ts @@ -0,0 +1,37 @@ +import { DateTime } from 'luxon' +import { BaseModel, column, scope } from '@adonisjs/lucid/orm' + +export default class Plugin extends BaseModel { + static table = 'escalated_plugins' + + @column({ isPrimary: true }) + declare id: number + + @column() + declare slug: string + + @column() + declare isActive: boolean + + @column.dateTime() + declare activatedAt: DateTime | null + + @column.dateTime() + declare deactivatedAt: DateTime | null + + @column.dateTime({ autoCreate: true }) + declare createdAt: DateTime + + @column.dateTime({ autoCreate: true, autoUpdate: true }) + declare updatedAt: DateTime + + // ---- Scopes ---- + + static active = scope((query) => { + query.where('is_active', true) + }) + + static inactive = scope((query) => { + query.where('is_active', false) + }) +} diff --git a/src/services/hook_registry.ts b/src/services/hook_registry.ts new file mode 100644 index 0000000..734f930 --- /dev/null +++ b/src/services/hook_registry.ts @@ -0,0 +1,249 @@ +/* +|-------------------------------------------------------------------------- +| HookRegistry +|-------------------------------------------------------------------------- +| +| Central registry of all available hooks and filters in the Escalated +| package. This class serves as documentation — plugins can reference +| this to discover what hooks are available to extend. +| +*/ + +export interface HookDefinition { + description: string + parameters: string[] + example: string +} + +export default class HookRegistry { + /** + * Get all available action hooks. + */ + static getActions(): Record { + return { + // ======================================== + // PLUGIN LIFECYCLE + // ======================================== + plugin_loaded: { + description: 'Fired when a plugin is loaded during boot or activation', + parameters: ['slug: string', 'manifest: PluginManifest'], + example: `escalated_addAction('plugin_loaded', async (slug, manifest) => { /* ... */ })`, + }, + plugin_activated: { + description: 'Fired when any plugin is activated', + parameters: ['slug: string'], + example: `escalated_addAction('plugin_activated', async (slug) => { /* ... */ })`, + }, + 'plugin_activated_{slug}': { + description: 'Fired when a specific plugin is activated (replace {slug} with your plugin slug)', + parameters: [], + example: `escalated_addAction('plugin_activated_my-plugin', async () => { /* ... */ })`, + }, + plugin_deactivated: { + description: 'Fired when any plugin is deactivated', + parameters: ['slug: string'], + example: `escalated_addAction('plugin_deactivated', async (slug) => { /* ... */ })`, + }, + 'plugin_deactivated_{slug}': { + description: 'Fired when a specific plugin is deactivated', + parameters: [], + example: `escalated_addAction('plugin_deactivated_my-plugin', async () => { /* ... */ })`, + }, + plugin_uninstalling: { + description: 'Fired before any plugin is deleted', + parameters: ['slug: string'], + example: `escalated_addAction('plugin_uninstalling', async (slug) => { /* ... */ })`, + }, + 'plugin_uninstalling_{slug}': { + description: 'Fired before a specific plugin is deleted', + parameters: [], + example: `escalated_addAction('plugin_uninstalling_my-plugin', async () => { /* ... */ })`, + }, + + // ======================================== + // TICKET HOOKS + // ======================================== + ticket_created: { + description: 'Fired after a ticket is created', + parameters: ['ticket: Ticket'], + example: `escalated_addAction('ticket_created', async (ticket) => { /* ... */ })`, + }, + ticket_updated: { + description: 'Fired after a ticket is updated', + parameters: ['ticket: Ticket'], + example: `escalated_addAction('ticket_updated', async (ticket) => { /* ... */ })`, + }, + ticket_deleted: { + description: 'Fired after a ticket is deleted', + parameters: ['ticket: Ticket'], + example: `escalated_addAction('ticket_deleted', async (ticket) => { /* ... */ })`, + }, + ticket_status_changed: { + description: 'Fired when a ticket status changes', + parameters: ['ticket: Ticket', 'oldStatus: string', 'newStatus: string', 'causer: any'], + example: `escalated_addAction('ticket_status_changed', async (ticket, old, next, causer) => { /* ... */ })`, + }, + ticket_assigned: { + description: 'Fired when a ticket is assigned to an agent', + parameters: ['ticket: Ticket', 'agentId: number', 'causer: any'], + example: `escalated_addAction('ticket_assigned', async (ticket, agentId, causer) => { /* ... */ })`, + }, + ticket_replied: { + description: 'Fired when a reply is added to a ticket', + parameters: ['ticket: Ticket', 'reply: Reply'], + example: `escalated_addAction('ticket_replied', async (ticket, reply) => { /* ... */ })`, + }, + ticket_resolved: { + description: 'Fired when a ticket is resolved', + parameters: ['ticket: Ticket', 'causer: any'], + example: `escalated_addAction('ticket_resolved', async (ticket, causer) => { /* ... */ })`, + }, + ticket_closed: { + description: 'Fired when a ticket is closed', + parameters: ['ticket: Ticket', 'causer: any'], + example: `escalated_addAction('ticket_closed', async (ticket, causer) => { /* ... */ })`, + }, + ticket_reopened: { + description: 'Fired when a ticket is reopened', + parameters: ['ticket: Ticket', 'causer: any'], + example: `escalated_addAction('ticket_reopened', async (ticket, causer) => { /* ... */ })`, + }, + ticket_escalated: { + description: 'Fired when a ticket is escalated', + parameters: ['ticket: Ticket'], + example: `escalated_addAction('ticket_escalated', async (ticket) => { /* ... */ })`, + }, + ticket_priority_changed: { + description: 'Fired when a ticket priority changes', + parameters: ['ticket: Ticket', 'oldPriority: string', 'newPriority: string', 'causer: any'], + example: `escalated_addAction('ticket_priority_changed', async (ticket, old, next, causer) => { /* ... */ })`, + }, + ticket_department_changed: { + description: 'Fired when a ticket is moved to a different department', + parameters: ['ticket: Ticket', 'oldDepartmentId: number | null', 'newDepartmentId: number', 'causer: any'], + example: `escalated_addAction('ticket_department_changed', async (ticket, oldId, newId, causer) => { /* ... */ })`, + }, + ticket_tag_added: { + description: 'Fired when a tag is added to a ticket', + parameters: ['ticket: Ticket', 'tag: Tag'], + example: `escalated_addAction('ticket_tag_added', async (ticket, tag) => { /* ... */ })`, + }, + ticket_tag_removed: { + description: 'Fired when a tag is removed from a ticket', + parameters: ['ticket: Ticket', 'tag: Tag'], + example: `escalated_addAction('ticket_tag_removed', async (ticket, tag) => { /* ... */ })`, + }, + sla_breached: { + description: 'Fired when a ticket breaches its SLA', + parameters: ['ticket: Ticket', "type: 'first_response' | 'resolution'"], + example: `escalated_addAction('sla_breached', async (ticket, type) => { /* ... */ })`, + }, + + // ======================================== + // DASHBOARD HOOKS + // ======================================== + dashboard_viewed: { + description: 'Fired when an agent/admin views the dashboard', + parameters: ['user: any'], + example: `escalated_addAction('dashboard_viewed', async (user) => { /* ... */ })`, + }, + } + } + + /** + * Get all available filter hooks. + */ + static getFilters(): Record { + return { + // ======================================== + // TICKET FILTERS + // ======================================== + ticket_display_subject: { + description: 'Modify ticket subject before display', + parameters: ['subject: string', 'ticket: Ticket'], + example: `escalated_addFilter('ticket_display_subject', async (subject, ticket) => { return subject.toUpperCase() })`, + }, + ticket_list_query: { + description: 'Modify the ticket listing database query', + parameters: ['query: ModelQueryBuilder', 'request: Request'], + example: `escalated_addFilter('ticket_list_query', async (query, request) => { return query.where('priority', 'high') })`, + }, + ticket_list_data: { + description: 'Modify the ticket collection before rendering the list page', + parameters: ['tickets: Ticket[]', 'request: Request'], + example: `escalated_addFilter('ticket_list_data', async (tickets, request) => { return tickets })`, + }, + ticket_show_data: { + description: 'Modify data passed to the ticket detail page', + parameters: ['data: Record', 'ticket: Ticket'], + example: `escalated_addFilter('ticket_show_data', async (data, ticket) => { data.custom = 'value'; return data })`, + }, + ticket_store_data: { + description: 'Modify validated data before creating a ticket', + parameters: ['data: Record', 'request: Request'], + example: `escalated_addFilter('ticket_store_data', async (data, request) => { return data })`, + }, + ticket_update_data: { + description: 'Modify validated data before updating a ticket', + parameters: ['data: Record', 'ticket: Ticket', 'request: Request'], + example: `escalated_addFilter('ticket_update_data', async (data, ticket, request) => { return data })`, + }, + + // ======================================== + // DASHBOARD FILTERS + // ======================================== + dashboard_stats_data: { + description: 'Modify dashboard statistics data before rendering', + parameters: ['stats: Record', 'user: any'], + example: `escalated_addFilter('dashboard_stats_data', async (stats, user) => { stats.custom_metric = 42; return stats })`, + }, + dashboard_page_data: { + description: 'Modify all data passed to the dashboard page', + parameters: ['data: Record', 'user: any'], + example: `escalated_addFilter('dashboard_page_data', async (data, user) => { return data })`, + }, + + // ======================================== + // UI FILTERS + // ======================================== + navigation_menu: { + description: 'Add or modify navigation menu items', + parameters: ['menuItems: MenuItem[]', 'user: any'], + example: `escalated_addFilter('navigation_menu', async (items, user) => { items.push({ label: 'Custom', url: '/custom' }); return items })`, + }, + sidebar_menu: { + description: 'Add or modify sidebar menu items', + parameters: ['menuItems: MenuItem[]', 'user: any'], + example: `escalated_addFilter('sidebar_menu', async (items, user) => { return items })`, + }, + + // ======================================== + // REPLY FILTERS + // ======================================== + reply_body: { + description: 'Modify reply body before storing', + parameters: ['body: string', 'ticket: Ticket', 'author: any'], + example: `escalated_addFilter('reply_body', async (body, ticket, author) => { return body })`, + }, + + // ======================================== + // NOTIFICATION FILTERS + // ======================================== + notification_channels: { + description: 'Modify notification channels for a ticket event', + parameters: ['channels: string[]', 'event: string', 'ticket: Ticket'], + example: `escalated_addFilter('notification_channels', async (channels, event, ticket) => { channels.push('slack'); return channels })`, + }, + } + } + + /** + * Get all hooks (both actions and filters). + */ + static getAllHooks(): { actions: Record; filters: Record } { + return { + actions: this.getActions(), + filters: this.getFilters(), + } + } +} diff --git a/src/services/plugin_service.ts b/src/services/plugin_service.ts new file mode 100644 index 0000000..a004e1a --- /dev/null +++ b/src/services/plugin_service.ts @@ -0,0 +1,476 @@ +/* +|-------------------------------------------------------------------------- +| PluginService +|-------------------------------------------------------------------------- +| +| Handles plugin discovery, activation, deactivation, deletion, upload, +| and boot-time loading. Plugins live in the HOST APPLICATION's filesystem +| at a configurable path (default: plugins/escalated). +| +*/ + +import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { getConfig } from '../helpers/config.js' +import Plugin from '../models/plugin.js' +import type HookManager from '../support/hook_manager.js' +import type { PluginManifest, PluginInfo } from '../types.js' + +export default class PluginService { + protected pluginsPath: string + protected hookManager: HookManager + + constructor(hookManager: HookManager) { + this.hookManager = hookManager + + const config = getConfig() + const configuredPath = (config as any).plugins?.path ?? 'app/plugins/escalated' + this.pluginsPath = resolve(process.cwd(), configuredPath) + + // Ensure the plugins directory exists + if (!existsSync(this.pluginsPath)) { + mkdirSync(this.pluginsPath, { recursive: true }) + } + } + + /** + * Merge local and npm-sourced plugins. + * Returns metadata for every installed plugin. + */ + async getAllPlugins(): Promise { + const local = await this.getLocalPlugins() + const npm = await this.getNpmPlugins() + return [...local, ...npm] + } + + /** + * Scan the local plugins directory and merge with DB activation state. + */ + private async getLocalPlugins(): Promise { + const plugins: PluginInfo[] = [] + + let directories: string[] + try { + directories = readdirSync(this.pluginsPath).filter((entry) => { + const fullPath = join(this.pluginsPath, entry) + return statSync(fullPath).isDirectory() + }) + } catch { + return plugins + } + + for (const dirName of directories) { + const manifestPath = join(this.pluginsPath, dirName, 'plugin.json') + + if (!existsSync(manifestPath)) { + continue + } + + try { + const raw = readFileSync(manifestPath, 'utf-8') + const manifest: PluginManifest = JSON.parse(raw) + + // Merge with DB state + const dbPlugin = await Plugin.query().where('slug', dirName).first() + + plugins.push({ + slug: dirName, + name: manifest.name ?? dirName, + description: manifest.description ?? '', + version: manifest.version ?? '1.0.0', + author: manifest.author ?? 'Unknown', + authorUrl: manifest.author_url ?? '', + requires: manifest.requires ?? '1.0.0', + mainFile: manifest.main_file ?? 'Plugin.ts', + isActive: dbPlugin?.isActive ?? false, + activatedAt: dbPlugin?.activatedAt?.toISO() ?? null, + path: join(this.pluginsPath, dirName), + source: 'local', + }) + } catch { + // Skip plugins with invalid manifests + continue + } + } + + return plugins + } + + /** + * Discover plugins installed via npm (node_modules), including scoped packages. + */ + private async getNpmPlugins(): Promise { + const plugins: PluginInfo[] = [] + const nodeModulesPath = join(process.cwd(), 'node_modules') + + try { + if (!existsSync(nodeModulesPath)) return plugins + + // Scan node_modules for packages with plugin.json (including scoped packages) + const entries = readdirSync(nodeModulesPath) + const dirsToCheck: string[] = [] + + for (const entry of entries) { + const entryPath = join(nodeModulesPath, entry) + if (entry.startsWith('@') && existsSync(entryPath)) { + // Scoped package — check subdirectories + const scopedEntries = readdirSync(entryPath) + for (const sub of scopedEntries) { + dirsToCheck.push(join(entryPath, sub)) + } + } else if (entry !== '.package-lock.json' && !entry.startsWith('.')) { + dirsToCheck.push(entryPath) + } + } + + for (const dir of dirsToCheck) { + const manifestPath = join(dir, 'plugin.json') + if (!existsSync(manifestPath)) continue + + try { + const raw = readFileSync(manifestPath, 'utf-8') + const manifest = JSON.parse(raw) + if (!manifest) continue + + // Derive slug from package directory name + const parts = dir.replace(/\\/g, '/').split('/') + const lastTwo = parts.slice(-2) + const slug = lastTwo[0].startsWith('@') ? lastTwo.join('--') : parts[parts.length - 1] + + let dbPlugin = null + try { + dbPlugin = await Plugin.query().where('slug', slug).first() + } catch {} + + plugins.push({ + slug, + name: manifest.name || slug, + description: manifest.description || '', + version: manifest.version || '1.0.0', + author: manifest.author || 'Unknown', + authorUrl: manifest.author_url || '', + requires: manifest.requires || '1.0.0', + mainFile: manifest.main_file || 'Plugin.js', + isActive: dbPlugin?.isActive || false, + activatedAt: dbPlugin?.activatedAt?.toISO() || null, + path: dir, + source: 'composer', // Use 'composer' for frontend consistency + }) + } catch { + // Skip invalid manifests + } + } + } catch (error) { + // node_modules scan failed, non-fatal + } + + return plugins + } + + /** + * Return the slugs of all currently activated plugins. + */ + async getActivatedPlugins(): Promise { + try { + const activePlugins = await Plugin.query() + .withScopes((scopes) => scopes.active()) + .select('slug') + + return activePlugins.map((p) => p.slug) + } catch { + // Table may not exist yet (before migrations) + return [] + } + } + + /** + * Activate a plugin: create/update DB record, load the plugin, fire hooks. + */ + async activatePlugin(slug: string): Promise { + // Verify plugin directory and manifest exist (check both local and npm sources) + const pluginPath = await this.resolvePluginPath(slug) + if (!pluginPath || !existsSync(join(pluginPath, 'plugin.json'))) { + throw new Error(`Plugin "${slug}" not found or missing plugin.json`) + } + + let plugin = await Plugin.query().where('slug', slug).first() + + if (!plugin) { + plugin = await Plugin.create({ + slug, + isActive: false, + }) + } + + if (!plugin.isActive) { + plugin.isActive = true + plugin.activatedAt = (await import('luxon')).DateTime.now() + plugin.deactivatedAt = null + await plugin.save() + + // Load the plugin so its hooks are registered + await this.loadPlugin(slug) + + // Fire activation hooks + await this.hookManager.doAction('plugin_activated', slug) + await this.hookManager.doAction(`plugin_activated_${slug}`) + } + + return true + } + + /** + * Deactivate a plugin: fire hooks, then update the DB record. + */ + async deactivatePlugin(slug: string): Promise { + const plugin = await Plugin.query().where('slug', slug).first() + + if (plugin && plugin.isActive) { + // Fire deactivation hooks BEFORE deactivating + await this.hookManager.doAction('plugin_deactivated', slug) + await this.hookManager.doAction(`plugin_deactivated_${slug}`) + + plugin.isActive = false + plugin.deactivatedAt = (await import('luxon')).DateTime.now() + await plugin.save() + } + + return true + } + + /** + * Delete a plugin: fire uninstall hooks, deactivate, remove DB record, + * and delete the plugin directory from disk. + */ + async deletePlugin(slug: string): Promise { + const allPlugins = await this.getAllPlugins() + const pluginData = allPlugins.find((p) => p.slug === slug) + if (pluginData && pluginData.source === 'composer') { + throw new Error('npm plugins cannot be deleted. Remove the package via npm instead.') + } + + const pluginPath = join(this.pluginsPath, slug) + + if (!existsSync(pluginPath)) { + return false + } + + const plugin = await Plugin.query().where('slug', slug).first() + + // Load plugin so its uninstall hooks can run + if (plugin && plugin.isActive) { + await this.loadPlugin(slug) + } + + // Fire uninstall hooks + await this.hookManager.doAction('plugin_uninstalling', slug) + await this.hookManager.doAction(`plugin_uninstalling_${slug}`) + + // Deactivate first if active + await this.deactivatePlugin(slug) + + // Delete database record + if (plugin) { + await plugin.delete() + } + + // Delete the plugin directory + rmSync(pluginPath, { recursive: true, force: true }) + + return true + } + + /** + * Upload and extract a plugin from a ZIP file. + * Returns the slug and path of the extracted plugin. + */ + async uploadPlugin(file: { + tmpPath?: string + clientName: string + move: (dest: string, options?: any) => Promise + }): Promise<{ slug: string; path: string }> { + // For AdonisJS multipart files, we need to handle extraction. + // We use the Node.js built-in zlib and tar, or the host app can provide + // a ZIP extraction utility. For now, we do a simple directory-based approach. + + // Move the uploaded file to a temp location + const tempDir = join(this.pluginsPath, '.tmp') + if (!existsSync(tempDir)) { + mkdirSync(tempDir, { recursive: true }) + } + + await file.move(tempDir, { name: file.clientName, overwrite: true }) + + const uploadedPath = join(tempDir, file.clientName) + + // Extract ZIP using dynamic import of adm-zip (or similar) + let rootFolder: string + + try { + // Attempt dynamic import of adm-zip + const { default: AdmZip } = await import('adm-zip') + const zip = new AdmZip(uploadedPath) + const entries = zip.getEntries() + + // Determine root folder + rootFolder = '' + for (const entry of entries) { + const name = entry.entryName + if (name.includes('/')) { + rootFolder = name.substring(0, name.indexOf('/')) + break + } + } + + if (!rootFolder) { + throw new Error('Invalid plugin structure: no root folder found in ZIP') + } + + const extractPath = join(this.pluginsPath, rootFolder) + if (existsSync(extractPath)) { + throw new Error(`Plugin "${rootFolder}" already exists`) + } + + // Extract to plugins directory + zip.extractAllTo(this.pluginsPath, true) + + // Validate plugin.json exists + const manifestPath = join(extractPath, 'plugin.json') + if (!existsSync(manifestPath)) { + rmSync(extractPath, { recursive: true, force: true }) + throw new Error('Invalid plugin: missing plugin.json') + } + + return { slug: rootFolder, path: extractPath } + } finally { + // Clean up temp file + try { + if (existsSync(uploadedPath)) { + rmSync(uploadedPath) + } + if (existsSync(tempDir)) { + rmSync(tempDir, { recursive: true, force: true }) + } + } catch { + // Ignore cleanup errors + } + } + } + + /** + * Load all active plugins. Called once during application boot. + */ + async loadActivePlugins(): Promise { + const activatedSlugs = await this.getActivatedPlugins() + + for (const slug of activatedSlugs) { + await this.loadPlugin(slug) + } + } + + /** + * Resolve the filesystem path for a plugin slug, checking local and npm sources. + */ + private async resolvePluginPath(slug: string): Promise { + // Check local plugins first + const localPath = join(this.pluginsPath, slug) + if (existsSync(join(localPath, 'plugin.json'))) { + return localPath + } + + // Check npm plugins + const nodeModulesPath = join(process.cwd(), 'node_modules') + // Direct package + const directPath = join(nodeModulesPath, slug) + if (existsSync(join(directPath, 'plugin.json'))) { + return directPath + } + // Scoped package (slug uses -- separator) + if (slug.includes('--')) { + const scopedPath = join(nodeModulesPath, slug.replace('--', '/')) + if (existsSync(join(scopedPath, 'plugin.json'))) { + return scopedPath + } + } + + return null + } + + /** + * Load a specific plugin by dynamically importing its main file. + * The plugin's main file should export a default function or class + * that receives the HookManager as its first argument. + */ + async loadPlugin(slug: string): Promise { + const pluginPath = await this.resolvePluginPath(slug) + + if (!pluginPath) { + return + } + + const manifestPath = join(pluginPath, 'plugin.json') + + let manifest: PluginManifest + try { + const raw = readFileSync(manifestPath, 'utf-8') + manifest = JSON.parse(raw) + } catch { + return + } + + const mainFile = manifest.main_file ?? 'Plugin.ts' + const pluginFile = join(pluginPath, mainFile) + + if (!existsSync(pluginFile)) { + // Try .js extension as fallback + const jsFile = pluginFile.replace(/\.ts$/, '.js') + if (existsSync(jsFile)) { + await this.executePluginFile(jsFile, slug, manifest) + } + return + } + + await this.executePluginFile(pluginFile, slug, manifest) + } + + /** + * Execute a plugin's main file by dynamically importing it. + */ + protected async executePluginFile( + filePath: string, + slug: string, + manifest: PluginManifest + ): Promise { + try { + // Convert to file:// URL for dynamic import on Windows and Unix + const fileUrl = `file:///${filePath.replace(/\\/g, '/')}` + const pluginModule = await import(fileUrl) + + // If the plugin exports a default function, call it with the hook manager + if (typeof pluginModule.default === 'function') { + // Check if it's a class (has prototype methods) or a plain function + if (pluginModule.default.prototype && pluginModule.default.prototype.constructor) { + // Class: instantiate with hookManager and call boot() if it exists + const instance = new pluginModule.default(this.hookManager) + if (typeof instance.boot === 'function') { + await instance.boot() + } + } else { + // Plain function: call with hookManager + await pluginModule.default(this.hookManager) + } + } + + // If the plugin exports a register function, call it + if (typeof pluginModule.register === 'function') { + await pluginModule.register(this.hookManager) + } + + // Fire the plugin_loaded action + await this.hookManager.doAction('plugin_loaded', slug, manifest) + } catch (error) { + // Log but don't crash on plugin load failure + console.error(`[Escalated] Failed to load plugin "${slug}":`, error) + } + } +} diff --git a/src/services/plugin_ui_service.ts b/src/services/plugin_ui_service.ts new file mode 100644 index 0000000..9af5fa4 --- /dev/null +++ b/src/services/plugin_ui_service.ts @@ -0,0 +1,222 @@ +/* +|-------------------------------------------------------------------------- +| PluginUIService +|-------------------------------------------------------------------------- +| +| Service for plugins to register custom UI elements — menu items, +| dashboard widgets, and page component slots. These registrations +| are accumulated at boot time and can be queried by controllers to +| inject plugin-provided UI into Inertia responses. +| +*/ + +export interface MenuItem { + label: string + route?: string | null + url?: string | null + icon?: string | null + permission?: string | null + position: number + parent?: string | null + badge?: string | number | null + activeRoutes: string[] + submenu: SubmenuItem[] +} + +export interface SubmenuItem { + label: string + route?: string | null + url?: string | null + icon?: string | null + permission?: string | null + activeRoutes: string[] +} + +export interface DashboardWidget { + id: string + title: string + component: string | null + data: Record + position: number + width: 'full' | 'half' | 'third' | 'quarter' + permission?: string | null +} + +export interface PageComponent { + component: string | null + data: Record + position: number + permission?: string | null + plugin?: string +} + +const MENU_ITEM_DEFAULTS: MenuItem = { + label: 'Custom Item', + route: null, + url: null, + icon: null, + permission: null, + position: 100, + parent: null, + badge: null, + activeRoutes: [], + submenu: [], +} + +const WIDGET_DEFAULTS: Omit = { + title: 'Custom Widget', + component: null, + data: {}, + position: 100, + width: 'full', + permission: null, +} + +const PAGE_COMPONENT_DEFAULTS: PageComponent = { + component: null, + data: {}, + position: 100, + permission: null, +} + +export default class PluginUIService { + protected menuItems: MenuItem[] = [] + protected dashboardWidgets: DashboardWidget[] = [] + protected pageComponents: Map> = new Map() + + // ---- Menu Items ---- + + /** + * Register a custom menu item. + */ + addMenuItem(item: Partial): void { + this.menuItems.push({ ...MENU_ITEM_DEFAULTS, ...item } as MenuItem) + } + + /** + * Register multiple menu items at once. + */ + addMenuItems(items: Partial[]): void { + for (const item of items) { + this.addMenuItem(item) + } + } + + /** + * Add a submenu item to an existing menu item identified by its label. + */ + addSubmenuItem(parentLabel: string, submenuItem: Partial): void { + const defaults: SubmenuItem = { + label: 'Submenu Item', + route: null, + url: null, + icon: null, + permission: null, + activeRoutes: [], + } + + const fullItem = { ...defaults, ...submenuItem } as SubmenuItem + + for (const menuItem of this.menuItems) { + if (menuItem.label === parentLabel) { + if (!menuItem.submenu) { + menuItem.submenu = [] + } + menuItem.submenu.push(fullItem) + break + } + } + } + + /** + * Get all registered menu items, sorted by position. + */ + getMenuItems(): MenuItem[] { + return [...this.menuItems].sort((a, b) => a.position - b.position) + } + + // ---- Dashboard Widgets ---- + + /** + * Register a dashboard widget. + */ + addDashboardWidget(widget: Partial): void { + const id = widget.id ?? `widget_${Date.now()}_${Math.random().toString(36).slice(2, 8)}` + this.dashboardWidgets.push({ + ...WIDGET_DEFAULTS, + ...widget, + id, + } as DashboardWidget) + } + + /** + * Get all registered dashboard widgets, sorted by position. + */ + getDashboardWidgets(): DashboardWidget[] { + return [...this.dashboardWidgets].sort((a, b) => a.position - b.position) + } + + // ---- Page Component Slots ---- + + /** + * Register a component to be injected into an existing page at a named slot. + * + * @param page - Page identifier (e.g. 'ticket.show', 'dashboard', 'ticket.index') + * @param slot - Slot name (e.g. 'sidebar', 'header', 'footer', 'tabs') + * @param component - Component configuration + */ + addPageComponent(page: string, slot: string, component: Partial): void { + if (!this.pageComponents.has(page)) { + this.pageComponents.set(page, new Map()) + } + + const pageSlots = this.pageComponents.get(page)! + if (!pageSlots.has(slot)) { + pageSlots.set(slot, []) + } + + pageSlots.get(slot)!.push({ ...PAGE_COMPONENT_DEFAULTS, ...component } as PageComponent) + } + + /** + * Get components registered for a specific page and slot, sorted by position. + */ + getPageComponents(page: string, slot: string): PageComponent[] { + const pageSlots = this.pageComponents.get(page) + if (!pageSlots) { + return [] + } + + const components = pageSlots.get(slot) + if (!components) { + return [] + } + + return [...components].sort((a, b) => a.position - b.position) + } + + /** + * Get all components for a specific page, organized by slot. + */ + getAllPageComponents(page: string): Record { + const pageSlots = this.pageComponents.get(page) + if (!pageSlots) { + return {} + } + + const result: Record = {} + for (const [slot, components] of pageSlots) { + result[slot] = [...components].sort((a, b) => a.position - b.position) + } + return result + } + + /** + * Clear all registered UI elements. Useful for testing. + */ + clear(): void { + this.menuItems = [] + this.dashboardWidgets = [] + this.pageComponents.clear() + } +} diff --git a/src/support/helpers.ts b/src/support/helpers.ts new file mode 100644 index 0000000..28a1c67 --- /dev/null +++ b/src/support/helpers.ts @@ -0,0 +1,211 @@ +/* +|-------------------------------------------------------------------------- +| Escalated Plugin Helpers +|-------------------------------------------------------------------------- +| +| Global helper functions for the plugin system. All functions are prefixed +| with `escalated_` to avoid conflicts with the host application. +| +| These functions access the HookManager and PluginUIService singletons +| from globalThis, which are set during provider boot. +| +*/ + +import type HookManager from './hook_manager.js' +import type PluginUIService from '../services/plugin_ui_service.js' + +/** + * Get the HookManager singleton. + */ +function getHookManager(): HookManager { + const hooks = (globalThis as any).__escalated_hooks + if (!hooks) { + throw new Error('[Escalated] HookManager not initialized. Ensure EscalatedProvider has booted.') + } + return hooks +} + +/** + * Get the PluginUIService singleton. + */ +function getPluginUI(): PluginUIService { + const pluginUI = (globalThis as any).__escalated_pluginUI + if (!pluginUI) { + throw new Error('[Escalated] PluginUIService not initialized. Ensure EscalatedProvider has booted.') + } + return pluginUI +} + +// ======================================== +// ACTION HELPERS +// ======================================== + +/** + * Add an action hook. + * + * @param tag - The action name + * @param callback - The function to call + * @param priority - Priority (lower numbers run first, default 10) + */ +export function escalated_addAction( + tag: string, + callback: (...args: any[]) => void | Promise, + priority: number = 10 +): void { + getHookManager().addAction(tag, callback, priority) +} + +/** + * Execute all callbacks registered for an action. + * + * @param tag - The action name + * @param args - Arguments to pass to callbacks + */ +export async function escalated_doAction(tag: string, ...args: any[]): Promise { + await getHookManager().doAction(tag, ...args) +} + +/** + * Check if an action has callbacks registered. + */ +export function escalated_hasAction(tag: string): boolean { + return getHookManager().hasAction(tag) +} + +/** + * Remove an action hook. Pass a specific callback to remove only that one, + * or omit to remove all callbacks for the tag. + */ +export function escalated_removeAction( + tag: string, + callback?: (...args: any[]) => void | Promise +): void { + getHookManager().removeAction(tag, callback) +} + +// ======================================== +// FILTER HELPERS +// ======================================== + +/** + * Add a filter hook. + * + * @param tag - The filter name + * @param callback - The function to call (receives value as first arg, returns transformed value) + * @param priority - Priority (lower numbers run first, default 10) + */ +export function escalated_addFilter( + tag: string, + callback: (value: any, ...args: any[]) => any | Promise, + priority: number = 10 +): void { + getHookManager().addFilter(tag, callback, priority) +} + +/** + * Apply all callbacks registered for a filter. + * + * @param tag - The filter name + * @param value - The initial value to filter + * @param args - Additional arguments to pass to callbacks + * @returns The final filtered value + */ +export async function escalated_applyFilters( + tag: string, + value: T, + ...args: any[] +): Promise { + return getHookManager().applyFilters(tag, value, ...args) +} + +/** + * Check if a filter has callbacks registered. + */ +export function escalated_hasFilter(tag: string): boolean { + return getHookManager().hasFilter(tag) +} + +/** + * Remove a filter hook. Pass a specific callback to remove only that one, + * or omit to remove all callbacks for the tag. + */ +export function escalated_removeFilter( + tag: string, + callback?: (value: any, ...args: any[]) => any | Promise +): void { + getHookManager().removeFilter(tag, callback) +} + +// ======================================== +// PLUGIN UI HELPERS +// ======================================== + +/** + * Register a custom menu item. + */ +export function escalated_registerMenuItem(item: { + label: string + route?: string | null + url?: string | null + icon?: string | null + permission?: string | null + position?: number + parent?: string | null + badge?: string | number | null + activeRoutes?: string[] + submenu?: any[] +}): void { + getPluginUI().addMenuItem(item) +} + +/** + * Register a dashboard widget. + */ +export function escalated_registerDashboardWidget(widget: { + id?: string + title: string + component: string | null + data?: Record + position?: number + width?: 'full' | 'half' | 'third' | 'quarter' + permission?: string | null +}): void { + getPluginUI().addDashboardWidget(widget) +} + +/** + * Add a component to an existing page slot. + * + * @param page - Page identifier (e.g. 'ticket.show', 'dashboard') + * @param slot - Slot name (e.g. 'sidebar', 'header', 'footer') + * @param component - Component configuration + */ +export function escalated_addPageComponent( + page: string, + slot: string, + component: { + component: string | null + data?: Record + position?: number + permission?: string | null + plugin?: string + } +): void { + getPluginUI().addPageComponent(page, slot, component) +} + +/** + * Get components for a specific page and slot. + */ +export function escalated_getPageComponents( + page: string, + slot: string +): Array<{ + component: string | null + data: Record + position: number + permission?: string | null + plugin?: string +}> { + return getPluginUI().getPageComponents(page, slot) +} diff --git a/src/support/hook_manager.ts b/src/support/hook_manager.ts new file mode 100644 index 0000000..eafea28 --- /dev/null +++ b/src/support/hook_manager.ts @@ -0,0 +1,193 @@ +/* +|-------------------------------------------------------------------------- +| HookManager +|-------------------------------------------------------------------------- +| +| WordPress-style actions and filters with priority-based execution. +| Actions fire side effects; filters transform values through a pipeline. +| +*/ + +type ActionCallback = (...args: any[]) => void | Promise +type FilterCallback = (value: any, ...args: any[]) => any | Promise + +interface RegisteredCallback { + callback: T + priority: number +} + +export default class HookManager { + protected actions: Map[]> = new Map() + protected filters: Map[]> = new Map() + + // ---- Actions ---- + + /** + * Register a callback for an action hook. + * + * @param tag - The action name (e.g. 'ticket_created') + * @param callback - The function to call when the action fires + * @param priority - Lower numbers run first (default 10) + */ + addAction(tag: string, callback: ActionCallback, priority: number = 10): void { + const existing = this.actions.get(tag) ?? [] + existing.push({ callback, priority }) + this.actions.set(tag, existing) + } + + /** + * Execute all callbacks registered for an action, in priority order. + * + * @param tag - The action name + * @param args - Arguments forwarded to every callback + */ + async doAction(tag: string, ...args: any[]): Promise { + const registered = this.actions.get(tag) + if (!registered || registered.length === 0) { + return + } + + // Sort by priority (lower first), stable sort preserves insertion order + const sorted = [...registered].sort((a, b) => a.priority - b.priority) + + for (const entry of sorted) { + await entry.callback(...args) + } + } + + /** + * Check whether any callbacks are registered for an action. + */ + hasAction(tag: string): boolean { + const registered = this.actions.get(tag) + return !!registered && registered.length > 0 + } + + /** + * Remove action callbacks. If `callback` is provided, only that specific + * callback is removed. If `callback` is omitted, all callbacks for the + * tag are removed. + */ + removeAction(tag: string, callback?: ActionCallback): void { + if (!callback) { + this.actions.delete(tag) + return + } + + const registered = this.actions.get(tag) + if (!registered) { + return + } + + const filtered = registered.filter((entry) => entry.callback !== callback) + if (filtered.length === 0) { + this.actions.delete(tag) + } else { + this.actions.set(tag, filtered) + } + } + + // ---- Filters ---- + + /** + * Register a callback for a filter hook. + * + * @param tag - The filter name (e.g. 'ticket_display_subject') + * @param callback - Receives the current value as the first arg, returns the transformed value + * @param priority - Lower numbers run first (default 10) + */ + addFilter(tag: string, callback: FilterCallback, priority: number = 10): void { + const existing = this.filters.get(tag) ?? [] + existing.push({ callback, priority }) + this.filters.set(tag, existing) + } + + /** + * Run a value through all registered filter callbacks in priority order. + * + * @param tag - The filter name + * @param value - The initial value to filter + * @param args - Additional arguments forwarded to every callback + * @returns The final filtered value + */ + async applyFilters(tag: string, value: T, ...args: any[]): Promise { + const registered = this.filters.get(tag) + if (!registered || registered.length === 0) { + return value + } + + const sorted = [...registered].sort((a, b) => a.priority - b.priority) + + let result: any = value + for (const entry of sorted) { + result = await entry.callback(result, ...args) + } + + return result as T + } + + /** + * Check whether any callbacks are registered for a filter. + */ + hasFilter(tag: string): boolean { + const registered = this.filters.get(tag) + return !!registered && registered.length > 0 + } + + /** + * Remove filter callbacks. If `callback` is provided, only that specific + * callback is removed. If `callback` is omitted, all callbacks for the + * tag are removed. + */ + removeFilter(tag: string, callback?: FilterCallback): void { + if (!callback) { + this.filters.delete(tag) + return + } + + const registered = this.filters.get(tag) + if (!registered) { + return + } + + const filtered = registered.filter((entry) => entry.callback !== callback) + if (filtered.length === 0) { + this.filters.delete(tag) + } else { + this.filters.set(tag, filtered) + } + } + + // ---- Introspection ---- + + /** + * Get all registered action tags and their callback counts. + */ + getActions(): Record { + const result: Record = {} + for (const [tag, callbacks] of this.actions) { + result[tag] = callbacks.length + } + return result + } + + /** + * Get all registered filter tags and their callback counts. + */ + getFilters(): Record { + const result: Record = {} + for (const [tag, callbacks] of this.filters) { + result[tag] = callbacks.length + } + return result + } + + /** + * Remove all registered actions and filters. + * Useful for testing. + */ + clear(): void { + this.actions.clear() + this.filters.clear() + } +} diff --git a/src/types.ts b/src/types.ts index 504e9ad..dde3fae 100644 --- a/src/types.ts +++ b/src/types.ts @@ -230,6 +230,11 @@ export interface EscalatedConfig { isAdmin: (user: any) => boolean | Promise } + plugins: { + enabled: boolean + path: string + } + activityLog: { retentionDays: number } @@ -349,3 +354,44 @@ export const ALLOWED_SORT_COLUMNS = [ 'subject', 'reference', 'assigned_to', 'department_id', 'resolved_at', 'closed_at', ] + +// ---- Plugin Types ---- + +/** + * Plugin manifest (plugin.json) + */ +export interface PluginManifest { + name: string + description?: string + version?: string + author?: string + author_url?: string + requires?: string + main_file?: string +} + +/** + * Plugin info returned by PluginService.getAllPlugins() + */ +export interface PluginInfo { + slug: string + name: string + description: string + version: string + author: string + authorUrl: string + requires: string + mainFile: string + isActive: boolean + activatedAt: string | null + path: string + source: string +} + +/** + * Plugin configuration section of EscalatedConfig + */ +export interface PluginConfig { + enabled: boolean + path: string +} diff --git a/start/routes.ts b/start/routes.ts index 12bfa82..e486910 100644 --- a/start/routes.ts +++ b/start/routes.ts @@ -24,6 +24,7 @@ const AdminCannedResponsesController = () => import('../src/controllers/admin_ca const AdminMacrosController = () => import('../src/controllers/admin_macros_controller.js') const AdminReportsController = () => import('../src/controllers/admin_reports_controller.js') const AdminSettingsController = () => import('../src/controllers/admin_settings_controller.js') +const AdminPluginsController = () => import('../src/controllers/admin_plugins_controller.js') const BulkActionsController = () => import('../src/controllers/bulk_actions_controller.js') const SatisfactionRatingController = () => import('../src/controllers/satisfaction_rating_controller.js') const GuestTicketsController = () => import('../src/controllers/guest_tickets_controller.js') @@ -161,6 +162,13 @@ export function registerRoutes() { router.post('/macros', [AdminMacrosController, 'store']).as('escalated.admin.macros.store') router.put('/macros/:macro', [AdminMacrosController, 'update']).as('escalated.admin.macros.update') router.delete('/macros/:macro', [AdminMacrosController, 'destroy']).as('escalated.admin.macros.destroy') + + // Plugins + router.get('/plugins', [AdminPluginsController, 'index']).as('escalated.admin.plugins.index') + router.post('/plugins/upload', [AdminPluginsController, 'upload']).as('escalated.admin.plugins.upload') + router.post('/plugins/:slug/activate', [AdminPluginsController, 'activate']).as('escalated.admin.plugins.activate') + router.post('/plugins/:slug/deactivate', [AdminPluginsController, 'deactivate']).as('escalated.admin.plugins.deactivate') + router.delete('/plugins/:slug', [AdminPluginsController, 'destroy']).as('escalated.admin.plugins.destroy') }) .prefix(`${prefix}/admin`) .use([...adminMiddleware, EnsureIsAdmin]) diff --git a/stubs/config/escalated.stub b/stubs/config/escalated.stub index d6d5359..985ba85 100644 --- a/stubs/config/escalated.stub +++ b/stubs/config/escalated.stub @@ -158,6 +158,20 @@ const escalatedConfig: EscalatedConfig = { }, }, + /* + |-------------------------------------------------------------------------- + | Plugins + |-------------------------------------------------------------------------- + | + | Enable the WordPress-style plugin/extension system. Plugins are + | discovered from the configured path relative to the app root. + | + */ + plugins: { + enabled: true, + path: 'plugins/escalated', + }, + /* |-------------------------------------------------------------------------- | Activity Log diff --git a/stubs/plugins/hello-world/Plugin.js b/stubs/plugins/hello-world/Plugin.js new file mode 100644 index 0000000..1d2f845 --- /dev/null +++ b/stubs/plugins/hello-world/Plugin.js @@ -0,0 +1,88 @@ +/** + * Hello World Plugin for Escalated + * + * This plugin demonstrates the Escalated plugin system and does nothing + * particularly useful. It is here to show you how plugins work and give + * you a starting point for building your own. + * + * Demonstrates: + * - How to use action hooks + * - How to use filter hooks + * - How to handle lifecycle events (activate, deactivate, uninstall) + * - How to register UI components (menu items, dashboard widgets, page slots) + * + * Delete this whenever you want! + */ + +export default function register(hooks) { + // ======================================== + // LIFECYCLE HOOKS + // ======================================== + + // Runs when this plugin is activated + hooks.addAction('plugin_activated_hello-world', async () => { + console.log('[HelloWorld] Plugin activated! Time to do... nothing!') + }) + + // Runs when this plugin is deactivated + hooks.addAction('plugin_deactivated_hello-world', async () => { + console.log('[HelloWorld] Plugin deactivated. We had a good run!') + }) + + // Runs when this plugin is about to be deleted + hooks.addAction('plugin_uninstalling_hello-world', async () => { + console.log('[HelloWorld] Plugin is being deleted. Goodbye!') + }) + + // ======================================== + // REGULAR PLUGIN CODE + // ======================================== + + // Log when the plugin is loaded + hooks.addAction('plugin_loaded', async (slug, manifest) => { + if (slug === 'hello-world') { + console.log(`[HelloWorld] Plugin loaded! Version: ${manifest.version ?? 'unknown'}`) + + // Register a component on the dashboard header slot + const pluginUI = globalThis.__escalated_pluginUI + if (pluginUI) { + pluginUI.addPageComponent('dashboard', 'header', { + component: 'HelloWorldBanner', + plugin: 'hello-world', + position: 1, + }) + } + } + }) + + // ======================================== + // EXAMPLES (uncomment to try!) + // ======================================== + + // Example 1: Log when tickets are created + // hooks.addAction('ticket_created', async (ticket) => { + // console.log(`[HelloWorld] A ticket was created: ${ticket.subject}`) + // }) + + // Example 2: Modify ticket subjects before display + // hooks.addFilter('ticket_display_subject', async (subject) => { + // return `[Demo] ${subject}` + // }) + + // Example 3: Add custom dashboard stats + // hooks.addFilter('dashboard_stats_data', async (stats) => { + // stats.hello_world_metric = Math.floor(Math.random() * 100) + // return stats + // }) + + // Example 4: Register a custom menu item + // const pluginUI = globalThis.__escalated_pluginUI + // if (pluginUI) { + // pluginUI.addMenuItem({ + // label: 'Hello World', + // route: 'escalated.agent.dashboard', + // icon: 'sparkles', + // position: 999, + // }) + // } +} diff --git a/stubs/plugins/hello-world/Plugin.ts b/stubs/plugins/hello-world/Plugin.ts new file mode 100644 index 0000000..4b69146 --- /dev/null +++ b/stubs/plugins/hello-world/Plugin.ts @@ -0,0 +1,114 @@ +/** + * Hello World Plugin for Escalated + * + * This plugin demonstrates the Escalated plugin system and does nothing + * particularly useful. It is here to show you how plugins work and give + * you a starting point for building your own. + * + * Demonstrates: + * - How to use action hooks + * - How to use filter hooks + * - How to handle lifecycle events (activate, deactivate, uninstall) + * - How to register UI components (menu items, dashboard widgets, page slots) + * + * Delete this whenever you want! + */ + +import type HookManager from '@escalated-dev/escalated-adonis/src/support/hook_manager' + +export default function register(hooks: HookManager) { + // ======================================== + // LIFECYCLE HOOKS + // ======================================== + + // Runs when this plugin is activated + hooks.addAction('plugin_activated_hello-world', async () => { + console.log('[HelloWorld] Plugin activated! Time to do... nothing!') + // This is where you would typically: + // - Run database migrations + // - Set up default settings + // - Initialize plugin data + }) + + // Runs when this plugin is deactivated + hooks.addAction('plugin_deactivated_hello-world', async () => { + console.log('[HelloWorld] Plugin deactivated. We had a good run!') + // This is where you would typically: + // - Clean up temporary data + // - Clear caches + // - Disable scheduled tasks + }) + + // Runs when this plugin is about to be deleted + hooks.addAction('plugin_uninstalling_hello-world', async () => { + console.log('[HelloWorld] Plugin is being deleted. Goodbye!') + // This is where you would typically: + // - Drop database tables + // - Remove all plugin data + // - Clean up any files created by the plugin + }) + + // ======================================== + // REGULAR PLUGIN CODE + // ======================================== + + // Log when the plugin is loaded + hooks.addAction('plugin_loaded', async (slug: string, manifest: any) => { + if (slug === 'hello-world') { + console.log(`[HelloWorld] Plugin loaded! Version: ${manifest.version ?? 'unknown'}`) + + // Register a component on the dashboard header slot + const pluginUI = (globalThis as any).__escalated_pluginUI + if (pluginUI) { + pluginUI.addPageComponent('dashboard', 'header', { + component: 'HelloWorldBanner', + plugin: 'hello-world', + position: 1, + }) + } + } + }) + + // ======================================== + // EXAMPLES (uncomment to try!) + // ======================================== + + // Example 1: Log when tickets are created + // hooks.addAction('ticket_created', async (ticket: any) => { + // console.log(`[HelloWorld] A ticket was created: ${ticket.subject}`) + // }) + + // Example 2: Modify ticket subjects before display + // hooks.addFilter('ticket_display_subject', async (subject: string) => { + // return `[Demo] ${subject}` + // }) + + // Example 3: Add custom dashboard stats + // hooks.addFilter('dashboard_stats_data', async (stats: Record) => { + // stats.hello_world_metric = Math.floor(Math.random() * 100) + // return stats + // }) + + // Example 4: Register a custom menu item + // const pluginUI = (globalThis as any).__escalated_pluginUI + // if (pluginUI) { + // pluginUI.addMenuItem({ + // label: 'Hello World', + // route: 'escalated.agent.dashboard', + // icon: 'sparkles', + // position: 999, + // }) + // } + + // Example 5: Register a dashboard widget + // if (pluginUI) { + // pluginUI.addDashboardWidget({ + // id: 'hello-world-widget', + // title: 'Hello World', + // component: 'HelloWorldWidget', + // data: { message: 'Hello from plugin!' }, + // position: 999, + // width: 'half', + // }) + // } +} diff --git a/stubs/plugins/hello-world/plugin.json b/stubs/plugins/hello-world/plugin.json new file mode 100644 index 0000000..57e4b94 --- /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.js" +}