diff --git a/app/Config/Events.php b/app/Config/Events.php index 0516b4a57..d4451b95a 100644 --- a/app/Config/Events.php +++ b/app/Config/Events.php @@ -8,23 +8,7 @@ use CodeIgniter\HotReloader\HotReloader; use App\Events\Db_log; use App\Events\Load_config; use App\Events\Method; - -/* - * -------------------------------------------------------------------- - * Application Events - * -------------------------------------------------------------------- - * Events allow you to tap into the execution of the program without - * modifying or extending core files. This file provides a central - * location to define your events, though they can always be added - * at run-time, also, if needed. - * - * You create code that can execute by subscribing to events with - * the 'on()' method. This accepts any form of callable, including - * Closures, that will be executed when the event is triggered. - * - * Example: - * Events::on('create', [$myInstance, 'myMethod']); - */ +use App\Libraries\Plugins\PluginManager; Events::on('pre_system', static function (): void { if (ENVIRONMENT !== 'testing') { @@ -39,16 +23,9 @@ Events::on('pre_system', static function (): void { ob_start(static fn ($buffer) => $buffer); } - /* - * -------------------------------------------------------------------- - * Debug Toolbar Listeners. - * -------------------------------------------------------------------- - * If you delete, they will no longer be collected. - */ if (CI_DEBUG && ! is_cli()) { Events::on('DBQuery', 'CodeIgniter\Debug\Toolbar\Collectors\Database::collect'); service('toolbar')->respond(); - // Hot Reload route - for framework use on the hot reloader. if (ENVIRONMENT === 'development') { service('routes')->get('__hot-reload', static function (): void { (new HotReloader())->run(); @@ -65,3 +42,9 @@ Events::on('DBQuery', [$db_log, 'db_log_queries']); $method = new Method(); Events::on('pre_controller', [$method, 'validate_method']); + +Events::on('post_system', static function (): void { + $pluginManager = new PluginManager(); + $pluginManager->discoverPlugins(); + $pluginManager->registerPluginEvents(); +}); \ No newline at end of file diff --git a/app/Controllers/Plugins/Manage.php b/app/Controllers/Plugins/Manage.php new file mode 100644 index 000000000..ce29d7c10 --- /dev/null +++ b/app/Controllers/Plugins/Manage.php @@ -0,0 +1,99 @@ +pluginManager = new PluginManager(); + $this->pluginManager->discoverPlugins(); + } + + public function getIndex(): string + { + $plugins = $this->pluginManager->getAllPlugins(); + $enabledPlugins = $this->pluginManager->getEnabledPlugins(); + + $pluginData = []; + foreach ($plugins as $pluginId => $plugin) { + $pluginData[$pluginId] = [ + 'id' => $plugin->getPluginId(), + 'name' => $plugin->getPluginName(), + 'description' => $plugin->getPluginDescription(), + 'version' => $plugin->getVersion(), + 'enabled' => isset($enabledPlugins[$pluginId]), + 'has_config' => $plugin->getConfigView() !== null, + ]; + } + + echo view('plugins/manage', ['plugins' => $pluginData]); + return ''; + } + + public function postEnable(string $pluginId): ResponseInterface + { + if ($this->pluginManager->enablePlugin($pluginId)) { + return $this->response->setJSON(['success' => true, 'message' => lang('Plugins.plugin_enabled')]); + } + return $this->response->setJSON(['success' => false, 'message' => lang('Plugins.plugin_enable_failed')]); + } + + public function postDisable(string $pluginId): ResponseInterface + { + if ($this->pluginManager->disablePlugin($pluginId)) { + return $this->response->setJSON(['success' => true, 'message' => lang('Plugins.plugin_disabled')]); + } + return $this->response->setJSON(['success' => false, 'message' => lang('Plugins.plugin_disable_failed')]); + } + + public function postUninstall(string $pluginId): ResponseInterface + { + if ($this->pluginManager->uninstallPlugin($pluginId)) { + return $this->response->setJSON(['success' => true, 'message' => lang('Plugins.plugin_uninstalled')]); + } + return $this->response->setJSON(['success' => false, 'message' => lang('Plugins.plugin_uninstall_failed')]); + } + + public function getConfig(string $pluginId): ResponseInterface + { + $plugin = $this->pluginManager->getPlugin($pluginId); + + if (!$plugin) { + return $this->response->setJSON(['success' => false, 'message' => lang('Plugins.plugin_not_found')]); + } + + $configView = $plugin->getConfigView(); + if (!$configView) { + return $this->response->setJSON(['success' => false, 'message' => lang('Plugins.plugin_no_config')]); + } + + $settings = $plugin->getSettings(); + echo view($configView, ['settings' => $settings, 'plugin' => $plugin]); + return $this->response; + } + + public function postSaveConfig(string $pluginId): ResponseInterface + { + $plugin = $this->pluginManager->getPlugin($pluginId); + + if (!$plugin) { + return $this->response->setJSON(['success' => false, 'message' => lang('Plugins.plugin_not_found')]); + } + + $settings = $this->request->getPost(); + unset($settings['_method'], $settings['csrf_token_name']); + + if ($plugin->saveSettings($settings)) { + return $this->response->setJSON(['success' => true, 'message' => lang('Plugins.settings_saved')]); + } + return $this->response->setJSON(['success' => false, 'message' => lang('Plugins.settings_save_failed')]); + } +} \ No newline at end of file diff --git a/app/Database/Migrations/20250531000000_PluginConfigTableCreate.php b/app/Database/Migrations/20250531000000_PluginConfigTableCreate.php new file mode 100644 index 000000000..578d7da4e --- /dev/null +++ b/app/Database/Migrations/20250531000000_PluginConfigTableCreate.php @@ -0,0 +1,25 @@ + "MailChimp", + "mailchimp_api_key" => "MailChimp API Key", + "mailchimp_configuration" => "MailChimp Configuration", + "mailchimp_key_successfully" => "API Key is valid.", + "mailchimp_key_unsuccessfully" => "API Key is invalid.", + "mailchimp_lists" => "MailChimp List(s)", + "mailchimp_tooltip" => "Click the icon for an API Key.", + + // Plugin Management + "plugins" => "Plugins", + "plugin_management" => "Plugin Management", + "plugin_name" => "Plugin Name", + "plugin_description" => "Description", + "plugin_version" => "Version", + "plugin_status" => "Status", + "plugin_enabled" => "Plugin enabled successfully", + "plugin_enable_failed" => "Failed to enable plugin", + "plugin_disabled" => "Plugin disabled successfully", + "plugin_disable_failed" => "Failed to disable plugin", + "plugin_uninstalled" => "Plugin uninstalled successfully", + "plugin_uninstall_failed" => "Failed to uninstall plugin", + "plugin_not_found" => "Plugin not found", + "plugin_no_config" => "This plugin has no configuration options", + "settings_saved" => "Plugin settings saved successfully", + "settings_save_failed" => "Failed to save plugin settings", + "enable" => "Enable", + "disable" => "Disable", + "configure" => "Configure", + "uninstall" => "Uninstall", + "no_plugins_found" => "No plugins found", + "active" => "Active", + "inactive" => "Inactive", +]; \ No newline at end of file diff --git a/app/Libraries/Plugins/BasePlugin.php b/app/Libraries/Plugins/BasePlugin.php new file mode 100644 index 000000000..87baf6978 --- /dev/null +++ b/app/Libraries/Plugins/BasePlugin.php @@ -0,0 +1,98 @@ +configModel = new Plugin_config(); + } + + /** + * Default install implementation. + * Override in subclass for custom installation logic. + */ + public function install(): bool + { + return true; + } + + /** + * Default uninstall implementation. + * Override in subclass for custom uninstallation logic. + */ + public function uninstall(): bool + { + return true; + } + + /** + * Check if the plugin is enabled. + */ + public function isEnabled(): bool + { + $enabled = $this->configModel->get("{$this->getPluginId()}_enabled"); + return $enabled === '1' || $enabled === 'true'; + } + + /** + * Get a plugin setting. + */ + protected function getSetting(string $key, mixed $default = null): mixed + { + $value = $this->configModel->get("{$this->getPluginId()}_{$key}"); + return $value ?? $default; + } + + /** + * Set a plugin setting. + */ + protected function setSetting(string $key, mixed $value): bool + { + $stringValue = is_array($value) || is_object($value) + ? json_encode($value) + : (string)$value; + + return $this->configModel->set("{$this->getPluginId()}_{$key}", $stringValue); + } + + /** + * Get all plugin settings. + */ + public function getSettings(): array + { + return $this->configModel->getPluginSettings($this->getPluginId()); + } + + /** + * Save plugin settings. + */ + public function saveSettings(array $settings): bool + { + $prefixedSettings = []; + foreach ($settings as $key => $value) { + $prefixedSettings["{$this->getPluginId()}_{$key}"] = (string)$value; + } + + return $this->configModel->batchSave($prefixedSettings); + } + + /** + * Log a plugin-specific message. + */ + protected function log(string $level, string $message): void + { + log_message($level, "[Plugin:{$this->getPluginName()}] {$message}"); + } +} \ No newline at end of file diff --git a/app/Libraries/Plugins/PluginInterface.php b/app/Libraries/Plugins/PluginInterface.php new file mode 100644 index 000000000..df6a4485d --- /dev/null +++ b/app/Libraries/Plugins/PluginInterface.php @@ -0,0 +1,96 @@ +configModel = new Plugin_config(); + $this->pluginsPath = APPPATH . 'Plugins'; + } + + /** + * Discover and load all available plugins. + * + * Scans the Plugins directory for classes implementing PluginInterface. + */ + public function discoverPlugins(): void + { + if (!is_dir($this->pluginsPath)) { + log_message('debug', 'Plugins directory does not exist: ' . $this->pluginsPath); + return; + } + + $iterator = new DirectoryIterator($this->pluginsPath); + foreach ($iterator as $file) { + if ($file->isDir() || $file->getExtension() !== 'php') { + continue; + } + + $className = 'App\\Plugins\\' . $file->getBasename('.php'); + + if (!class_exists($className)) { + continue; + } + + $plugin = new $className(); + + if (!$plugin instanceof PluginInterface) { + log_message('warning', "Plugin {$className} does not implement PluginInterface"); + continue; + } + + $this->plugins[$plugin->getPluginId()] = $plugin; + log_message('debug', "Discovered plugin: {$plugin->getPluginName()}"); + } + } + + /** + * Register event listeners for all enabled plugins. + * + * This should be called during application bootstrap. + */ + public function registerPluginEvents(): void + { + foreach ($this->plugins as $pluginId => $plugin) { + if ($this->isPluginEnabled($pluginId)) { + $this->enabledPlugins[$pluginId] = $plugin; + $plugin->registerEvents(); + log_message('debug', "Registered events for plugin: {$plugin->getPluginName()}"); + } + } + } + + /** + * Get all discovered plugins. + * + * @return array + */ + public function getAllPlugins(): array + { + return $this->plugins; + } + + /** + * Get all enabled plugins. + * + * @return array + */ + public function getEnabledPlugins(): array + { + return $this->enabledPlugins; + } + + /** + * Get a specific plugin by ID. + */ + public function getPlugin(string $pluginId): ?PluginInterface + { + return $this->plugins[$pluginId] ?? null; + } + + /** + * Check if a plugin is enabled. + */ + public function isPluginEnabled(string $pluginId): bool + { + $enabled = $this->configModel->get($this->getEnabledKey($pluginId)); + return $enabled === '1' || $enabled === 'true'; + } + + /** + * Enable a plugin. + */ + public function enablePlugin(string $pluginId): bool + { + $plugin = $this->getPlugin($pluginId); + if (!$plugin) { + log_message('error', "Plugin not found: {$pluginId}"); + return false; + } + + // Check if plugin needs installation + if (!$this->configModel->exists($this->getInstalledKey($pluginId))) { + if (!$plugin->install()) { + log_message('error', "Failed to install plugin: {$pluginId}"); + return false; + } + $this->configModel->set($this->getInstalledKey($pluginId), '1'); + } + + $this->configModel->set($this->getEnabledKey($pluginId), '1'); + log_message('info', "Plugin enabled: {$pluginId}"); + + return true; + } + + /** + * Disable a plugin. + */ + public function disablePlugin(string $pluginId): bool + { + $this->configModel->set($this->getEnabledKey($pluginId), '0'); + log_message('info', "Plugin disabled: {$pluginId}"); + + return true; + } + + /** + * Uninstall a plugin completely. + */ + public function uninstallPlugin(string $pluginId): bool + { + $plugin = $this->getPlugin($pluginId); + if (!$plugin) { + log_message('error', "Plugin not found: {$pluginId}"); + return false; + } + + if (!$plugin->uninstall()) { + log_message('error', "Failed to uninstall plugin: {$pluginId}"); + return false; + } + + // Remove all plugin configuration + $this->configModel->deleteAllStartingWith($pluginId . '_'); + + return true; + } + + /** + * Get plugin setting. + */ + public function getSetting(string $pluginId, string $key, mixed $default = null): mixed + { + return $this->configModel->get("{$pluginId}_{$key}") ?? $default; + } + + /** + * Set plugin setting. + */ + public function setSetting(string $pluginId, string $key, mixed $value): bool + { + return $this->configModel->set("{$pluginId}_{$key}", $value); + } + + /** + * Get the enabled config key for a plugin. + */ + private function getEnabledKey(string $pluginId): string + { + return "{$pluginId}_enabled"; + } + + /** + * Get the installed config key for a plugin. + */ + private function getInstalledKey(string $pluginId): string + { + return "{$pluginId}_installed"; + } +} \ No newline at end of file diff --git a/app/Models/Plugin_config.php b/app/Models/Plugin_config.php new file mode 100644 index 000000000..71deabfc1 --- /dev/null +++ b/app/Models/Plugin_config.php @@ -0,0 +1,135 @@ +db->table('plugin_config'); + $builder->where('key', $key); + + return ($builder->get()->getNumRows() === 1); + } + + /** + * Get a configuration value by key. + */ + public function get(string $key): ?string + { + $builder = $this->db->table('plugin_config'); + $query = $builder->getWhere(['key' => $key], 1); + + if ($query->getNumRows() === 1) { + return $query->getRow()->value; + } + + return null; + } + + /** + * Set a configuration value. + */ + public function set(string $key, string $value): bool + { + $builder = $this->db->table('plugin_config'); + + if ($this->exists($key)) { + return $builder->update(['value' => $value], ['key' => $key]); + } + + return $builder->insert(['key' => $key, 'value' => $value]); + } + + /** + * Get all configuration values for a specific plugin. + * + * @return array + */ + public function getPluginSettings(string $pluginId): array + { + $builder = $this->db->table('plugin_config'); + $builder->like('key', $pluginId . '_', 'after'); + $query = $builder->get(); + + $settings = []; + foreach ($query->getResult() as $row) { + $key = str_replace($pluginId . '_', '', $row->key); + $settings[$key] = $row->value; + } + + return $settings; + } + + /** + * Delete a configuration key. + */ + public function deleteKey(string $key): bool + { + $builder = $this->db->table('plugin_config'); + return $builder->delete(['key' => $key]); + } + + /** + * Delete all configuration keys starting with a prefix. + */ + public function deleteAllStartingWith(string $prefix): bool + { + $builder = $this->db->table('plugin_config'); + $builder->like('key', $prefix, 'after'); + return $builder->delete(); + } + + /** + * Batch save configuration values. + */ + public function batchSave(array $data): bool + { + $success = true; + + $this->db->transStart(); + + foreach ($data as $key => $value) { + $success &= $this->set($key, $value); + } + + $this->db->transComplete(); + + return $success && $this->db->transStatus(); + } + + /** + * Get all plugin configurations. + */ + public function getAll(): array + { + $builder = $this->db->table('plugin_config'); + $query = $builder->get(); + + $configs = []; + foreach ($query->getResult() as $row) { + $configs[$row->key] = $row->value; + } + + return $configs; + } +} \ No newline at end of file diff --git a/app/Plugins/ExamplePlugin.php b/app/Plugins/ExamplePlugin.php new file mode 100644 index 000000000..f1c8b3efd --- /dev/null +++ b/app/Plugins/ExamplePlugin.php @@ -0,0 +1,110 @@ +log('debug', 'Example plugin events registered'); + } + + public function install(): bool + { + $this->log('info', 'Installing Example Plugin'); + + $this->setSetting('log_changes', '1'); + $this->setSetting('log_sales', '1'); + + return true; + } + + public function uninstall(): bool + { + $this->log('info', 'Uninstalling Example Plugin'); + return true; + } + + public function getConfigView(): ?string + { + return 'Plugins/example/config'; + } + + public function getSettings(): array + { + return [ + 'log_changes' => $this->getSetting('log_changes', '1'), + 'log_sales' => $this->getSetting('log_sales', '1'), + ]; + } + + public function saveSettings(array $settings): bool + { + if (isset($settings['log_changes'])) { + $this->setSetting('log_changes', $settings['log_changes'] ? '1' : '0'); + } + + if (isset($settings['log_sales'])) { + $this->setSetting('log_sales', $settings['log_sales'] ? '1' : '0'); + } + + return true; + } + + /** + * Handle item change event. + */ + public function onItemChange(int $itemId): void + { + if (!$this->isEnabled() || $this->getSetting('log_changes', '1') !== '1') { + return; + } + + $this->log('info', "Item changed: ID {$itemId}"); + } + + /** + * Handle item sale event. + */ + public function onItemSale(array $saleData): void + { + if (!$this->isEnabled() || $this->getSetting('log_sales', '1') !== '1') { + return; + } + + $saleId = $saleData['sale_id_num'] ?? 'unknown'; + $this->log('info', "Item sale: ID {$saleId}"); + } +} \ No newline at end of file diff --git a/app/Plugins/MailchimpPlugin.php b/app/Plugins/MailchimpPlugin.php new file mode 100644 index 000000000..4c2610365 --- /dev/null +++ b/app/Plugins/MailchimpPlugin.php @@ -0,0 +1,210 @@ +log('debug', 'Mailchimp plugin events registered'); + } + + public function install(): bool + { + $this->log('info', 'Installing Mailchimp plugin'); + + $this->setSetting('api_key', ''); + $this->setSetting('list_id', ''); + $this->setSetting('sync_on_save', '1'); + $this->setSetting('enabled', '0'); + + return true; + } + + public function uninstall(): bool + { + $this->log('info', 'Uninstalling Mailchimp plugin'); + return true; + } + + public function getConfigView(): ?string + { + return 'Plugins/mailchimp/config'; + } + + public function getSettings(): array + { + return [ + 'api_key' => $this->getSetting('api_key', ''), + 'list_id' => $this->getSetting('list_id', ''), + 'sync_on_save' => $this->getSetting('sync_on_save', '1'), + 'enabled' => $this->getSetting('enabled', '0'), + ]; + } + + public function saveSettings(array $settings): bool + { + if (isset($settings['api_key'])) { + $this->setSetting('api_key', $settings['api_key']); + } + + if (isset($settings['list_id'])) { + $this->setSetting('list_id', $settings['list_id']); + } + + if (isset($settings['sync_on_save'])) { + $this->setSetting('sync_on_save', $settings['sync_on_save'] ? '1' : '0'); + } + + return true; + } + + /** + * Handle customer saved event. + * + * @param array $customerData Customer information + */ + public function onCustomerSaved(array $customerData): void + { + if (!$this->isEnabled() || !$this->shouldSyncOnSave()) { + return; + } + + $this->log('debug', "Customer saved event received for ID: {$customerData['person_id']}"); + + try { + $this->subscribeCustomer($customerData); + } catch (\Exception $e) { + $this->log('error', "Failed to sync customer to Mailchimp: {$e->getMessage()}"); + } + } + + /** + * Handle customer deleted event. + * + * @param int $customerId Customer ID + */ + public function onCustomerDeleted(int $customerId): void + { + if (!$this->isEnabled()) { + return; + } + + $this->log('debug', "Customer deleted event received for ID: {$customerId}"); + } + + /** + * Subscribe customer to Mailchimp list. + */ + private function subscribeCustomer(array $customerData): bool + { + $apiKey = $this->getSetting('api_key'); + $listId = $this->getSetting('list_id'); + + if (empty($apiKey) || empty($listId)) { + $this->log('warning', 'Mailchimp API key or List ID not configured'); + return false; + } + + if (empty($customerData['email'])) { + $this->log('debug', 'Customer has no email, skipping Mailchimp sync'); + return false; + } + + $mailchimp = $this->getMailchimpLib(['api_key' => $apiKey]); + + $result = $mailchimp->addOrUpdateMember( + $listId, + $customerData['email'], + $customerData['first_name'] ?? '', + $customerData['last_name'] ?? '', + 'subscribed' + ); + + if ($result) { + $this->log('info', "Successfully subscribed customer {$customerData['email']} to Mailchimp"); + return true; + } + + return false; + } + + /** + * Check if sync on save is enabled. + */ + private function shouldSyncOnSave(): bool + { + return $this->getSetting('sync_on_save', '1') === '1'; + } + + /** + * Get Mailchimp library instance. + */ + private function getMailchimpLib(array $params = []): Mailchimp_lib + { + if ($this->mailchimpLib === null) { + $this->mailchimpLib = new Mailchimp_lib($params); + } + return $this->mailchimpLib; + } + + /** + * Test the Mailchimp API connection. + */ + public function testConnection(): array + { + $apiKey = $this->getSetting('api_key'); + + if (empty($apiKey)) { + return ['success' => false, 'message' => 'API key not configured']; + } + + $mailchimp = $this->getMailchimpLib(['api_key' => $apiKey]); + $result = $mailchimp->getLists(); + + if ($result && isset($result['lists'])) { + return [ + 'success' => true, + 'message' => 'API key is valid', + 'lists' => $result['lists'] + ]; + } + + return ['success' => false, 'message' => 'API key is invalid']; + } +} \ No newline at end of file diff --git a/app/Plugins/README.md b/app/Plugins/README.md new file mode 100644 index 000000000..4d574bc32 --- /dev/null +++ b/app/Plugins/README.md @@ -0,0 +1,469 @@ +# OSPOS Plugin System + +## Overview + +The OSPOS Plugin System allows third-party integrations to extend the application's functionality without modifying core code. Plugins can listen to events, add configuration settings, and integrate with external services. + +## Architecture + +### Plugin Interface + +All plugins must implement `App\Libraries\Plugins\PluginInterface`: + +```php +interface PluginInterface +{ + public function getPluginId(): string; // Unique identifier + public function getPluginName(): string; // Display name + public function getPluginDescription(): string; + public function getVersion(): string; + public function registerEvents(): void; // Register event listeners + public function install(): bool; // First-time setup + public function uninstall(): bool; // Cleanup + public function isEnabled(): bool; + public function getConfigView(): ?string; // Configuration view path + public function getSettings(): array; + public function saveSettings(array $settings): bool; +} +``` + +### Base Plugin Class + +Extend `App\Libraries\Plugins\BasePlugin` for common functionality: + +```php +class MyPlugin extends BasePlugin +{ + public function getPluginId(): string { return 'my_plugin'; } + public function getPluginName(): string { return 'My Plugin'; } + // ... implement other methods +} +``` + +### Plugin Manager + +The `PluginManager` class handles: +- Plugin discovery from `app/Plugins/` directory +- Loading and registering enabled plugins +- Managing plugin settings + +## Available Events + +OSPOS fires these events that plugins can listen to: + +| Event | Arguments | Description | +|-------|-----------|-------------| +| `item_sale` | `array $saleData` | Fired when a sale is completed | +| `item_return` | `array $returnData` | Fired when a return is processed | +| `item_change` | `int $itemId` | Fired when an item is created/updated/deleted | +| `item_inventory` | `array $inventoryData` | Fired on inventory changes | +| `items_csv_import` | `array $importData` | Fired after items CSV import | +| `customers_csv_import` | `array $importData` | Fired after customers CSV import | + +## Creating a Plugin + +### 1. Create the Plugin Class + +```php +isEnabled()) { + return; + } + + // Your integration logic here + $this->log('info', "Processing sale: {$saleData['sale_id_num']}"); + } + + public function onItemChange(int $itemId): void + { + if (!$this->isEnabled()) { + return; + } + + // Your logic here + } + + public function install(): bool + { + // Set default settings + $this->setSetting('api_key', ''); + $this->setSetting('enabled', '0'); + return true; + } + + public function getConfigView(): ?string + { + return 'Plugins/my_plugin/config'; + } +} +``` + +### 2. Create Configuration View (Optional) + +```php +getSetting('setting_key', 'default_value'); + +// Set setting +$this->setSetting('setting_key', 'value'); + +// Get all plugin settings +$settings = $this->getSettings(); + +// Save multiple settings +$this->saveSettings(['key1' => 'value1', 'key2' => 'value2']); +``` + +Settings are prefixed with the plugin ID (e.g., `my_plugin_api_key`). + +## Example Plugins + +### Example Plugin (app/Plugins/ExamplePlugin.php) +A demonstration plugin that logs events to the debug log. + +### Mailchimp Plugin (app/Plugins/MailchimpPlugin.php) +Integrates with Mailchimp to sync customer data. + +## Database + +Plugin settings are stored in the `ospos_plugin_config` table: + +```sql +CREATE TABLE ospos_plugin_config ( + `key` varchar(100) NOT NULL PRIMARY KEY, + `value` text NOT NULL, + created_at timestamp DEFAULT current_timestamp(), + updated_at timestamp DEFAULT current_timestamp() ON UPDATE current_timestamp() +); +``` + +## Event Flow + +1. Application triggers event: `Events::trigger('item_sale', $data)` +2. PluginManager loads enabled plugins +3. Each plugin registers its listeners via `registerEvents()` +4. Events::on() callbacks are invoked automatically + +## Plugin Directory Structure + +### Core Plugin System Files + +``` +app/ +├── Libraries/ +│ └── Plugins/ +│ ├── PluginInterface.php # Contract all plugins must implement +│ ├── BasePlugin.php # Abstract base class with common functionality +│ └── PluginManager.php # Discovers and loads plugins +├── Models/ +│ └── Plugin_config.php # Model for plugin settings storage +├── Controllers/ +│ └── Plugins/ +│ └── Manage.php # Admin controller for plugin management +└── Views/ + └── plugins/ + └── manage.php # Plugin management admin view +``` + +### Plugin Files Organization + +Plugins are organized with a clear separation of concerns: + +``` +app/ +├── Plugins/ # EVENT ORCHESTRATORS +│ ├── ExamplePlugin.php # Simple plugin (event handlers only) +│ ├── MailchimpPlugin.php # Integration plugin +│ └── CasposPlugin.php # Complex plugin needing MVC +│ +├── Models/Plugins/ # DATA MODELS (for plugins needing custom tables) +│ └── Caspos_data.php # Model for CASPOS API data +│ +├── Controllers/Plugins/ # ADMIN CONTROLLERS (for plugin config UI) +│ └── Caspos.php # Controller for CASPOS admin interface +│ +└── Views/Plugins/ # ADMIN VIEWS (for plugin configuration) + ├── example/ + │ └── config.php + ├── mailchimp/ + │ └── config.php + └── caspos/ + └── config.php +``` + +## Plugin Architecture Patterns + +### Simple Plugin (Event Handlers Only) + +For plugins that only need to listen to events and don't require custom database tables or complex admin UI: + +```php +// app/Plugins/ExamplePlugin.php +class ExamplePlugin extends BasePlugin +{ + public function registerEvents(): void + { + Events::on('item_sale', [$this, 'onItemSale']); + } + + public function onItemSale(array $saleData): void + { + // Simple logic - just log or make API calls + $this->log('info', "Sale processed: {$saleData['sale_id_num']}"); + } +} +``` + +### Complex Plugin (Full MVC) + +For plugins that need database tables, complex admin UI, or business logic: + +**1. Plugin Class (Event Orchestrator)** - Entry point that registers events and coordinates with MVC components: + +```php +// app/Plugins/CasposPlugin.php +namespace App\Plugins; + +use App\Libraries\Plugins\BasePlugin; +use App\Models\Plugins\Caspos_data; +use CodeIgniter\Events\Events; + +class CasposPlugin extends BasePlugin +{ + private ?Caspos_data $dataModel = null; + + public function registerEvents(): void + { + Events::on('item_sale', [$this, 'onItemSale']); + Events::on('item_change', [$this, 'onItemChange']); + } + + private function getDataModel(): Caspos_data + { + if ($this->dataModel === null) { + $this->dataModel = new Caspos_data(); + } + return $this->dataModel; + } + + public function onItemSale(array $saleData): void + { + if (!$this->isEnabled()) { + return; + } + + // Use the model for data persistence + $this->getDataModel()->saveSaleRecord($saleData); + + // Call external API + $this->sendToGovernmentApi($saleData); + } + + private function sendToGovernmentApi(array $saleData): void + { + // Integration logic + } + + public function install(): bool + { + // Create plugin-specific database table + $this->getDataModel()->createTable(); + + // Set default settings + $this->setSetting('api_url', ''); + $this->setSetting('api_key', ''); + return true; + } +} +``` + +**2. Model (Data Persistence)** - For plugins needing custom database tables: + +```php +// app/Models/Plugins/Caspos_data.php +namespace App\Models\Plugins; + +use CodeIgniter\Model; + +class Caspos_data extends Model +{ + protected $table = 'caspos_records'; + protected $primaryKey = 'id'; + protected $allowedFields = [ + 'sale_id', + 'fiscal_number', + 'api_response', + 'created_at' + ]; + + public function createTable(): void + { + $forge = \Config\Database::forge(); + + $fields = [ + 'id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + 'auto_increment' => true + ], + 'sale_id' => [ + 'type' => 'INT', + 'constraint' => 11 + ], + 'fiscal_number' => [ + 'type' => 'VARCHAR', + 'constraint' => 100 + ], + 'api_response' => [ + 'type' => 'TEXT' + ], + 'created_at' => [ + 'type' => 'TIMESTAMP' + ] + ]; + + $forge->addField($fields); + $forge->addKey('id', true); + $forge->addKey('sale_id'); + $forge->createTable($this->table, true); + } + + public function saveSaleRecord(array $saleData): bool + { + return $this->insert([ + 'sale_id' => $saleData['sale_id_num'], + 'fiscal_number' => $saleData['fiscal_number'] ?? '', + 'api_response' => '' + ]); + } +} +``` + +**3. Controller (Admin UI)** - For plugin configuration pages: + +```php +// app/Controllers/Plugins/Caspos.php +namespace App\Controllers\Plugins; + +use App\Controllers\Secure_Controller; +use App\Models\Plugins\Caspos_data; + +class Caspos extends Secure_Controller +{ + private Caspos_data $dataModel; + + public function __construct() + { + parent::__construct('plugins'); + $this->dataModel = new Caspos_data(); + } + + public function getIndex(): void + { + $data = [ + 'records' => $this->dataModel->orderBy('created_at', 'DESC')->findAll(50) + ]; + echo view('Plugins/caspos/dashboard', $data); + } + + public function postTestConnection(): \CodeIgniter\HTTP\ResponseInterface + { + // Test API connection + return $this->response->setJSON(['success' => true]); + } +} +``` + +**4. Views (Admin Interface)** - Configuration and dashboard: + +```php +// app/Views/Plugins/caspos/config.php + +
+ + +
+
+ + +
+ + +``` + +### Architecture Summary + +| Component | Directory | Purpose | +|-----------|-----------|---------| +| Event Orchestrator | `app/Plugins/` | Implements `PluginInterface`, registers listeners, coordinates logic | +| Data Models | `app/Models/Plugins/` | Database models for plugin-specific tables | +| Admin Controllers | `app/Controllers/Plugins/` | Controllers for plugin configuration UI | +| Admin Views | `app/Views/Plugins/` | Views for plugin configuration | + +The **plugin class** in `app/Plugins/` acts as the entry point - it listens to events and coordinates with its own MVC components as needed. This keeps the architecture modular and maintains separation of concerns. + +## Testing + +Enable plugin logging to debug: + +```php +$this->log('debug', 'Debug message'); +$this->log('info', 'Info message'); +$this->log('error', 'Error message'); +``` + +Check logs in `writable/logs/`. \ No newline at end of file diff --git a/app/Views/plugins/manage.php b/app/Views/plugins/manage.php new file mode 100644 index 000000000..f4928ce04 --- /dev/null +++ b/app/Views/plugins/manage.php @@ -0,0 +1,119 @@ + + +
+
+
+
+
+

+ +

+
+
+ +
+ +
+ + + + + + + + + + + + + $plugin): ?> + + + + + + + + + +
+ +
+
+ + + + + + + + + + + + + + + +
+ +
+
+
+
+
+ + + + + + + \ No newline at end of file