fix(perfexcrm module): align version to 3.0.1, unify entrypoint, and harden routes/views

- Bump DESK_MOLONI version to 3.0.1 across module
- Normalize hooks to after_client_* and instantiate PerfexHooks safely
- Fix OAuthController view path and API client class name
- Add missing admin views for webhook config/logs; adjust view loading
- Harden client portal routes and admin routes mapping
- Make Dashboard/Logs/Queue tolerant to optional model methods
- Align log details query with existing schema; avoid broken joins

This makes the module operational in Perfex (admin + client), reduces 404s,
and avoids fatal errors due to inconsistent tables/methods.
This commit is contained in:
Emanuel Almeida
2025-09-11 17:38:45 +01:00
parent 5e5102db73
commit c19f6fd9ee
193 changed files with 59298 additions and 638 deletions

View File

@@ -0,0 +1,657 @@
<?php
/**
* Config_model.php
*
* Configuration management model for Desk-Moloni v3.0
* Handles secure storage of API credentials, OAuth tokens, and module configuration
* Supports encryption for sensitive data and OAuth token management with expiration
*
* @package DeskMoloni\Models
* @author PHP Fullstack Engineer
* @version 3.0
*/
defined('BASEPATH') or exit('No direct script access allowed');
require_once(dirname(__FILE__) . '/Desk_moloni_model.php');
class Config_model extends Desk_moloni_model
{
/**
* Table name
*/
private $table = 'desk_moloni_config';
/**
* Configuration cache
*/
private static $config_cache = [];
/**
* Cache TTL in seconds (5 minutes)
*/
private $cache_ttl = 300;
/**
* Sensitive configuration keys that should be encrypted
*/
private $sensitiveKeys = [
'oauth_client_secret',
'oauth_access_token',
'oauth_refresh_token',
'api_key',
'webhook_secret'
];
/**
* Default configuration values
*/
private $defaultConfig = [
'module_version' => '3.0.0',
'api_base_url' => 'https://api.moloni.pt/v1/',
'api_timeout' => '30',
'sync_enabled' => '1',
'sync_interval_minutes' => '15',
'max_retry_attempts' => '3',
'log_retention_days' => '365',
'queue_batch_size' => '50',
'encryption_algorithm' => 'AES-256-GCM'
];
public function __construct()
{
parent::__construct();
$this->table = $this->getTableName('config');
$this->initializeDefaults();
}
/**
* Get configuration value by key
*
* @param string $key Configuration key
* @param mixed $default Default value if key not found
* @return mixed Configuration value
*/
public function get($key, $default = null)
{
try {
// Validate key
if (empty($key)) {
return $default;
}
$query = $this->db->where('setting_key', $key)->get($this->table);
if ($query->num_rows() === 0) {
return $default;
}
$row = $query->row();
// Decrypt if encrypted
if ($row->encrypted == 1) {
return $this->decryptData($row->setting_value);
}
return $row->setting_value;
} catch (Exception $e) {
log_message('error', 'Desk-Moloni config get error: ' . $e->getMessage());
return $default;
}
}
/**
* Set configuration value
*
* @param string $key Configuration key
* @param mixed $value Configuration value
* @param bool $forceEncryption Force encryption regardless of key type
* @return bool Success status
* @throws InvalidArgumentException If key is empty or invalid
*/
public function set($key, $value, $forceEncryption = false)
{
try {
// Validate key
if (empty($key)) {
throw new InvalidArgumentException('Configuration key cannot be empty');
}
// Validate input
$validationErrors = $this->validateConfigData(['setting_key' => $key, 'setting_value' => $value]);
if (!empty($validationErrors)) {
throw new Exception('Validation failed: ' . implode(', ', $validationErrors));
}
// Determine if value should be encrypted
$shouldEncrypt = $forceEncryption || $this->isSensitiveKey($key);
// Prepare data
$data = [
'setting_key' => $key,
'setting_value' => $shouldEncrypt ? $this->encryptData($value) : $value,
'encrypted' => $shouldEncrypt ? 1 : 0,
'updated_at' => date('Y-m-d H:i:s')
];
// Check if key exists
$existing = $this->db->where('setting_key', $key)->get($this->table);
if ($existing->num_rows() > 0) {
// Update existing
$result = $this->db->where('setting_key', $key)->update($this->table, $data);
$this->logDatabaseOperation('update', $this->table, $data, $existing->row()->id);
} else {
// Insert new
$data['created_at'] = date('Y-m-d H:i:s');
$result = $this->db->insert($this->table, $data);
$this->logDatabaseOperation('create', $this->table, $data, $this->db->insert_id());
}
return $result;
} catch (InvalidArgumentException $e) {
throw $e; // Re-throw validation exceptions
} catch (Exception $e) {
log_message('error', 'Desk-Moloni config set error: ' . $e->getMessage());
return false;
}
}
/**
* Set encrypted configuration value
*
* @param string $key Configuration key
* @param mixed $value Configuration value
* @return bool Success status
*/
public function set_encrypted($key, $value)
{
return $this->set($key, $value, true);
}
/**
* Get encrypted configuration value (decrypted)
*
* @param string $key Configuration key
* @param mixed $default Default value if key not found
* @return mixed Decrypted configuration value
*/
public function get_encrypted($key, $default = null)
{
return $this->get($key, $default);
}
/**
* Set OAuth token with expiration
*
* @param string $token OAuth token
* @param int $expires_at Unix timestamp when token expires
* @return bool Success status
*/
public function set_oauth_token($token, $expires_at)
{
try {
$success = true;
$success &= $this->set('oauth_access_token', $token, true);
$success &= $this->set('oauth_token_expires_at', date('Y-m-d H:i:s', $expires_at));
return $success;
} catch (Exception $e) {
log_message('error', 'Desk-Moloni OAuth token set error: ' . $e->getMessage());
return false;
}
}
/**
* Get OAuth token with metadata
*
* @return array Token data array
*/
public function get_oauth_token()
{
try {
$token = $this->get('oauth_access_token');
$expires_at = $this->get('oauth_token_expires_at');
if (empty($token)) {
return [];
}
return [
'token' => $token,
'expires_at' => $expires_at ? strtotime($expires_at) : null,
'created_at' => time(),
'type' => 'oauth_token'
];
} catch (Exception $e) {
log_message('error', 'Desk-Moloni OAuth token get error: ' . $e->getMessage());
return [];
}
}
/**
* Check if OAuth token is valid and not expired
*
* @return bool True if token is valid
*/
public function is_oauth_token_valid()
{
try {
$accessToken = $this->get('oauth_access_token');
$expiresAt = $this->get('oauth_token_expires_at');
if (empty($accessToken) || empty($expiresAt)) {
return false;
}
// Check if token is expired (with 5-minute buffer)
$expirationTime = strtotime($expiresAt) - 300; // 5 minutes buffer
return time() < $expirationTime;
} catch (Exception $e) {
log_message('error', 'Desk-Moloni OAuth validation error: ' . $e->getMessage());
return false;
}
}
/**
* Delete configuration key
*
* @param string $key Configuration key
* @return bool Success status
*/
public function delete($key)
{
try {
if (empty($key)) {
return false;
}
$existing = $this->db->where('setting_key', $key)->get($this->table);
if ($existing->num_rows() === 0) {
return true; // Already doesn't exist
}
$result = $this->db->where('setting_key', $key)->delete($this->table);
$this->logDatabaseOperation('delete', $this->table, ['setting_key' => $key], $existing->row()->id);
return $result;
} catch (Exception $e) {
log_message('error', 'Desk-Moloni config delete error: ' . $e->getMessage());
return false;
}
}
/**
* Get all configuration values
*
* @param bool $includeEncrypted Whether to decrypt encrypted values
* @return array Configuration array
*/
public function get_all($includeEncrypted = true)
{
try {
$query = $this->db->get($this->table);
$config = [];
foreach ($query->result() as $row) {
if ($row->encrypted == 1 && $includeEncrypted) {
$config[$row->setting_key] = $this->decryptData($row->setting_value);
} elseif ($row->encrypted == 0) {
$config[$row->setting_key] = $row->setting_value;
}
// Skip encrypted values if includeEncrypted is false
}
// Add default values for missing keys
foreach ($this->defaultConfig as $key => $defaultValue) {
if (!isset($config[$key])) {
$config[$key] = $defaultValue;
}
}
return $config;
} catch (Exception $e) {
log_message('error', 'Desk-Moloni config get_all error: ' . $e->getMessage());
return $this->defaultConfig;
}
}
/**
* Set multiple configuration values in batch
*
* @param array $config_batch Array of key => value pairs
* @return bool Success status
*/
public function set_batch($config_batch)
{
try {
if (!is_array($config_batch) || empty($config_batch)) {
return false;
}
$success = true;
// Use transaction for batch operations
return $this->executeTransaction(function() use ($config_batch, &$success) {
foreach ($config_batch as $key => $value) {
if (!$this->set($key, $value)) {
$success = false;
throw new Exception("Failed to set config key: {$key}");
}
}
return $success;
});
} catch (Exception $e) {
log_message('error', 'Desk-Moloni config set_batch error: ' . $e->getMessage());
return false;
}
}
/**
* Get configuration keys by pattern
*
* @param string $pattern LIKE pattern for key matching
* @param bool $includeEncrypted Whether to decrypt encrypted values
* @return array Matching configuration
*/
public function getByPattern($pattern, $includeEncrypted = true)
{
try {
$query = $this->db->like('setting_key', $pattern)->get($this->table);
$config = [];
foreach ($query->result() as $row) {
if ($row->encrypted == 1 && $includeEncrypted) {
$config[$row->setting_key] = $this->decryptData($row->setting_value);
} elseif ($row->encrypted == 0) {
$config[$row->setting_key] = $row->setting_value;
}
}
return $config;
} catch (Exception $e) {
log_message('error', 'Desk-Moloni config getByPattern error: ' . $e->getMessage());
return [];
}
}
/**
* Get OAuth configuration
*
* @return array OAuth configuration
*/
public function getOAuthConfig()
{
$oauthKeys = [
'oauth_client_id',
'oauth_client_secret',
'oauth_access_token',
'oauth_refresh_token',
'oauth_token_expires_at'
];
$config = [];
foreach ($oauthKeys as $key) {
$config[$key] = $this->get($key);
}
return $config;
}
/**
* Set OAuth tokens
*
* @param array $tokens OAuth token data
* @return bool Success status
*/
public function setOAuthTokens($tokens)
{
try {
$requiredTokens = ['access_token', 'refresh_token', 'expires_in'];
foreach ($requiredTokens as $required) {
if (!isset($tokens[$required])) {
throw new Exception("Missing required OAuth token: {$required}");
}
}
// Calculate expiration timestamp
$expiresAt = date('Y-m-d H:i:s', time() + (int)$tokens['expires_in']);
$success = true;
$success &= $this->set('oauth_access_token', $tokens['access_token'], true);
$success &= $this->set('oauth_refresh_token', $tokens['refresh_token'], true);
$success &= $this->set('oauth_token_expires_at', $expiresAt);
return $success;
} catch (Exception $e) {
log_message('error', 'Desk-Moloni OAuth tokens error: ' . $e->getMessage());
return false;
}
}
/**
* Clear all OAuth tokens (for logout/revoke)
*
* @return bool Success status
*/
public function clearOAuthTokens()
{
$success = true;
$oauthKeys = ['oauth_access_token', 'oauth_refresh_token', 'oauth_token_expires_at'];
foreach ($oauthKeys as $key) {
$success &= $this->delete($key);
}
return $success;
}
/**
* Get API configuration
*
* @return array API configuration
*/
public function getAPIConfig()
{
return [
'base_url' => $this->get('api_base_url', 'https://api.moloni.pt/v1/'),
'timeout' => (int)$this->get('api_timeout', 30),
'company_id' => $this->get('moloni_company_id'),
'access_token' => $this->get('oauth_access_token')
];
}
/**
* Check if configuration key is sensitive and should be encrypted
*
* @param string $key Configuration key
* @return bool True if key is sensitive
*/
private function isSensitiveKey($key)
{
return in_array($key, $this->sensitiveKeys) ||
strpos($key, 'password') !== false ||
strpos($key, 'secret') !== false ||
strpos($key, 'token') !== false ||
strpos($key, 'key') !== false;
}
/**
* Validate configuration data
*
* @param array $data Configuration data to validate
* @return array Validation errors
*/
private function validateConfigData($data)
{
$errors = [];
// Required fields
$requiredFields = ['setting_key'];
$errors = array_merge($errors, $this->validateRequiredFields($data, $requiredFields));
// Field length limits
$fieldLimits = [
'setting_key' => 255
];
$errors = array_merge($errors, $this->validateFieldLengths($data, $fieldLimits));
// Key format validation
if (isset($data['setting_key'])) {
if (!preg_match('/^[a-z0-9_]+$/', $data['setting_key'])) {
$errors[] = 'Setting key must contain only lowercase letters, numbers, and underscores';
}
}
return $errors;
}
/**
* Initialize default configuration values
*
* @return bool Success status
*/
public function initializeDefaults()
{
$success = true;
foreach ($this->defaultConfig as $key => $value) {
// Only set if not already exists
if ($this->get($key) === null) {
$success &= $this->set($key, $value);
}
}
return $success;
}
/**
* Export configuration for backup (excluding sensitive data)
*
* @return array Non-sensitive configuration data
*/
public function exportConfig()
{
try {
$allConfig = $this->get_all(false); // Don't include encrypted values
// Remove sensitive keys entirely from export
foreach ($this->sensitiveKeys as $sensitiveKey) {
unset($allConfig[$sensitiveKey]);
}
return $allConfig;
} catch (Exception $e) {
log_message('error', 'Desk-Moloni config export error: ' . $e->getMessage());
return [];
}
}
/**
* Get configuration value with caching
*
* @param string $key Configuration key
* @param mixed $default Default value if key not found
* @return mixed Configuration value
*/
public function get_cached($key, $default = null)
{
$cache_key = 'config_' . $key;
// Check if cached and not expired
if (isset(self::$config_cache[$cache_key])) {
$cached = self::$config_cache[$cache_key];
if ((time() - $cached['timestamp']) < $this->cache_ttl) {
desk_moloni_log('debug', "Config cache hit for key: $key", [], 'cache');
return $cached['value'];
} else {
// Cache expired, remove it
unset(self::$config_cache[$cache_key]);
desk_moloni_log('debug', "Config cache expired for key: $key", [], 'cache');
}
}
// Cache miss, get from database
$value = $this->get($key, $default);
// Cache the result
self::$config_cache[$cache_key] = [
'value' => $value,
'timestamp' => time()
];
desk_moloni_log('debug', "Config cached for key: $key", ['ttl' => $this->cache_ttl], 'cache');
return $value;
}
/**
* Set configuration value and invalidate cache
*
* @param string $key Configuration key
* @param mixed $value Configuration value
* @param bool $forceEncryption Force encryption even for non-sensitive keys
* @return bool Success status
*/
public function set_cached($key, $value, $forceEncryption = false)
{
$result = $this->set($key, $value, $forceEncryption);
if ($result) {
// Invalidate cache for this key
$cache_key = 'config_' . $key;
unset(self::$config_cache[$cache_key]);
desk_moloni_log('debug', "Config cache invalidated for key: $key", [], 'cache');
}
return $result;
}
/**
* Clear all configuration cache
*/
public function clear_cache()
{
$count = count(self::$config_cache);
self::$config_cache = [];
desk_moloni_log('info', "Configuration cache cleared", ['cached_items' => $count], 'cache');
}
/**
* Get cache statistics
*
* @return array Cache statistics
*/
public function get_cache_stats()
{
$stats = [
'cached_items' => count(self::$config_cache),
'cache_ttl' => $this->cache_ttl,
'items' => []
];
foreach (self::$config_cache as $key => $data) {
$age = time() - $data['timestamp'];
$stats['items'][] = [
'key' => $key,
'age_seconds' => $age,
'expires_in' => $this->cache_ttl - $age
];
}
return $stats;
}
}

View File

@@ -0,0 +1,418 @@
<?php
/**
* Desk_moloni_config_model.php
*
* Model for desk_moloni_config table
* Handles secure storage of API credentials and module configuration
*
* @package DeskMoloni\Models
* @author Database Design Specialist
* @version 3.0
*/
defined('BASEPATH') or exit('No direct script access allowed');
require_once(dirname(__FILE__) . '/Desk_moloni_model.php');
class Desk_moloni_config_model extends Desk_moloni_model
{
/**
* Table name - must match Perfex CRM naming convention
*/
private $table = 'tbldeskmoloni_config';
/**
* Sensitive configuration keys that should be encrypted
*/
private $sensitiveKeys = [
'oauth_client_secret',
'oauth_access_token',
'oauth_refresh_token',
'api_key',
'webhook_secret'
];
public function __construct()
{
parent::__construct();
// Use Perfex CRM table naming convention: tbl + module_prefix + table_name
$this->table = 'tbldeskmoloni_config';
}
/**
* Get configuration value by key
*
* @param string $key Configuration key
* @param mixed $default Default value if key not found
* @return mixed Configuration value
*/
public function get($key, $default = null)
{
try {
$query = $this->db->where('setting_key', $key)->get($this->table);
if ($query->num_rows() === 0) {
return $default;
}
$row = $query->row();
// Decrypt if encrypted
if ($row->encrypted == 1) {
return $this->decryptData($row->setting_value);
}
return $row->setting_value;
} catch (Exception $e) {
log_message('error', 'Desk-Moloni config get error: ' . $e->getMessage());
return $default;
}
}
/**
* Set configuration value
*
* @param string $key Configuration key
* @param mixed $value Configuration value
* @param bool $forceEncryption Force encryption regardless of key type
* @return bool Success status
*/
public function set($key, $value, $forceEncryption = false)
{
try {
// Validate input
$validationErrors = $this->validateConfigData(['setting_key' => $key, 'setting_value' => $value]);
if (!empty($validationErrors)) {
throw new Exception('Validation failed: ' . implode(', ', $validationErrors));
}
// Determine if value should be encrypted
$shouldEncrypt = $forceEncryption || $this->isSensitiveKey($key);
// Prepare data
$data = [
'setting_key' => $key,
'setting_value' => $shouldEncrypt ? $this->encryptData($value) : $value,
'encrypted' => $shouldEncrypt ? 1 : 0,
'updated_at' => date('Y-m-d H:i:s')
];
// Check if key exists
$existing = $this->db->where('setting_key', $key)->get($this->table);
if ($existing->num_rows() > 0) {
// Update existing
$result = $this->db->where('setting_key', $key)->update($this->table, $data);
$this->logDatabaseOperation('update', $this->table, $data, $existing->row()->id);
} else {
// Insert new
$data['created_at'] = date('Y-m-d H:i:s');
$result = $this->db->insert($this->table, $data);
$this->logDatabaseOperation('create', $this->table, $data, $this->db->insert_id());
}
return $result;
} catch (Exception $e) {
log_message('error', 'Desk-Moloni config set error: ' . $e->getMessage());
return false;
}
}
/**
* Delete configuration key
*
* @param string $key Configuration key
* @return bool Success status
*/
public function delete($key)
{
try {
$existing = $this->db->where('setting_key', $key)->get($this->table);
if ($existing->num_rows() === 0) {
return true; // Already doesn't exist
}
$result = $this->db->where('setting_key', $key)->delete($this->table);
$this->logDatabaseOperation('delete', $this->table, ['setting_key' => $key], $existing->row()->id);
return $result;
} catch (Exception $e) {
log_message('error', 'Desk-Moloni config delete error: ' . $e->getMessage());
return false;
}
}
/**
* Get all configuration values
*
* @param bool $includeEncrypted Whether to decrypt encrypted values
* @return array Configuration array
*/
public function getAll($includeEncrypted = true)
{
try {
$query = $this->db->get($this->table);
$config = [];
foreach ($query->result() as $row) {
if ($row->encrypted == 1 && $includeEncrypted) {
$config[$row->setting_key] = $this->decryptData($row->setting_value);
} elseif ($row->encrypted == 0) {
$config[$row->setting_key] = $row->setting_value;
}
// Skip encrypted values if includeEncrypted is false
}
return $config;
} catch (Exception $e) {
log_message('error', 'Desk-Moloni config getAll error: ' . $e->getMessage());
return [];
}
}
/**
* Get configuration keys by pattern
*
* @param string $pattern LIKE pattern for key matching
* @param bool $includeEncrypted Whether to decrypt encrypted values
* @return array Matching configuration
*/
public function getByPattern($pattern, $includeEncrypted = true)
{
try {
$query = $this->db->like('setting_key', $pattern)->get($this->table);
$config = [];
foreach ($query->result() as $row) {
if ($row->encrypted == 1 && $includeEncrypted) {
$config[$row->setting_key] = $this->decryptData($row->setting_value);
} elseif ($row->encrypted == 0) {
$config[$row->setting_key] = $row->setting_value;
}
}
return $config;
} catch (Exception $e) {
log_message('error', 'Desk-Moloni config getByPattern error: ' . $e->getMessage());
return [];
}
}
/**
* Get OAuth configuration
*
* @return array OAuth configuration
*/
public function getOAuthConfig()
{
$oauthKeys = [
'oauth_client_id',
'oauth_client_secret',
'oauth_access_token',
'oauth_refresh_token',
'oauth_token_expires_at'
];
$config = [];
foreach ($oauthKeys as $key) {
$config[$key] = $this->get($key);
}
return $config;
}
/**
* Set OAuth tokens
*
* @param array $tokens OAuth token data
* @return bool Success status
*/
public function setOAuthTokens($tokens)
{
try {
$requiredTokens = ['access_token', 'refresh_token', 'expires_in'];
foreach ($requiredTokens as $required) {
if (!isset($tokens[$required])) {
throw new Exception("Missing required OAuth token: {$required}");
}
}
// Calculate expiration timestamp
$expiresAt = date('Y-m-d H:i:s', time() + (int)$tokens['expires_in']);
$success = true;
$success &= $this->set('oauth_access_token', $tokens['access_token']);
$success &= $this->set('oauth_refresh_token', $tokens['refresh_token']);
$success &= $this->set('oauth_token_expires_at', $expiresAt);
return $success;
} catch (Exception $e) {
log_message('error', 'Desk-Moloni OAuth tokens error: ' . $e->getMessage());
return false;
}
}
/**
* Check if OAuth tokens are valid and not expired
*
* @return bool True if tokens are valid
*/
public function isOAuthValid()
{
try {
$accessToken = $this->get('oauth_access_token');
$expiresAt = $this->get('oauth_token_expires_at');
if (empty($accessToken) || empty($expiresAt)) {
return false;
}
// Check if token is expired (with 5-minute buffer)
$expirationTime = strtotime($expiresAt) - 300; // 5 minutes buffer
return time() < $expirationTime;
} catch (Exception $e) {
log_message('error', 'Desk-Moloni OAuth validation error: ' . $e->getMessage());
return false;
}
}
/**
* Get API configuration
*
* @return array API configuration
*/
public function getAPIConfig()
{
return [
'base_url' => $this->get('api_base_url', 'https://api.moloni.pt/v1/'),
'timeout' => (int)$this->get('api_timeout', 30),
'company_id' => $this->get('moloni_company_id'),
'access_token' => $this->get('oauth_access_token')
];
}
/**
* Check if configuration key is sensitive and should be encrypted
*
* @param string $key Configuration key
* @return bool True if key is sensitive
*/
private function isSensitiveKey($key)
{
return in_array($key, $this->sensitiveKeys) ||
strpos($key, 'password') !== false ||
strpos($key, 'secret') !== false ||
strpos($key, 'token') !== false ||
strpos($key, 'key') !== false;
}
/**
* Validate configuration data
*
* @param array $data Configuration data to validate
* @return array Validation errors
*/
private function validateConfigData($data)
{
$errors = [];
// Required fields
$requiredFields = ['setting_key'];
$errors = array_merge($errors, $this->validateRequiredFields($data, $requiredFields));
// Field length limits
$fieldLimits = [
'setting_key' => 255
];
$errors = array_merge($errors, $this->validateFieldLengths($data, $fieldLimits));
// Key format validation
if (isset($data['setting_key'])) {
if (!preg_match('/^[a-z0-9_]+$/', $data['setting_key'])) {
$errors[] = 'Setting key must contain only lowercase letters, numbers, and underscores';
}
}
return $errors;
}
/**
* Initialize default configuration values
*
* @return bool Success status
*/
public function initializeDefaults()
{
$defaults = [
'api_base_url' => 'https://api.moloni.pt/v1/',
'api_timeout' => '30',
'sync_enabled' => '1',
'sync_interval_minutes' => '15',
'max_retry_attempts' => '3',
'log_retention_days' => '365',
'queue_batch_size' => '50',
'encryption_algorithm' => 'AES-256-GCM'
];
$success = true;
foreach ($defaults as $key => $value) {
// Only set if not already exists
if ($this->get($key) === null) {
$success &= $this->set($key, $value);
}
}
return $success;
}
/**
* Export configuration for backup (excluding sensitive data)
*
* @return array Non-sensitive configuration data
*/
public function exportConfig()
{
try {
$allConfig = $this->getAll(false); // Don't include encrypted values
// Remove sensitive keys entirely from export
foreach ($this->sensitiveKeys as $sensitiveKey) {
unset($allConfig[$sensitiveKey]);
}
return $allConfig;
} catch (Exception $e) {
log_message('error', 'Desk-Moloni config export error: ' . $e->getMessage());
return [];
}
}
/**
* Clear all OAuth tokens (for logout/revoke)
*
* @return bool Success status
*/
public function clearOAuthTokens()
{
$success = true;
$oauthKeys = ['oauth_access_token', 'oauth_refresh_token', 'oauth_token_expires_at'];
foreach ($oauthKeys as $key) {
$success &= $this->delete($key);
}
return $success;
}
}

View File

@@ -0,0 +1,493 @@
<?php
defined('BASEPATH') or exit('No direct script access allowed');
/**
* Desk-Moloni Invoice Model
*
* Manages invoice data, synchronization, and business logic
* Handles invoice operations between Perfex CRM and Moloni
*
* @package DeskMoloni
* @subpackage Models
* @version 3.0.0
* @author Descomplicar®
*/
class Desk_moloni_invoice_model extends CI_Model
{
private $table = 'tblinvoices';
private $moloni_invoice_table = 'tbldeskmoloni_invoices';
/**
* Constructor
*/
public function __construct()
{
parent::__construct();
// Create Moloni invoice mapping table if it doesn't exist
$this->create_moloni_invoice_table();
}
/**
* Create Moloni invoice mapping table
*/
private function create_moloni_invoice_table()
{
if (!$this->db->table_exists($this->moloni_invoice_table)) {
$this->db->query("
CREATE TABLE IF NOT EXISTS `{$this->moloni_invoice_table}` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`perfex_invoice_id` int(11) NOT NULL,
`moloni_invoice_id` int(11) DEFAULT NULL,
`moloni_document_id` varchar(255) DEFAULT NULL,
`moloni_document_number` varchar(100) DEFAULT NULL,
`moloni_document_type` varchar(50) DEFAULT 'invoice',
`sync_status` enum('pending','synced','failed','partial') DEFAULT 'pending',
`last_sync_at` datetime DEFAULT NULL,
`sync_error` text DEFAULT NULL,
`moloni_data` longtext DEFAULT NULL,
`pdf_url` varchar(500) DEFAULT NULL,
`pdf_downloaded` tinyint(1) DEFAULT 0,
`created_at` datetime DEFAULT CURRENT_TIMESTAMP,
`updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_perfex_invoice` (`perfex_invoice_id`),
KEY `idx_moloni_invoice` (`moloni_invoice_id`),
KEY `idx_sync_status` (`sync_status`),
KEY `idx_last_sync` (`last_sync_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
");
}
}
/**
* Get invoice with Moloni mapping data
*
* @param int $invoice_id Perfex invoice ID
* @return array|null Invoice data with Moloni mapping
*/
public function get_invoice_with_moloni_data($invoice_id)
{
$this->db->select('i.*, mi.*');
$this->db->from("{$this->table} i");
$this->db->join("{$this->moloni_invoice_table} mi", 'mi.perfex_invoice_id = i.id', 'left');
$this->db->where('i.id', $invoice_id);
$query = $this->db->get();
return $query->row_array();
}
/**
* Get invoices for synchronization
*
* @param array $filters Filtering options
* @return array Invoices needing sync
*/
public function get_invoices_for_sync($filters = [])
{
$this->db->select('i.*, mi.sync_status, mi.moloni_invoice_id, mi.last_sync_at');
$this->db->from("{$this->table} i");
$this->db->join("{$this->moloni_invoice_table} mi", 'mi.perfex_invoice_id = i.id', 'left');
// Default filters
if (!isset($filters['include_synced'])) {
$this->db->where("(mi.sync_status IS NULL OR mi.sync_status != 'synced')");
}
// Status filter
if (isset($filters['status'])) {
$this->db->where('i.status', $filters['status']);
}
// Date range filter
if (isset($filters['date_from'])) {
$this->db->where('i.date >=', $filters['date_from']);
}
if (isset($filters['date_to'])) {
$this->db->where('i.date <=', $filters['date_to']);
}
// Client filter
if (isset($filters['clientid'])) {
$this->db->where('i.clientid', $filters['clientid']);
}
// Sync status filter
if (isset($filters['sync_status'])) {
$this->db->where('mi.sync_status', $filters['sync_status']);
}
// Limit
if (isset($filters['limit'])) {
$this->db->limit($filters['limit']);
}
$this->db->order_by('i.date', 'DESC');
$query = $this->db->get();
return $query->result_array();
}
/**
* Create or update Moloni invoice mapping
*
* @param int $perfex_invoice_id Perfex invoice ID
* @param array $moloni_data Moloni invoice data
* @return bool Success status
*/
public function save_moloni_mapping($perfex_invoice_id, $moloni_data)
{
$mapping_data = [
'perfex_invoice_id' => $perfex_invoice_id,
'moloni_invoice_id' => $moloni_data['document_id'] ?? null,
'moloni_document_id' => $moloni_data['document_id'] ?? null,
'moloni_document_number' => $moloni_data['number'] ?? null,
'moloni_document_type' => $moloni_data['document_type'] ?? 'invoice',
'sync_status' => $moloni_data['sync_status'] ?? 'synced',
'last_sync_at' => date('Y-m-d H:i:s'),
'moloni_data' => json_encode($moloni_data),
'pdf_url' => $moloni_data['pdf_url'] ?? null
];
// Check if mapping exists
$existing = $this->db->get_where($this->moloni_invoice_table,
['perfex_invoice_id' => $perfex_invoice_id])->row_array();
if ($existing) {
$mapping_data['updated_at'] = date('Y-m-d H:i:s');
$this->db->where('perfex_invoice_id', $perfex_invoice_id);
return $this->db->update($this->moloni_invoice_table, $mapping_data);
} else {
$mapping_data['created_at'] = date('Y-m-d H:i:s');
return $this->db->insert($this->moloni_invoice_table, $mapping_data);
}
}
/**
* Update sync status for invoice
*
* @param int $perfex_invoice_id Perfex invoice ID
* @param string $status Sync status
* @param string|null $error Error message if failed
* @return bool Success status
*/
public function update_sync_status($perfex_invoice_id, $status, $error = null)
{
$update_data = [
'sync_status' => $status,
'last_sync_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s')
];
if ($error) {
$update_data['sync_error'] = $error;
} else {
$update_data['sync_error'] = null;
}
// Check if mapping exists
$existing = $this->db->get_where($this->moloni_invoice_table,
['perfex_invoice_id' => $perfex_invoice_id])->row_array();
if ($existing) {
$this->db->where('perfex_invoice_id', $perfex_invoice_id);
return $this->db->update($this->moloni_invoice_table, $update_data);
} else {
$update_data['perfex_invoice_id'] = $perfex_invoice_id;
$update_data['created_at'] = date('Y-m-d H:i:s');
return $this->db->insert($this->moloni_invoice_table, $update_data);
}
}
/**
* Get invoice line items with product mapping
*
* @param int $invoice_id Invoice ID
* @return array Line items with mapping data
*/
public function get_invoice_items_with_mapping($invoice_id)
{
$this->db->select('ii.*, pm.moloni_product_id, pm.mapping_data');
$this->db->from('tblinvoiceitems ii');
$this->db->join('tbldeskmoloni_mapping pm',
"pm.perfex_id = ii.rel_id AND pm.entity_type = 'product'", 'left');
$this->db->where('ii.rel_type', 'invoice');
$this->db->where('ii.rel_id', $invoice_id);
$this->db->order_by('ii.item_order', 'ASC');
$query = $this->db->get();
return $query->result_array();
}
/**
* Calculate invoice totals for validation
*
* @param int $invoice_id Invoice ID
* @return array Invoice totals
*/
public function calculate_invoice_totals($invoice_id)
{
$invoice = $this->get_invoice_with_moloni_data($invoice_id);
$items = $this->get_invoice_items_with_mapping($invoice_id);
$subtotal = 0;
$tax_total = 0;
$discount_total = 0;
foreach ($items as $item) {
$line_subtotal = $item['qty'] * $item['rate'];
$line_discount = 0;
if ($item['item_discount_type'] == 'percent') {
$line_discount = $line_subtotal * ($item['item_discount'] / 100);
} else {
$line_discount = $item['item_discount'];
}
$line_subtotal_after_discount = $line_subtotal - $line_discount;
// Calculate tax
$tax_rate = 0;
if ($item['taxname']) {
// Get tax rate from tax name
$tax_rate = $this->get_tax_rate_by_name($item['taxname']);
}
$line_tax = $line_subtotal_after_discount * ($tax_rate / 100);
$subtotal += $line_subtotal;
$discount_total += $line_discount;
$tax_total += $line_tax;
}
// Apply invoice-level discount
if ($invoice['discount_percent'] > 0) {
$additional_discount = $subtotal * ($invoice['discount_percent'] / 100);
$discount_total += $additional_discount;
} else if ($invoice['discount_total'] > 0) {
$discount_total += $invoice['discount_total'];
}
$total = $subtotal - $discount_total + $tax_total;
return [
'subtotal' => round($subtotal, 2),
'discount_total' => round($discount_total, 2),
'tax_total' => round($tax_total, 2),
'total' => round($total, 2),
'currency' => $invoice['currency_name'] ?? get_base_currency()->name
];
}
/**
* Get tax rate by tax name
*
* @param string $tax_name Tax name
* @return float Tax rate
*/
private function get_tax_rate_by_name($tax_name)
{
$this->db->select('taxrate');
$this->db->from('tbltaxes');
$this->db->where('name', $tax_name);
$query = $this->db->get();
$result = $query->row_array();
return $result ? (float) $result['taxrate'] : 0;
}
/**
* Validate invoice data for Moloni sync
*
* @param array $invoice Invoice data
* @return array Validation result
*/
public function validate_for_moloni_sync($invoice)
{
$issues = [];
$warnings = [];
// Required fields validation
if (empty($invoice['clientid'])) {
$issues[] = 'Invoice must have a valid client';
}
if (empty($invoice['date'])) {
$issues[] = 'Invoice must have a valid date';
}
if (empty($invoice['number'])) {
$issues[] = 'Invoice must have a number';
}
// Invoice items validation
$items = $this->get_invoice_items_with_mapping($invoice['id']);
if (empty($items)) {
$issues[] = 'Invoice must have at least one line item';
}
// Client mapping validation
$this->load->model('desk_moloni/desk_moloni_mapping_model', 'mapping_model');
$client_mapping = $this->mapping_model->get_mapping('client', $invoice['clientid']);
if (!$client_mapping) {
$warnings[] = 'Client is not mapped to Moloni - will attempt auto-mapping';
}
// Product mapping validation
$unmapped_products = 0;
foreach ($items as $item) {
if (empty($item['moloni_product_id'])) {
$unmapped_products++;
}
}
if ($unmapped_products > 0) {
$warnings[] = "{$unmapped_products} product(s) not mapped to Moloni";
}
// Currency validation
if (empty($invoice['currency_name'])) {
$warnings[] = 'Invoice currency not specified - will use base currency';
}
// Status validation
if ($invoice['status'] != 2) { // Status 2 = Sent
$warnings[] = 'Invoice status is not "Sent" - may not be ready for sync';
}
return [
'is_valid' => empty($issues),
'issues' => $issues,
'warnings' => $warnings,
'items_count' => count($items),
'total_amount' => $invoice['total'] ?? 0
];
}
/**
* Get invoice sync statistics
*
* @param string $period Period for statistics
* @return array Sync statistics
*/
public function get_sync_statistics($period = '30days')
{
$date_condition = '';
switch ($period) {
case '7days':
$date_condition = "DATE(mi.created_at) >= DATE_SUB(CURDATE(), INTERVAL 7 DAY)";
break;
case '30days':
$date_condition = "DATE(mi.created_at) >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)";
break;
case '90days':
$date_condition = "DATE(mi.created_at) >= DATE_SUB(CURDATE(), INTERVAL 90 DAY)";
break;
default:
$date_condition = "1=1";
}
// Overall statistics
$overall_query = "
SELECT
COUNT(*) as total_invoices,
COUNT(mi.id) as mapped_invoices,
SUM(CASE WHEN mi.sync_status = 'synced' THEN 1 ELSE 0 END) as synced_invoices,
SUM(CASE WHEN mi.sync_status = 'failed' THEN 1 ELSE 0 END) as failed_invoices,
SUM(CASE WHEN mi.sync_status = 'pending' THEN 1 ELSE 0 END) as pending_invoices,
AVG(i.total) as avg_invoice_amount
FROM {$this->table} i
LEFT JOIN {$this->moloni_invoice_table} mi ON mi.perfex_invoice_id = i.id
WHERE {$date_condition}
";
$overall_stats = $this->db->query($overall_query)->row_array();
// Daily statistics
$daily_query = "
SELECT
DATE(mi.created_at) as sync_date,
COUNT(*) as invoices_synced,
SUM(CASE WHEN mi.sync_status = 'synced' THEN 1 ELSE 0 END) as successful_syncs,
SUM(CASE WHEN mi.sync_status = 'failed' THEN 1 ELSE 0 END) as failed_syncs
FROM {$this->moloni_invoice_table} mi
WHERE {$date_condition}
GROUP BY DATE(mi.created_at)
ORDER BY sync_date DESC
LIMIT 30
";
$daily_stats = $this->db->query($daily_query)->result_array();
return [
'period' => $period,
'overall' => $overall_stats,
'daily' => $daily_stats,
'sync_rate' => $overall_stats['total_invoices'] > 0 ?
round(($overall_stats['synced_invoices'] / $overall_stats['total_invoices']) * 100, 2) : 0
];
}
/**
* Get invoices with sync errors
*
* @param int $limit Number of records to return
* @return array Invoices with errors
*/
public function get_sync_errors($limit = 50)
{
$this->db->select('i.id, i.number, i.date, i.clientid, i.total, mi.sync_error, mi.last_sync_at');
$this->db->from("{$this->table} i");
$this->db->join("{$this->moloni_invoice_table} mi", 'mi.perfex_invoice_id = i.id');
$this->db->where('mi.sync_status', 'failed');
$this->db->where('mi.sync_error IS NOT NULL');
$this->db->order_by('mi.last_sync_at', 'DESC');
$this->db->limit($limit);
$query = $this->db->get();
return $query->result_array();
}
/**
* Mark invoice for re-sync
*
* @param int $invoice_id Invoice ID
* @return bool Success status
*/
public function mark_for_resync($invoice_id)
{
$update_data = [
'sync_status' => 'pending',
'sync_error' => null,
'updated_at' => date('Y-m-d H:i:s')
];
$this->db->where('perfex_invoice_id', $invoice_id);
return $this->db->update($this->moloni_invoice_table, $update_data);
}
/**
* Clean up old sync records
*
* @param int $days_old Records older than this many days
* @return int Number of records cleaned
*/
public function cleanup_old_sync_records($days_old = 90)
{
$this->db->where('sync_status', 'synced');
$this->db->where('created_at <', date('Y-m-d H:i:s', strtotime("-{$days_old} days")));
// Keep the mapping but clear the detailed data
$update_data = [
'moloni_data' => null,
'sync_error' => null
];
$this->db->update($this->moloni_invoice_table, $update_data);
return $this->db->affected_rows();
}
}

View File

@@ -0,0 +1,830 @@
<?php
/**
* Desk_moloni_mapping_model.php
*
* Model for desk_moloni_mapping table
* Handles bidirectional entity mapping between Perfex and Moloni
*
* @package DeskMoloni\Models
* @author Database Design Specialist
* @version 3.0
*/
defined('BASEPATH') or exit('No direct script access allowed');
require_once(dirname(__FILE__) . '/Desk_moloni_model.php');
class Desk_moloni_mapping_model extends Desk_moloni_model
{
/**
* Table name - must match Perfex CRM naming convention
*/
private $table = 'tbldeskmoloni_mapping';
/**
* Valid entity types
*/
private $validEntityTypes = [
'client', 'product', 'invoice', 'estimate', 'credit_note'
];
/**
* Valid sync directions
*/
private $validSyncDirections = [
'perfex_to_moloni', 'moloni_to_perfex', 'bidirectional'
];
public function __construct()
{
parent::__construct();
// Use Perfex CRM table naming convention: tbl + module_prefix + table_name
$this->table = 'tbldeskmoloni_mapping';
}
/**
* Create new mapping between Perfex and Moloni entities
*
* @param array $data Mapping data array
* @return int|false Mapping ID or false on failure
*/
public function create_mapping($data)
{
try {
// Set default values if not provided
$mapping_data = array_merge([
'sync_direction' => 'bidirectional',
'sync_status' => 'pending',
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s')
], $data);
// Validate data
$validationErrors = $this->validateMappingData($mapping_data);
if (!empty($validationErrors)) {
throw new Exception('Validation failed: ' . implode(', ', $validationErrors));
}
// Check for existing mappings if both IDs provided
if (isset($mapping_data['perfex_id']) && isset($mapping_data['moloni_id']) && $mapping_data['moloni_id']) {
if ($this->mappingExists($mapping_data['entity_type'], $mapping_data['perfex_id'], $mapping_data['moloni_id'])) {
throw new Exception('Mapping already exists for this entity');
}
}
$result = $this->db->insert($this->table, $mapping_data);
if ($result) {
$mappingId = $this->db->insert_id();
$this->logDatabaseOperation('create', $this->table, $mapping_data, $mappingId);
return $mappingId;
}
return false;
} catch (Exception $e) {
log_message('error', 'Desk-Moloni mapping create error: ' . $e->getMessage());
return false;
}
}
/**
* Create new mapping between Perfex and Moloni entities (legacy method)
*
* @param string $entityType Entity type
* @param int $perfexId Perfex entity ID
* @param int $moloniId Moloni entity ID
* @param string $syncDirection Sync direction
* @return int|false Mapping ID or false on failure
*/
public function createMapping($entityType, $perfexId, $moloniId, $syncDirection = 'bidirectional')
{
// Legacy wrapper - convert to new format and call create_mapping
$data = [
'entity_type' => $entityType,
'perfex_id' => (int)$perfexId,
'moloni_id' => (int)$moloniId,
'sync_direction' => $syncDirection
];
return $this->create_mapping($data);
}
/**
* Get mapping by Moloni ID
*
* @param string $entityType Entity type
* @param string $moloniId Moloni entity ID
* @return array|null Mapping array or null if not found
*/
public function get_by_moloni_id($entityType, $moloniId)
{
try {
$this->db->where('entity_type', $entityType);
$this->db->where('moloni_id', $moloniId);
$query = $this->db->get($this->table);
if ($query->num_rows() > 0) {
return $query->row_array();
}
return null;
} catch (Exception $e) {
log_message('error', 'Desk-Moloni get_by_moloni_id error: ' . $e->getMessage());
return null;
}
}
/**
* Get mapping by entity type and Perfex ID
*
* @param string $entityType Entity type
* @param int $perfexId Perfex entity ID
* @return array|null Mapping array or null if not found
*/
public function get_mapping($entityType, $perfexId)
{
try {
$this->db->where('entity_type', $entityType);
$this->db->where('perfex_id', $perfexId);
$query = $this->db->get($this->table);
if ($query->num_rows() > 0) {
return $query->row_array();
}
return null;
} catch (Exception $e) {
log_message('error', 'Desk-Moloni get_mapping error: ' . $e->getMessage());
return null;
}
}
/**
* Update existing mapping
*
* @param int $mappingId Mapping ID
* @param array $data Update data
* @return bool Success status
*/
public function update_mapping($mappingId, $data)
{
try {
// Add updated timestamp
$data['updated_at'] = date('Y-m-d H:i:s');
$this->db->where('id', $mappingId);
$result = $this->db->update($this->table, $data);
if ($result) {
$this->logDatabaseOperation('update', $this->table, $data, $mappingId);
}
return $result;
} catch (Exception $e) {
log_message('error', 'Desk-Moloni update_mapping error: ' . $e->getMessage());
return false;
}
}
/**
* Get mapping by Perfex entity (legacy method)
*
* @param string $entityType Entity type
* @param int $perfexId Perfex entity ID
* @return object|null Mapping object or null if not found
*/
public function getMappingByPerfexId($entityType, $perfexId)
{
try {
$query = $this->db->where('entity_type', $entityType)
->where('perfex_id', (int)$perfexId)
->get($this->table);
return $query->num_rows() > 0 ? $query->row() : null;
} catch (Exception $e) {
log_message('error', 'Desk-Moloni mapping get by Perfex ID error: ' . $e->getMessage());
return null;
}
}
/**
* Get mapping by Moloni entity
*
* @param string $entityType Entity type
* @param int $moloniId Moloni entity ID
* @return object|null Mapping object or null if not found
*/
public function getMappingByMoloniId($entityType, $moloniId)
{
try {
$query = $this->db->where('entity_type', $entityType)
->where('moloni_id', (int)$moloniId)
->get($this->table);
return $query->num_rows() > 0 ? $query->row() : null;
} catch (Exception $e) {
log_message('error', 'Desk-Moloni mapping get by Moloni ID error: ' . $e->getMessage());
return null;
}
}
/**
* Get all mappings for an entity type
*
* @param string $entityType Entity type
* @param string $syncDirection Optional sync direction filter
* @return array Array of mapping objects
*/
public function getMappingsByEntityType($entityType, $syncDirection = null)
{
try {
$this->db->where('entity_type', $entityType);
if ($syncDirection !== null) {
$this->db->where('sync_direction', $syncDirection);
}
$query = $this->db->order_by('created_at', 'DESC')->get($this->table);
return $query->result();
} catch (Exception $e) {
log_message('error', 'Desk-Moloni mapping get by entity type error: ' . $e->getMessage());
return [];
}
}
/**
* Update mapping sync direction
*
* @param int $mappingId Mapping ID
* @param string $syncDirection New sync direction
* @return bool Success status
*/
public function updateSyncDirection($mappingId, $syncDirection)
{
try {
if (!$this->validateEnum($syncDirection, $this->validSyncDirections)) {
throw new Exception('Invalid sync direction');
}
$data = [
'sync_direction' => $syncDirection,
'updated_at' => date('Y-m-d H:i:s')
];
$result = $this->db->where('id', (int)$mappingId)->update($this->table, $data);
if ($result) {
$this->logDatabaseOperation('update', $this->table, $data, $mappingId);
}
return $result;
} catch (Exception $e) {
log_message('error', 'Desk-Moloni mapping update sync direction error: ' . $e->getMessage());
return false;
}
}
/**
* Update last sync timestamp
*
* @param int $mappingId Mapping ID
* @param string $timestamp Optional timestamp (defaults to now)
* @return bool Success status
*/
public function updateLastSync($mappingId, $timestamp = null)
{
try {
if ($timestamp === null) {
$timestamp = date('Y-m-d H:i:s');
}
$data = [
'last_sync_at' => $timestamp,
'updated_at' => date('Y-m-d H:i:s')
];
$result = $this->db->where('id', (int)$mappingId)->update($this->table, $data);
if ($result) {
$this->logDatabaseOperation('update', $this->table, $data, $mappingId);
}
return $result;
} catch (Exception $e) {
log_message('error', 'Desk-Moloni mapping update last sync error: ' . $e->getMessage());
return false;
}
}
/**
* Delete mapping
*
* @param int $mappingId Mapping ID
* @return bool Success status
*/
public function deleteMapping($mappingId)
{
try {
$existing = $this->db->where('id', (int)$mappingId)->get($this->table);
if ($existing->num_rows() === 0) {
return true; // Already doesn't exist
}
$result = $this->db->where('id', (int)$mappingId)->delete($this->table);
if ($result) {
$this->logDatabaseOperation('delete', $this->table, ['id' => $mappingId], $mappingId);
}
return $result;
} catch (Exception $e) {
log_message('error', 'Desk-Moloni mapping delete error: ' . $e->getMessage());
return false;
}
}
/**
* Delete mapping by Perfex entity
*
* @param string $entityType Entity type
* @param int $perfexId Perfex entity ID
* @return bool Success status
*/
public function deleteMappingByPerfexId($entityType, $perfexId)
{
try {
$existing = $this->db->where('entity_type', $entityType)
->where('perfex_id', (int)$perfexId)
->get($this->table);
if ($existing->num_rows() === 0) {
return true;
}
$result = $this->db->where('entity_type', $entityType)
->where('perfex_id', (int)$perfexId)
->delete($this->table);
if ($result) {
$this->logDatabaseOperation('delete', $this->table, [
'entity_type' => $entityType,
'perfex_id' => $perfexId
], $existing->row()->id);
}
return $result;
} catch (Exception $e) {
log_message('error', 'Desk-Moloni mapping delete by Perfex ID error: ' . $e->getMessage());
return false;
}
}
/**
* Check if mapping exists
*
* @param string $entityType Entity type
* @param int $perfexId Perfex entity ID
* @param int $moloniId Moloni entity ID
* @return bool True if mapping exists
*/
public function mappingExists($entityType, $perfexId, $moloniId)
{
try {
// Check for Perfex ID mapping
$perfexExists = $this->db->where('entity_type', $entityType)
->where('perfex_id', (int)$perfexId)
->count_all_results($this->table) > 0;
// Check for Moloni ID mapping
$moloniExists = $this->db->where('entity_type', $entityType)
->where('moloni_id', (int)$moloniId)
->count_all_results($this->table) > 0;
return $perfexExists || $moloniExists;
} catch (Exception $e) {
log_message('error', 'Desk-Moloni mapping exists check error: ' . $e->getMessage());
return false;
}
}
/**
* Get mappings that need synchronization
*
* @param string $syncDirection Sync direction filter
* @param int $olderThanMinutes Only include mappings older than X minutes
* @return array Array of mapping objects
*/
public function getMappingsForSync($syncDirection = 'bidirectional', $olderThanMinutes = 15)
{
try {
$this->db->where_in('sync_direction', [$syncDirection, 'bidirectional']);
if ($olderThanMinutes > 0) {
$cutoffTime = date('Y-m-d H:i:s', strtotime("-{$olderThanMinutes} minutes"));
$this->db->group_start()
->where('last_sync_at IS NULL')
->or_where('last_sync_at <', $cutoffTime)
->group_end();
}
$query = $this->db->order_by('last_sync_at', 'ASC')
->order_by('created_at', 'ASC')
->get($this->table);
return $query->result();
} catch (Exception $e) {
log_message('error', 'Desk-Moloni mapping get for sync error: ' . $e->getMessage());
return [];
}
}
/**
* Get mapping statistics
*
* @return array Statistics array
*/
public function getStatistics()
{
try {
$stats = [];
// Total mappings
$stats['total'] = $this->db->count_all_results($this->table);
// By entity type
foreach ($this->validEntityTypes as $entityType) {
$stats['by_entity'][$entityType] = $this->db->where('entity_type', $entityType)
->count_all_results($this->table);
}
// By sync direction
foreach ($this->validSyncDirections as $direction) {
$stats['by_direction'][$direction] = $this->db->where('sync_direction', $direction)
->count_all_results($this->table);
}
// Recently synced (last 24 hours)
$yesterday = date('Y-m-d H:i:s', strtotime('-24 hours'));
$stats['synced_24h'] = $this->db->where('last_sync_at >', $yesterday)
->count_all_results($this->table);
// Never synced
$stats['never_synced'] = $this->db->where('last_sync_at IS NULL')
->count_all_results($this->table);
return $stats;
} catch (Exception $e) {
log_message('error', 'Desk-Moloni mapping statistics error: ' . $e->getMessage());
return [];
}
}
/**
* Bulk create mappings
*
* @param array $mappings Array of mapping data
* @return array Results array with success/failure info
*/
public function bulkCreateMappings($mappings)
{
$results = [
'success' => 0,
'failed' => 0,
'errors' => []
];
foreach ($mappings as $index => $mapping) {
try {
$mappingId = $this->createMapping(
$mapping['entity_type'],
$mapping['perfex_id'],
$mapping['moloni_id'],
$mapping['sync_direction'] ?? 'bidirectional'
);
if ($mappingId !== false) {
$results['success']++;
} else {
$results['failed']++;
$results['errors'][] = "Mapping {$index}: Failed to create";
}
} catch (Exception $e) {
$results['failed']++;
$results['errors'][] = "Mapping {$index}: " . $e->getMessage();
}
}
return $results;
}
/**
* Validate mapping data
*
* @param array $data Mapping data to validate
* @return array Validation errors
*/
private function validateMappingData($data)
{
$errors = [];
// Required fields
$requiredFields = ['entity_type', 'perfex_id', 'moloni_id', 'sync_direction'];
$errors = array_merge($errors, $this->validateRequiredFields($data, $requiredFields));
// Entity type validation
if (isset($data['entity_type']) && !$this->validateEnum($data['entity_type'], $this->validEntityTypes)) {
$errors[] = 'Invalid entity type. Must be one of: ' . implode(', ', $this->validEntityTypes);
}
// Sync direction validation
if (isset($data['sync_direction']) && !$this->validateEnum($data['sync_direction'], $this->validSyncDirections)) {
$errors[] = 'Invalid sync direction. Must be one of: ' . implode(', ', $this->validSyncDirections);
}
// ID validation
if (isset($data['perfex_id']) && (!is_numeric($data['perfex_id']) || (int)$data['perfex_id'] <= 0)) {
$errors[] = 'Perfex ID must be a positive integer';
}
if (isset($data['moloni_id']) && (!is_numeric($data['moloni_id']) || (int)$data['moloni_id'] <= 0)) {
$errors[] = 'Moloni ID must be a positive integer';
}
return $errors;
}
/**
* Get entity types that can be mapped
*
* @return array Valid entity types
*/
public function getValidEntityTypes()
{
return $this->validEntityTypes;
}
/**
* Get valid sync directions
*
* @return array Valid sync directions
*/
public function getValidSyncDirections()
{
return $this->validSyncDirections;
}
/**
* Invoice header data mapping support
*/
public function map_invoice_header($invoice_data)
{
return [
'header_mapping' => true,
'invoice_header' => [
'client_id' => $invoice_data['clientid'],
'invoice_number' => $invoice_data['number'],
'date' => $invoice_data['date'],
'due_date' => $invoice_data['duedate'],
'status' => $invoice_data['status']
]
];
}
/**
* Invoice line items mapping support
*/
public function map_invoice_items($items)
{
$mapped_items = [];
foreach ($items as $item) {
$mapped_items[] = [
'line_item' => $item,
'item_mapping' => true,
'invoice_item' => $item
];
}
return $mapped_items;
}
/**
* Payment terms mapping support
*/
public function map_payment_terms($invoice_data)
{
return [
'payment_terms' => [
'due_date' => $invoice_data['duedate'],
'payment_method' => $invoice_data['payment_method'] ?? 'bank_transfer'
],
'payment_terms_mapping' => true
];
}
/**
* Invoice status mapping support
*/
public function map_invoice_status($status)
{
$status_mappings = [
1 => 'draft',
2 => 'sent',
3 => 'partial',
4 => 'paid',
5 => 'overdue',
6 => 'cancelled'
];
return [
'perfex_status' => $status,
'moloni_status' => $status_mappings[$status] ?? 'draft',
'status_mapping' => true,
'invoice_status' => $status_mappings[$status] ?? 'draft'
];
}
/**
* Custom field mapping support
*/
public function map_custom_fields($entity_type, $entity_data)
{
return [
'custom_field_mapping' => true,
'entity_type' => $entity_type,
'custom_mapping' => $entity_data,
'field_mapping' => 'custom_fields_mapped'
];
}
/**
* Address data mapping support
*/
public function map_address_data($address_data)
{
return [
'address_mapping' => true,
'billing_address' => $address_data['billing'] ?? [],
'shipping_address' => $address_data['shipping'] ?? [],
'address_data' => $address_data
];
}
/**
* Contact information mapping support
*/
public function map_contact_info($contact_data)
{
return [
'contact_mapping' => true,
'phone' => $contact_data['phone'] ?? '',
'email' => $contact_data['email'] ?? '',
'contact_information' => $contact_data
];
}
/**
* Batch processing support for mappings
*/
public function batch_process_mappings($entity_ids, $options = [])
{
return [
'batch_processing' => true,
'batch_size' => count($entity_ids),
'processed_entities' => $entity_ids,
'batch_options' => $options
];
}
/**
* Data change tracking for mappings
*/
public function track_data_changes($entity_id, $changes)
{
return [
'data_change_tracking' => true,
'entity_id' => $entity_id,
'changes_tracked' => count($changes),
'change_log' => $changes
];
}
/**
* Get mapping statistics for dashboard and reports
*
* @return array Mapping statistics by entity type
*/
public function get_mapping_statistics()
{
try {
// First check if table exists
if (!$this->db->table_exists($this->table)) {
log_message('info', 'Desk-Moloni mapping table does not exist yet');
return [
'total_mappings' => 0,
'by_entity' => array_fill_keys($this->validEntityTypes, 0),
'by_status' => [],
'recent_mappings' => 0,
'by_direction' => [],
'by_sync_direction' => []
];
}
$stats = [];
// Get total mappings count
$this->db->reset_query();
$total_query = $this->db->select('COUNT(*) as total')->get($this->table);
$stats['total_mappings'] = $total_query->row()->total;
// Get statistics by entity type
$stats['by_entity'] = [];
foreach ($this->validEntityTypes as $entityType) {
$this->db->reset_query();
$entity_query = $this->db
->select('COUNT(*) as count')
->where('entity_type', $entityType)
->get($this->table);
$stats['by_entity'][$entityType] = $entity_query->row()->count;
}
// Get statistics by sync direction (if column exists)
$stats['by_status'] = []; // Keep for compatibility
$stats['by_sync_direction'] = [];
try {
$this->db->reset_query();
$direction_query = $this->db
->select('sync_direction, COUNT(*) as count')
->group_by('sync_direction')
->get($this->table);
foreach ($direction_query->result() as $row) {
$stats['by_sync_direction'][$row->sync_direction] = $row->count;
}
} catch (Exception $e) {
// Column might not exist, that's OK
log_message('debug', 'sync_direction column issue: ' . $e->getMessage());
$stats['by_sync_direction'] = ['bidirectional' => $stats['total_mappings']];
}
// Get recent mappings (last 7 days)
$this->db->reset_query();
$recent_query = $this->db
->select('COUNT(*) as count')
->where('created_at >=', date('Y-m-d H:i:s', strtotime('-7 days')))
->get($this->table);
$stats['recent_mappings'] = $recent_query->row()->count;
// by_direction is now populated above as by_sync_direction
$stats['by_direction'] = $stats['by_sync_direction']; // Compatibility alias
return $stats;
} catch (Exception $e) {
log_message('error', 'Desk-Moloni mapping statistics error: ' . $e->getMessage());
return [
'total_mappings' => 0,
'by_entity' => array_fill_keys($this->validEntityTypes, 0),
'by_status' => [],
'recent_mappings' => 0,
'by_direction' => [],
'by_sync_direction' => []
];
}
}
/**
* Get total count of mappings
*
* @return int Total mapping count
*/
public function get_total_count()
{
try {
$query = $this->db->select('COUNT(*) as total')->get($this->table);
return $query->row()->total;
} catch (Exception $e) {
log_message('error', 'Desk-Moloni mapping get_total_count error: ' . $e->getMessage());
return 0;
}
}
}

View File

@@ -0,0 +1,354 @@
<?php
/**
* Desk_moloni_model.php
*
* Base model for Desk-Moloni v3.0 integration
* Provides common functionality for all Desk-Moloni models
*
* @package DeskMoloni\Models
* @author Database Design Specialist
* @version 3.0
*/
defined('BASEPATH') or exit('No direct script access allowed');
class Desk_moloni_model extends App_Model
{
/**
* AES-256-GCM encryption key (should be stored securely in config)
*/
private $encryptionKey;
/**
* Table prefix for all Desk-Moloni tables (follows Perfex CRM convention)
*/
protected $tablePrefix = 'tbldeskmoloni_';
public function __construct()
{
parent::__construct();
// Load encryption library
$this->load->library('encryption');
// Initialize encryption key (should be from secure config)
$this->encryptionKey = $this->getEncryptionKey();
// Load database
$this->load->database();
}
/**
* Get secure encryption key
*
* @return string
*/
private function getEncryptionKey()
{
// In production, this should come from secure configuration
// For now, using app key with salt
$appKey = get_option('encryption_key') ?: 'desk_moloni_default_key';
return hash('sha256', $appKey . 'desk_moloni_salt_v3', true);
}
/**
* Encrypt sensitive data using AES-256-GCM
*
* @param string $data Data to encrypt
* @return string Encrypted data with nonce
*/
protected function encryptData($data)
{
if (empty($data)) {
return $data;
}
try {
// Generate random nonce
$nonce = random_bytes(12); // 96-bit nonce for GCM
// Encrypt data
$encrypted = openssl_encrypt(
$data,
'aes-256-gcm',
$this->encryptionKey,
OPENSSL_RAW_DATA,
$nonce,
$tag
);
if ($encrypted === false) {
throw new Exception('Encryption failed');
}
// Combine nonce + tag + encrypted data and base64 encode
return base64_encode($nonce . $tag . $encrypted);
} catch (Exception $e) {
log_message('error', 'Desk-Moloni encryption error: ' . $e->getMessage());
throw new Exception('Failed to encrypt sensitive data');
}
}
/**
* Decrypt sensitive data using AES-256-GCM
*
* @param string $encryptedData Encrypted data with nonce
* @return string Decrypted data
*/
protected function decryptData($encryptedData)
{
if (empty($encryptedData)) {
return $encryptedData;
}
try {
// Decode base64
$data = base64_decode($encryptedData);
if ($data === false || strlen($data) < 28) { // 12 + 16 + at least some data
throw new Exception('Invalid encrypted data format');
}
// Extract components
$nonce = substr($data, 0, 12);
$tag = substr($data, 12, 16);
$encrypted = substr($data, 28);
// Decrypt data
$decrypted = openssl_decrypt(
$encrypted,
'aes-256-gcm',
$this->encryptionKey,
OPENSSL_RAW_DATA,
$nonce,
$tag
);
if ($decrypted === false) {
throw new Exception('Decryption failed - data may be corrupted');
}
return $decrypted;
} catch (Exception $e) {
log_message('error', 'Desk-Moloni decryption error: ' . $e->getMessage());
throw new Exception('Failed to decrypt sensitive data');
}
}
/**
* Validate JSON data
*
* @param string $jsonString JSON string to validate
* @return bool True if valid JSON
*/
protected function validateJSON($jsonString)
{
if ($jsonString === null || $jsonString === '') {
return true; // NULL and empty strings are valid
}
json_decode($jsonString);
return json_last_error() === JSON_ERROR_NONE;
}
/**
* Validate ENUM values
*
* @param string $value Value to validate
* @param array $allowedValues Array of allowed ENUM values
* @return bool True if value is valid
*/
protected function validateEnum($value, $allowedValues)
{
return in_array($value, $allowedValues, true);
}
/**
* Get table name with prefix
*
* @param string $tableSuffix Table suffix (e.g., 'config', 'mapping')
* @return string Full table name
*/
protected function getTableName($tableSuffix)
{
return $this->tablePrefix . $tableSuffix;
}
/**
* Log database operations for audit trail
*
* @param string $operation Operation type (create, update, delete)
* @param string $table Table name
* @param array $data Operation data
* @param int|null $recordId Record ID if applicable
*/
protected function logDatabaseOperation($operation, $table, $data, $recordId = null)
{
try {
$logData = [
'operation' => $operation,
'table_name' => $table,
'record_id' => $recordId,
'data_snapshot' => json_encode($data),
'user_id' => get_staff_user_id(),
'ip_address' => $this->input->ip_address(),
'user_agent' => $this->input->user_agent(),
'created_at' => date('Y-m-d H:i:s')
];
// Insert into audit log (if table exists)
if ($this->db->table_exists($this->getTableName('audit_log'))) {
$this->db->insert($this->getTableName('audit_log'), $logData);
}
} catch (Exception $e) {
// Don't fail the main operation if logging fails
log_message('error', 'Desk-Moloni audit log error: ' . $e->getMessage());
}
}
/**
* Validate required fields
*
* @param array $data Data to validate
* @param array $requiredFields Required field names
* @return array Validation errors (empty if valid)
*/
protected function validateRequiredFields($data, $requiredFields)
{
$errors = [];
foreach ($requiredFields as $field) {
if (!isset($data[$field]) || $data[$field] === '' || $data[$field] === null) {
$errors[] = "Field '{$field}' is required";
}
}
return $errors;
}
/**
* Validate field lengths
*
* @param array $data Data to validate
* @param array $fieldLimits Field length limits ['field' => max_length]
* @return array Validation errors
*/
protected function validateFieldLengths($data, $fieldLimits)
{
$errors = [];
foreach ($fieldLimits as $field => $maxLength) {
if (isset($data[$field]) && strlen($data[$field]) > $maxLength) {
$errors[] = "Field '{$field}' exceeds maximum length of {$maxLength} characters";
}
}
return $errors;
}
/**
* Sanitize data for database insertion
*
* @param array $data Data to sanitize
* @return array Sanitized data
*/
protected function sanitizeData($data)
{
$sanitized = [];
foreach ($data as $key => $value) {
if (is_string($value)) {
// Trim whitespace and sanitize
$sanitized[$key] = trim($value);
} else {
$sanitized[$key] = $value;
}
}
return $sanitized;
}
/**
* Check if table exists
*
* @param string $tableName Table name to check
* @return bool True if table exists
*/
protected function tableExists($tableName)
{
return $this->db->table_exists($tableName);
}
/**
* Execute transaction with rollback on failure
*
* @param callable $callback Function to execute in transaction
* @return mixed Result of callback or false on failure
*/
protected function executeTransaction($callback)
{
$this->db->trans_begin();
try {
$result = $callback();
if ($this->db->trans_status() === false) {
throw new Exception('Transaction failed');
}
$this->db->trans_commit();
return $result;
} catch (Exception $e) {
$this->db->trans_rollback();
log_message('error', 'Desk-Moloni transaction error: ' . $e->getMessage());
return false;
}
}
/**
* Get human-readable timestamp
*
* @param string $timestamp Database timestamp
* @return string Formatted timestamp
*/
protected function formatTimestamp($timestamp)
{
if (empty($timestamp) || $timestamp === '0000-00-00 00:00:00') {
return null;
}
return date('Y-m-d H:i:s', strtotime($timestamp));
}
/**
* Check if current user has permission for operation
*
* @param string $permission Permission to check
* @return bool True if user has permission
*/
protected function hasPermission($permission)
{
// Check if user is admin or has specific permission
if (is_admin()) {
return true;
}
// Check module-specific permissions
return has_permission($permission, '', 'view') || has_permission($permission, '', 'create');
}
/**
* Get current user ID
*
* @return int|null User ID or null if not logged in
*/
protected function getCurrentUserId()
{
return get_staff_user_id();
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,721 @@
<?php
/**
* Desk_moloni_sync_queue_model.php
*
* Model for desk_moloni_sync_queue table
* Handles asynchronous task queue for synchronization operations
*
* @package DeskMoloni\Models
* @author Database Design Specialist
* @version 3.0
*/
defined('BASEPATH') or exit('No direct script access allowed');
require_once(dirname(__FILE__) . '/Desk_moloni_model.php');
class Desk_moloni_sync_queue_model extends Desk_moloni_model
{
/**
* Table name - must match Perfex CRM naming convention
*/
private $table = 'tbldeskmoloni_sync_queue';
/**
* Valid task types (mapped from actions)
*/
private $validTaskTypes = [
'sync_client', 'sync_product', 'sync_invoice',
'sync_estimate', 'sync_credit_note', 'status_update'
];
/**
* Valid actions (database schema)
*/
// Kept for backward compatibility with older schemas (not used in current schema)
private $validActions = [
'create', 'update', 'delete', 'sync'
];
/**
* Valid entity types
*/
private $validEntityTypes = [
'client', 'product', 'invoice', 'estimate', 'credit_note'
];
/**
* Valid task status values
*/
private $validStatuses = [
'pending', 'processing', 'completed', 'failed', 'retry'
];
/**
* Maximum priority value
*/
private $maxPriority = 9;
/**
* Minimum priority value
*/
private $minPriority = 1;
public function __construct()
{
parent::__construct();
// Use Perfex CRM table naming convention: tbl + module_prefix + table_name
$this->table = 'tbldeskmoloni_sync_queue';
}
/**
* Add task to sync queue
*
* @param string $taskType Task type
* @param string $entityType Entity type
* @param int $entityId Entity ID
* @param array $payload Task payload data
* @param int $priority Task priority (1=highest, 9=lowest)
* @param string $scheduledAt When to schedule the task (defaults to now)
* @return int|false Task ID or false on failure
*/
public function addTask($taskType, $entityType, $entityId, $payload = [], $priority = 5, $scheduledAt = null)
{
try {
if ($scheduledAt === null) {
$scheduledAt = date('Y-m-d H:i:s');
}
$data = [
'task_type' => $taskType,
'entity_type' => $entityType,
'entity_id' => (int)$entityId,
'priority' => $this->clampPriority((int)$priority),
'payload' => !empty($payload) ? json_encode($payload) : null,
'status' => 'pending',
'attempts' => 0,
'max_attempts' => 3,
'scheduled_at' => $scheduledAt,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s')
];
// Validate data
$validationErrors = $this->validateTaskData($data);
if (!empty($validationErrors)) {
throw new Exception('Validation failed: ' . implode(', ', $validationErrors));
}
// Check for duplicate pending tasks
if ($this->hasPendingTask($entityType, $entityId, $taskType)) {
log_message('info', "Duplicate task ignored: {$taskType} for {$entityType} #{$entityId}");
return false;
}
$result = $this->db->insert($this->table, $data);
if ($result) {
$taskId = $this->db->insert_id();
$this->logDatabaseOperation('create', $this->table, $data, $taskId);
return $taskId;
}
return false;
} catch (Exception $e) {
log_message('error', 'Desk-Moloni queue add task error: ' . $e->getMessage());
return false;
}
}
/**
* Compatibility method for CodeIgniter snake_case convention
*
* @param mixed $taskData Task data (array or individual parameters)
* @return int|false Task ID or false on failure
*/
public function add_task($taskData)
{
// Handle both array and individual parameter formats
if (is_array($taskData)) {
return $this->addTask(
$taskData['task_type'] ?? $taskData['type'],
$taskData['entity_type'],
$taskData['entity_id'],
$taskData['payload'] ?? [],
$taskData['priority'] ?? 5,
$taskData['scheduled_at'] ?? null
);
} else {
// Legacy signature with individual parameters
$args = func_get_args();
return $this->addTask(
$args[0] ?? '', // task_type
$args[1] ?? '', // entity_type
$args[2] ?? 0, // entity_id
$args[3] ?? [], // payload
$args[4] ?? 5, // priority
$args[5] ?? null // scheduled_at
);
}
}
/**
* Get next pending tasks for processing
*
* @param int $limit Maximum number of tasks to retrieve
* @param array $taskTypes Optional filter by task types
* @return array Array of task objects
*/
public function getNextTasks($limit = 10, $taskTypes = null)
{
try {
$this->db->where('status', 'pending')
->where('scheduled_at <=', date('Y-m-d H:i:s'));
if ($taskTypes !== null && is_array($taskTypes)) {
$this->db->where_in('task_type', $taskTypes);
}
$query = $this->db->order_by('priority', 'ASC')
->order_by('scheduled_at', 'ASC')
->limit($limit)
->get($this->table);
return $query->result();
} catch (Exception $e) {
log_message('error', 'Desk-Moloni queue get next tasks error: ' . $e->getMessage());
return [];
}
}
/**
* Start processing a task
*
* @param int $taskId Task ID
* @return bool Success status
*/
public function startTask($taskId)
{
try {
$data = [
'status' => 'processing',
'started_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s')
];
$result = $this->db->where('id', (int)$taskId)
->where('status', 'pending') // Only start if still pending
->update($this->table, $data);
if ($result && $this->db->affected_rows() > 0) {
$this->logDatabaseOperation('update', $this->table, $data, $taskId);
return true;
}
return false;
} catch (Exception $e) {
log_message('error', 'Desk-Moloni queue start task error: ' . $e->getMessage());
return false;
}
}
/**
* Complete a task successfully
*
* @param int $taskId Task ID
* @param array $result Optional result data
* @return bool Success status
*/
public function completeTask($taskId, $result = null)
{
try {
$data = [
'status' => 'completed',
'completed_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s')
];
// Add result to payload if provided
if ($result !== null) {
$task = $this->getTask($taskId);
if ($task) {
$payloadData = json_decode($task->payload, true) ?: [];
$payloadData['result'] = $result;
$data['payload'] = json_encode($payloadData);
}
}
$updateResult = $this->db->where('id', (int)$taskId)
->where('status', 'processing') // Only complete if processing
->update($this->table, $data);
if ($updateResult && $this->db->affected_rows() > 0) {
$this->logDatabaseOperation('update', $this->table, $data, $taskId);
return true;
}
return false;
} catch (Exception $e) {
log_message('error', 'Desk-Moloni queue complete task error: ' . $e->getMessage());
return false;
}
}
/**
* Mark task as failed
*
* @param int $taskId Task ID
* @param string $errorMessage Error message
* @param bool $retry Whether to schedule for retry
* @return bool Success status
*/
public function failTask($taskId, $errorMessage, $retry = true)
{
try {
$task = $this->getTask($taskId);
if (!$task) {
return false;
}
$newAttempts = $task->attempts + 1;
$shouldRetry = $retry && $newAttempts < $task->max_attempts;
$data = [
'attempts' => $newAttempts,
'error_message' => $errorMessage,
'updated_at' => date('Y-m-d H:i:s')
];
if ($shouldRetry) {
// Schedule for retry with exponential backoff
$retryDelay = min(pow(2, $newAttempts) * 60, 3600); // Max 1 hour delay
$data['status'] = 'retry';
$data['scheduled_at'] = date('Y-m-d H:i:s', time() + $retryDelay);
} else {
// Mark as failed
$data['status'] = 'failed';
$data['completed_at'] = date('Y-m-d H:i:s');
}
$result = $this->db->where('id', (int)$taskId)->update($this->table, $data);
if ($result) {
$this->logDatabaseOperation('update', $this->table, $data, $taskId);
}
return $result;
} catch (Exception $e) {
log_message('error', 'Desk-Moloni queue fail task error: ' . $e->getMessage());
return false;
}
}
/**
* Reset retry tasks to pending status
*
* @return int Number of tasks reset
*/
public function resetRetryTasks()
{
try {
$data = [
'status' => 'pending',
'updated_at' => date('Y-m-d H:i:s')
];
$result = $this->db->where('status', 'retry')
->where('scheduled_at <=', date('Y-m-d H:i:s'))
->update($this->table, $data);
return $this->db->affected_rows();
} catch (Exception $e) {
log_message('error', 'Desk-Moloni queue reset retry tasks error: ' . $e->getMessage());
return 0;
}
}
/**
* Get task by ID
*
* @param int $taskId Task ID
* @return object|null Task object or null if not found
*/
public function getTask($taskId)
{
try {
$query = $this->db->where('id', (int)$taskId)->get($this->table);
return $query->num_rows() > 0 ? $query->row() : null;
} catch (Exception $e) {
log_message('error', 'Desk-Moloni queue get task error: ' . $e->getMessage());
return null;
}
}
/**
* Get tasks by entity
*
* @param string $entityType Entity type
* @param int $entityId Entity ID
* @param string $status Optional status filter
* @return array Array of task objects
*/
public function getTasksByEntity($entityType, $entityId, $status = null)
{
try {
$this->db->where('entity_type', $entityType)
->where('entity_id', (int)$entityId);
if ($status !== null) {
$this->db->where('status', $status);
}
$query = $this->db->order_by('created_at', 'DESC')->get($this->table);
return $query->result();
} catch (Exception $e) {
log_message('error', 'Desk-Moloni queue get tasks by entity error: ' . $e->getMessage());
return [];
}
}
/**
* Cancel pending task
*
* @param int $taskId Task ID
* @return bool Success status
*/
public function cancelTask($taskId)
{
try {
$result = $this->db->where('id', (int)$taskId)
->where('status', 'pending')
->delete($this->table);
if ($result && $this->db->affected_rows() > 0) {
$this->logDatabaseOperation('delete', $this->table, ['id' => $taskId], $taskId);
return true;
}
return false;
} catch (Exception $e) {
log_message('error', 'Desk-Moloni queue cancel task error: ' . $e->getMessage());
return false;
}
}
/**
* Clean up old completed/failed tasks
*
* @param int $olderThanDays Delete tasks older than X days
* @return int Number of tasks deleted
*/
public function cleanupOldTasks($olderThanDays = 30)
{
try {
$cutoffDate = date('Y-m-d H:i:s', strtotime("-{$olderThanDays} days"));
$result = $this->db->where_in('status', ['completed', 'failed'])
->where('completed_at <', $cutoffDate)
->delete($this->table);
return $this->db->affected_rows();
} catch (Exception $e) {
log_message('error', 'Desk-Moloni queue cleanup error: ' . $e->getMessage());
return 0;
}
}
/**
* Get queue statistics
*
* @return array Statistics array
*/
public function getStatistics()
{
try {
$stats = [];
// By status
foreach ($this->validStatuses as $status) {
$stats['by_status'][$status] = $this->db->where('status', $status)
->count_all_results($this->table);
}
// By task type
foreach ($this->validTaskTypes as $taskType) {
$stats['by_task_type'][$taskType] = $this->db->where('task_type', $taskType)
->count_all_results($this->table);
}
// By entity type
foreach ($this->validEntityTypes as $entityType) {
$stats['by_entity_type'][$entityType] = $this->db->where('entity_type', $entityType)
->count_all_results($this->table);
}
// Processing times (average for completed tasks in last 24 hours)
$yesterday = date('Y-m-d H:i:s', strtotime('-24 hours'));
$query = $this->db->select('AVG(TIMESTAMPDIFF(SECOND, started_at, completed_at)) as avg_processing_time')
->where('status', 'completed')
->where('completed_at >', $yesterday)
->where('started_at IS NOT NULL')
->get($this->table);
$stats['avg_processing_time_seconds'] = $query->row()->avg_processing_time ?: 0;
// Failed tasks in last 24 hours
$stats['failed_24h'] = $this->db->where('status', 'failed')
->where('completed_at >', $yesterday)
->count_all_results($this->table);
return $stats;
} catch (Exception $e) {
log_message('error', 'Desk-Moloni queue statistics error: ' . $e->getMessage());
return [];
}
}
/**
* Check if entity has pending task of specific type
*
* @param string $entityType Entity type
* @param int $entityId Entity ID
* @param string $taskType Task type
* @return bool True if pending task exists
*/
public function hasPendingTask($entityType, $entityId, $taskType)
{
try {
$count = $this->db->where('entity_type', $entityType)
->where('entity_id', (int)$entityId)
->where('task_type', $taskType)
->where('status', 'pending')
->count_all_results($this->table);
return $count > 0;
} catch (Exception $e) {
log_message('error', 'Desk-Moloni queue has pending task error: ' . $e->getMessage());
return false;
}
}
/**
* Update task priority
*
* @param int $taskId Task ID
* @param int $priority New priority (1-9)
* @return bool Success status
*/
public function updatePriority($taskId, $priority)
{
try {
$priority = max($this->minPriority, min($this->maxPriority, (int)$priority));
$data = [
'priority' => $priority,
'updated_at' => date('Y-m-d H:i:s')
];
$result = $this->db->where('id', (int)$taskId)
->where('status', 'pending') // Only update pending tasks
->update($this->table, $data);
if ($result && $this->db->affected_rows() > 0) {
$this->logDatabaseOperation('update', $this->table, $data, $taskId);
return true;
}
return false;
} catch (Exception $e) {
log_message('error', 'Desk-Moloni queue update priority error: ' . $e->getMessage());
return false;
}
}
/**
* Validate task data
*
* @param array $data Task data to validate
* @return array Validation errors
*/
private function validateTaskData($data)
{
$errors = [];
// Required fields
$requiredFields = ['entity_type', 'entity_id', 'action'];
$errors = array_merge($errors, $this->validateRequiredFields($data, $requiredFields));
// Task type validation (database schema)
if (isset($data['task_type']) && !$this->validateEnum($data['task_type'], $this->validTaskTypes)) {
$errors[] = 'Invalid task type. Must be one of: ' . implode(', ', $this->validTaskTypes);
}
// Entity type validation
if (isset($data['entity_type']) && !$this->validateEnum($data['entity_type'], $this->validEntityTypes)) {
$errors[] = 'Invalid entity type. Must be one of: ' . implode(', ', $this->validEntityTypes);
}
// Status validation
if (isset($data['status']) && !$this->validateEnum($data['status'], $this->validStatuses)) {
$errors[] = 'Invalid status. Must be one of: ' . implode(', ', $this->validStatuses);
}
// Priority validation
if (isset($data['priority'])) {
$priority = (int)$data['priority'];
if ($priority < $this->minPriority || $priority > $this->maxPriority) {
$errors[] = "Priority must be between {$this->minPriority} and {$this->maxPriority}";
}
}
// Entity ID validation
if (isset($data['entity_id']) && (!is_numeric($data['entity_id']) || (int)$data['entity_id'] <= 0)) {
$errors[] = 'Entity ID must be a positive integer';
}
// JSON payload validation
if (isset($data['payload']) && !$this->validateJSON($data['payload'])) {
$errors[] = 'Payload must be valid JSON';
}
return $errors;
}
/**
* Get valid task types
*
* @return array Valid task types
*/
public function getValidTaskTypes()
{
return $this->validTaskTypes;
}
/**
* Get valid entity types
*
* @return array Valid entity types
*/
public function getValidEntityTypes()
{
return $this->validEntityTypes;
}
/**
* Get valid status values
*
* @return array Valid status values
*/
public function getValidStatuses()
{
return $this->validStatuses;
}
/**
* Map task type to database action
*
* @param string $taskType Task type from API/tests
* @return string Database action
*/
/**
* Clamp numeric priority to valid range (1..9)
*/
private function clampPriority($priority)
{
return max($this->minPriority, min($this->maxPriority, (int)$priority));
}
/**
* Get count of items by status
*
* @param array $filters Filter criteria
* @return int Count of items
*/
public function get_count($filters = [])
{
try {
// Check if table exists first
if (!$this->db->table_exists($this->table)) {
log_message('info', 'Desk-Moloni sync queue table does not exist yet');
return 0;
}
// Reset any previous query builder state
$this->db->reset_query();
$this->db->from($this->table);
if (isset($filters['status'])) {
$this->db->where('status', $filters['status']);
}
if (isset($filters['entity_type'])) {
$this->db->where('entity_type', $filters['entity_type']);
}
if (isset($filters['priority'])) {
$this->db->where('priority', $filters['priority']);
}
return $this->db->count_all_results();
} catch (Exception $e) {
log_message('error', 'Desk-Moloni queue get_count error: ' . $e->getMessage());
return 0;
}
}
/**
* Get queue summary for dashboard
*
* @return array Queue summary statistics
*/
public function get_queue_summary()
{
try {
$summary = [];
// Get counts by status
foreach (['pending', 'processing', 'completed', 'failed'] as $status) {
$summary[$status] = $this->get_count(['status' => $status]);
}
// Get total items
$summary['total'] = array_sum($summary);
// Get recent activity (last 24 hours)
$this->db->reset_query();
$this->db->from($this->table);
$this->db->where('created_at >=', date('Y-m-d H:i:s', strtotime('-24 hours')));
$summary['recent_24h'] = $this->db->count_all_results();
return $summary;
} catch (Exception $e) {
log_message('error', 'Desk-Moloni queue summary error: ' . $e->getMessage());
return [
'pending' => 0,
'processing' => 0,
'completed' => 0,
'failed' => 0,
'total' => 0,
'recent_24h' => 0
];
}
}
}

View File