Files
desk-moloni/modules/desk_moloni/controllers/ClientPortalController.php
Emanuel Almeida c19f6fd9ee 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.
2025-09-11 17:38:45 +01:00

1209 lines
40 KiB
PHP

<?php
defined('BASEPATH') or exit('No direct script access allowed');
require_once(__DIR__ . '/../libraries/DocumentAccessControl.php');
require_once(__DIR__ . '/../libraries/ClientNotificationService.php');
/**
* Desk-Moloni Client Portal Controller
* Handles client-facing document access and portal functionality
*
* @package Desk-Moloni
* @version 3.0.0
* @author Descomplicar Business Solutions
*/
class ClientPortalController extends ClientsController
{
private $documentAccessControl;
private $notificationService;
private $currentClient;
private $rateLimiter;
public function __construct()
{
parent::__construct();
// Load required models
$this->load->model('desk_moloni/desk_moloni_model');
$this->load->model('desk_moloni/desk_moloni_mapping_model');
$this->load->model('desk_moloni/desk_moloni_sync_log_model');
$this->load->model('clients_model');
$this->load->model('invoices_model');
$this->load->model('estimates_model');
// Load libraries
$this->load->library('form_validation');
$this->load->helper('security');
// Initialize document access control
$this->documentAccessControl = new DocumentAccessControl();
// Initialize notification service
$this->notificationService = new ClientNotificationService();
// Initialize rate limiter
$this->_initializeRateLimiter();
// Authenticate client and set up session
$this->_authenticateClient();
}
/**
* List Client Documents
* GET /clients/desk_moloni/documents
*/
public function documents()
{
// Rate limiting check
if (!$this->_checkRateLimit('documents_list', 60, 100)) {
$this->_respondWithError('Rate limit exceeded', 429);
return;
}
try {
// Get and validate query parameters
$filters = $this->_getDocumentFilters();
$pagination = $this->_getPaginationParams();
// Get client documents
$documents = $this->_getClientDocuments($filters, $pagination);
$totalCount = $this->_getClientDocumentsCount($filters);
// Build response
$response = [
'data' => $documents,
'pagination' => $this->_buildPaginationResponse($pagination, $totalCount),
'filters' => $this->_getAvailableFilters()
];
// Log access
$this->_logDocumentAccess('list', null, 'success');
$this->_respondWithSuccess($response);
} catch (Exception $e) {
log_message('error', 'Client portal documents error: ' . $e->getMessage());
$this->_logDocumentAccess('list', null, 'error', $e->getMessage());
$this->_respondWithError($e->getMessage(), 500);
}
}
/**
* Get Document Details
* GET /clients/desk_moloni/documents/{document_id}
*/
public function document_details($documentId)
{
// Rate limiting check
if (!$this->_checkRateLimit('document_details', 30, 50)) {
$this->_respondWithError('Rate limit exceeded', 429);
return;
}
try {
// Validate document ID
if (!is_numeric($documentId) || $documentId <= 0) {
$this->_respondWithError('Invalid document ID', 400);
return;
}
// Check document access permissions
if (!$this->documentAccessControl->canAccessDocument($this->currentClient['userid'], $documentId)) {
$this->_logDocumentAccess('view', $documentId, 'unauthorized');
$this->_respondWithError('Access denied', 403);
return;
}
// Get document details
$document = $this->_getDocumentDetails($documentId);
if (!$document) {
$this->_respondWithError('Document not found', 404);
return;
}
// Log successful access
$this->_logDocumentAccess('view', $documentId, 'success');
$this->_respondWithSuccess($document);
} catch (Exception $e) {
log_message('error', 'Client portal document details error: ' . $e->getMessage());
$this->_logDocumentAccess('view', $documentId, 'error', $e->getMessage());
$this->_respondWithError($e->getMessage(), 500);
}
}
/**
* Download Document PDF
* GET /clients/desk_moloni/documents/{document_id}/download
*/
public function download_document($documentId)
{
// Rate limiting check
if (!$this->_checkRateLimit('document_download', 10, 20)) {
$this->_respondWithError('Rate limit exceeded', 429);
return;
}
try {
// Validate document ID
if (!is_numeric($documentId) || $documentId <= 0) {
$this->_respondWithError('Invalid document ID', 400);
return;
}
// Check document access permissions
if (!$this->documentAccessControl->canAccessDocument($this->currentClient['userid'], $documentId)) {
$this->_logDocumentAccess('download', $documentId, 'unauthorized');
$this->_respondWithError('Access denied', 403);
return;
}
// Get document and PDF path
$document = $this->_getDocumentForDownload($documentId);
if (!$document || !$document['has_pdf']) {
$this->_respondWithError('Document or PDF not found', 404);
return;
}
// Serve PDF file
$this->_servePDFFile($document, 'attachment');
// Log successful download
$this->_logDocumentAccess('download', $documentId, 'success');
} catch (Exception $e) {
log_message('error', 'Client portal document download error: ' . $e->getMessage());
$this->_logDocumentAccess('download', $documentId, 'error', $e->getMessage());
$this->_respondWithError($e->getMessage(), 500);
}
}
/**
* View Document PDF (inline)
* GET /clients/desk_moloni/documents/{document_id}/view
*/
public function view_document($documentId)
{
// Rate limiting check
if (!$this->_checkRateLimit('document_view', 30, 100)) {
$this->_respondWithError('Rate limit exceeded', 429);
return;
}
try {
// Validate document ID
if (!is_numeric($documentId) || $documentId <= 0) {
$this->_respondWithError('Invalid document ID', 400);
return;
}
// Check document access permissions
if (!$this->documentAccessControl->canAccessDocument($this->currentClient['userid'], $documentId)) {
$this->_logDocumentAccess('view_pdf', $documentId, 'unauthorized');
$this->_respondWithError('Access denied', 403);
return;
}
// Get document and PDF path
$document = $this->_getDocumentForDownload($documentId);
if (!$document || !$document['has_pdf']) {
$this->_respondWithError('Document or PDF not found', 404);
return;
}
// Serve PDF file for inline viewing
$this->_servePDFFile($document, 'inline');
// Log successful view
$this->_logDocumentAccess('view_pdf', $documentId, 'success');
} catch (Exception $e) {
log_message('error', 'Client portal document view error: ' . $e->getMessage());
$this->_logDocumentAccess('view_pdf', $documentId, 'error', $e->getMessage());
$this->_respondWithError($e->getMessage(), 500);
}
}
/**
* Get Client Dashboard Data
* GET /clients/desk_moloni/dashboard
*/
public function dashboard()
{
// Rate limiting check
if (!$this->_checkRateLimit('dashboard', 60, 200)) {
$this->_respondWithError('Rate limit exceeded', 429);
return;
}
try {
$dashboard = [
'summary' => $this->_getDashboardSummary(),
'recent_documents' => $this->_getRecentDocuments(10),
'payment_status' => $this->_getPaymentStatusStats(),
'monthly_totals' => $this->_getMonthlyTotals(12)
];
// Log dashboard access
$this->_logDocumentAccess('dashboard', null, 'success');
$this->_respondWithSuccess($dashboard);
} catch (Exception $e) {
log_message('error', 'Client portal dashboard error: ' . $e->getMessage());
$this->_logDocumentAccess('dashboard', null, 'error', $e->getMessage());
$this->_respondWithError($e->getMessage(), 500);
}
}
/**
* Get Client Notifications
* GET /clients/desk_moloni/notifications
*/
public function notifications()
{
// Rate limiting check
if (!$this->_checkRateLimit('notifications', 60, 100)) {
$this->_respondWithError('Rate limit exceeded', 429);
return;
}
try {
$unreadOnly = $this->input->get('unread_only') === 'true';
$limit = (int) $this->input->get('limit') ?: 10;
$limit = min($limit, 50); // Max 50 notifications
$notifications = $this->_getClientNotifications($unreadOnly, $limit);
$unreadCount = $this->_getUnreadNotificationsCount();
$response = [
'notifications' => $notifications,
'unread_count' => $unreadCount
];
$this->_respondWithSuccess($response);
} catch (Exception $e) {
log_message('error', 'Client portal notifications error: ' . $e->getMessage());
$this->_respondWithError($e->getMessage(), 500);
}
}
/**
* Mark Notification as Read
* POST /clients/desk_moloni/notifications/{notification_id}/mark_read
*/
public function mark_notification_read($notificationId)
{
// Rate limiting check
if (!$this->_checkRateLimit('mark_notification', 30, 50)) {
$this->_respondWithError('Rate limit exceeded', 429);
return;
}
try {
// Validate notification ID
if (!is_numeric($notificationId) || $notificationId <= 0) {
$this->_respondWithError('Invalid notification ID', 400);
return;
}
// Check if notification belongs to current client
if (!$this->_canAccessNotification($notificationId)) {
$this->_respondWithError('Access denied', 403);
return;
}
// Mark as read
$success = $this->_markNotificationAsRead($notificationId);
if ($success) {
$this->_respondWithSuccess(['message' => 'Notification marked as read']);
} else {
$this->_respondWithError('Failed to mark notification as read', 500);
}
} catch (Exception $e) {
log_message('error', 'Client portal mark notification error: ' . $e->getMessage());
$this->_respondWithError($e->getMessage(), 500);
}
}
// Private Methods
/**
* Authenticate client and set up session
*/
private function _authenticateClient()
{
// Check if client is logged in through Perfex CRM client portal
if (!is_client_logged_in()) {
$this->_respondWithError('Client authentication required', 401);
return;
}
// Get current client
$clientId = get_client_user_id();
$this->currentClient = $this->clients_model->get($clientId);
if (!$this->currentClient) {
$this->_respondWithError('Invalid client session', 401);
return;
}
// Verify client is active
if ($this->currentClient['active'] != 1) {
$this->_respondWithError('Client account is not active', 403);
return;
}
}
/**
* Initialize rate limiter
*/
private function _initializeRateLimiter()
{
$this->load->driver('cache', ['adapter' => 'redis']);
if (!$this->cache->is_supported('redis')) {
// Fallback to file cache
$this->load->driver('cache', ['adapter' => 'file']);
}
}
/**
* Check rate limit
*/
private function _checkRateLimit($action, $windowSeconds, $maxRequests)
{
if (!$this->currentClient) {
return true; // Skip rate limiting if no client authenticated yet
}
$clientId = $this->currentClient['userid'];
$clientIp = $this->input->ip_address();
// Create rate limit key
$key = "rate_limit_{$action}_{$clientId}_{$clientIp}_" . floor(time() / $windowSeconds);
// Get current count
$currentCount = (int) $this->cache->get($key);
if ($currentCount >= $maxRequests) {
return false;
}
// Increment count
$this->cache->save($key, $currentCount + 1, $windowSeconds);
return true;
}
/**
* Get document filters from query parameters
*/
private function _getDocumentFilters()
{
$filters = [];
// Document type filter
$type = $this->input->get('type');
if ($type && in_array($type, ['invoice', 'estimate', 'credit_note', 'receipt'])) {
$filters['type'] = $type;
}
// Status filter
$status = $this->input->get('status');
if ($status && in_array($status, ['paid', 'unpaid', 'overdue', 'draft', 'pending'])) {
$filters['status'] = $status;
}
// Date range filters
$fromDate = $this->input->get('from_date');
if ($fromDate && $this->_isValidDate($fromDate)) {
$filters['from_date'] = $fromDate;
}
$toDate = $this->input->get('to_date');
if ($toDate && $this->_isValidDate($toDate)) {
$filters['to_date'] = $toDate;
}
// Search filter
$search = $this->input->get('search');
if ($search) {
$filters['search'] = $this->security->xss_clean(trim($search));
}
return $filters;
}
/**
* Get pagination parameters
*/
private function _getPaginationParams()
{
$page = max(1, (int) $this->input->get('page'));
$perPage = min(100, max(1, (int) $this->input->get('per_page') ?: 20));
return [
'page' => $page,
'per_page' => $perPage,
'offset' => ($page - 1) * $perPage
];
}
/**
* Get client documents
*/
private function _getClientDocuments($filters, $pagination)
{
$clientId = $this->currentClient['userid'];
$documents = [];
// Get invoices
if (!isset($filters['type']) || $filters['type'] === 'invoice') {
$invoices = $this->_getFilteredInvoices($clientId, $filters, $pagination);
$documents = array_merge($documents, $invoices);
}
// Get estimates
if (!isset($filters['type']) || $filters['type'] === 'estimate') {
$estimates = $this->_getFilteredEstimates($clientId, $filters, $pagination);
$documents = array_merge($documents, $estimates);
}
// Sort by date (newest first)
usort($documents, function($a, $b) {
return strtotime($b['date']) - strtotime($a['date']);
});
// Apply pagination to combined results
$offset = $pagination['offset'];
$perPage = $pagination['per_page'];
return array_slice($documents, $offset, $perPage);
}
/**
* Get filtered invoices
*/
private function _getFilteredInvoices($clientId, $filters, $pagination)
{
$invoices = $this->invoices_model->get('', [
'clientid' => $clientId
]);
$documents = [];
foreach ($invoices as $invoice) {
// Apply filters
if (isset($filters['status']) && $this->_getInvoiceStatus($invoice) !== $filters['status']) {
continue;
}
if (isset($filters['from_date']) && $invoice['date'] < $filters['from_date']) {
continue;
}
if (isset($filters['to_date']) && $invoice['date'] > $filters['to_date']) {
continue;
}
if (isset($filters['search']) && !$this->_documentMatchesSearch($invoice, $filters['search'])) {
continue;
}
$documents[] = $this->_formatDocumentSummary($invoice, 'invoice');
}
return $documents;
}
/**
* Get filtered estimates
*/
private function _getFilteredEstimates($clientId, $filters, $pagination)
{
$estimates = $this->estimates_model->get('', [
'clientid' => $clientId
]);
$documents = [];
foreach ($estimates as $estimate) {
// Apply filters
if (isset($filters['status']) && $this->_getEstimateStatus($estimate) !== $filters['status']) {
continue;
}
if (isset($filters['from_date']) && $estimate['date'] < $filters['from_date']) {
continue;
}
if (isset($filters['to_date']) && $estimate['date'] > $filters['to_date']) {
continue;
}
if (isset($filters['search']) && !$this->_documentMatchesSearch($estimate, $filters['search'])) {
continue;
}
$documents[] = $this->_formatDocumentSummary($estimate, 'estimate');
}
return $documents;
}
/**
* Get client documents count
*/
private function _getClientDocumentsCount($filters)
{
$clientId = $this->currentClient['userid'];
$count = 0;
// Count invoices
if (!isset($filters['type']) || $filters['type'] === 'invoice') {
$invoices = $this->_getFilteredInvoices($clientId, $filters, ['offset' => 0, 'per_page' => 999999]);
$count += count($invoices);
}
// Count estimates
if (!isset($filters['type']) || $filters['type'] === 'estimate') {
$estimates = $this->_getFilteredEstimates($clientId, $filters, ['offset' => 0, 'per_page' => 999999]);
$count += count($estimates);
}
return $count;
}
/**
* Format document summary
*/
private function _formatDocumentSummary($document, $type)
{
$baseUrl = site_url("clients/desk_moloni/documents/{$document['id']}");
return [
'id' => (int) $document['id'],
'type' => $type,
'number' => $document['number'] ?? '',
'date' => $document['date'],
'due_date' => $document['duedate'] ?? null,
'amount' => (float) ($document['subtotal'] ?? 0),
'currency' => get_base_currency()->name,
'status' => $type === 'invoice' ? $this->_getInvoiceStatus($document) : $this->_getEstimateStatus($document),
'moloni_id' => $this->_getMoloniId($type, $document['id']),
'has_pdf' => $this->_documentHasPDF($type, $document['id']),
'pdf_url' => $baseUrl,
'view_url' => $baseUrl . '/view',
'download_url' => $baseUrl . '/download',
'created_at' => $document['datecreated']
];
}
/**
* Get document details
*/
private function _getDocumentDetails($documentId)
{
// Try to find in invoices first
$invoice = $this->invoices_model->get($documentId);
if ($invoice && $invoice['clientid'] == $this->currentClient['userid']) {
return $this->_formatDocumentDetails($invoice, 'invoice');
}
// Try estimates
$estimate = $this->estimates_model->get($documentId);
if ($estimate && $estimate['clientid'] == $this->currentClient['userid']) {
return $this->_formatDocumentDetails($estimate, 'estimate');
}
return null;
}
/**
* Format document details
*/
private function _formatDocumentDetails($document, $type)
{
$baseUrl = site_url("clients/desk_moloni/documents/{$document['id']}");
$details = [
'id' => (int) $document['id'],
'type' => $type,
'number' => $document['number'] ?? '',
'date' => $document['date'],
'due_date' => $document['duedate'] ?? null,
'amount' => (float) ($document['subtotal'] ?? 0),
'tax_amount' => (float) ($document['total_tax'] ?? 0),
'total_amount' => (float) ($document['total'] ?? 0),
'currency' => get_base_currency()->name,
'status' => $type === 'invoice' ? $this->_getInvoiceStatus($document) : $this->_getEstimateStatus($document),
'moloni_id' => $this->_getMoloniId($type, $document['id']),
'notes' => $document['adminnote'] ?? '',
'items' => $this->_getDocumentItems($type, $document['id']),
'payment_info' => $type === 'invoice' ? $this->_getPaymentInfo($document) : null,
'has_pdf' => $this->_documentHasPDF($type, $document['id']),
'pdf_url' => $baseUrl,
'view_url' => $baseUrl . '/view',
'download_url' => $baseUrl . '/download',
'created_at' => $document['datecreated'],
'updated_at' => $document['date']
];
return $details;
}
/**
* Get dashboard summary
*/
private function _getDashboardSummary()
{
$clientId = $this->currentClient['userid'];
// Get all client invoices
$invoices = $this->invoices_model->get('', ['clientid' => $clientId]);
$summary = [
'total_documents' => 0,
'pending_payments' => 0,
'overdue_documents' => 0,
'total_amount_due' => 0.0,
'total_paid_this_year' => 0.0
];
$currentYear = date('Y');
foreach ($invoices as $invoice) {
$summary['total_documents']++;
$status = $this->_getInvoiceStatus($invoice);
if ($status === 'unpaid') {
$summary['pending_payments']++;
$summary['total_amount_due'] += (float) $invoice['total'];
} elseif ($status === 'overdue') {
$summary['overdue_documents']++;
$summary['total_amount_due'] += (float) $invoice['total'];
} elseif ($status === 'paid' && date('Y', strtotime($invoice['date'])) === $currentYear) {
$summary['total_paid_this_year'] += (float) $invoice['total'];
}
}
// Add estimates
$estimates = $this->estimates_model->get('', ['clientid' => $clientId]);
$summary['total_documents'] += count($estimates);
return $summary;
}
/**
* Get recent documents
*/
private function _getRecentDocuments($limit = 10)
{
$documents = $this->_getClientDocuments([], ['offset' => 0, 'per_page' => $limit]);
return array_slice($documents, 0, $limit);
}
/**
* Get payment status stats
*/
private function _getPaymentStatusStats()
{
$clientId = $this->currentClient['userid'];
$invoices = $this->invoices_model->get('', ['clientid' => $clientId]);
$stats = [
'paid' => 0,
'unpaid' => 0,
'overdue' => 0
];
foreach ($invoices as $invoice) {
$status = $this->_getInvoiceStatus($invoice);
if (isset($stats[$status])) {
$stats[$status]++;
}
}
return $stats;
}
/**
* Get monthly totals
*/
private function _getMonthlyTotals($months = 12)
{
$clientId = $this->currentClient['userid'];
$invoices = $this->invoices_model->get('', ['clientid' => $clientId]);
$totals = [];
// Initialize last 12 months
for ($i = $months - 1; $i >= 0; $i--) {
$month = date('Y-m', strtotime("-{$i} months"));
$totals[] = [
'month' => $month,
'total' => 0.0
];
}
// Calculate totals
foreach ($invoices as $invoice) {
if ($this->_getInvoiceStatus($invoice) === 'paid') {
$invoiceMonth = date('Y-m', strtotime($invoice['date']));
for ($i = 0; $i < count($totals); $i++) {
if ($totals[$i]['month'] === $invoiceMonth) {
$totals[$i]['total'] += (float) $invoice['total'];
break;
}
}
}
}
return $totals;
}
/**
* Serve PDF file
*/
private function _servePDFFile($document, $disposition = 'attachment')
{
$pdfPath = $this->_getPDFPath($document['type'], $document['id']);
if (!file_exists($pdfPath)) {
// Generate PDF if it doesn't exist
$this->_generateDocumentPDF($document['type'], $document['id']);
}
if (!file_exists($pdfPath)) {
throw new Exception('PDF file not found or could not be generated');
}
// Security check - ensure file is within allowed directory
$realPath = realpath($pdfPath);
$allowedPath = realpath(FCPATH . 'uploads/desk_moloni/');
if (strpos($realPath, $allowedPath) !== 0) {
throw new Exception('Access denied to file location');
}
// Set headers for PDF download/view
$filename = $this->_generatePDFFilename($document);
header('Content-Type: application/pdf');
header("Content-Disposition: {$disposition}; filename=\"{$filename}\"");
header('Content-Length: ' . filesize($pdfPath));
header('Cache-Control: private, max-age=0, must-revalidate');
header('Pragma: public');
// Output file
readfile($pdfPath);
exit;
}
/**
* Log document access
*/
private function _logDocumentAccess($action, $documentId, $status, $errorMessage = null)
{
$logData = [
'client_id' => $this->currentClient['userid'],
'action' => $action,
'document_id' => $documentId,
'status' => $status,
'error_message' => $errorMessage,
'ip_address' => $this->input->ip_address(),
'user_agent' => $this->input->user_agent(),
'timestamp' => date('Y-m-d H:i:s')
];
// Use existing sync log model for audit trail
$this->desk_moloni_sync_log_model->logClientPortalAccess($logData);
}
/**
* Respond with success
*/
private function _respondWithSuccess($data)
{
$this->output
->set_content_type('application/json')
->set_status_header(200)
->set_output(json_encode([
'success' => true,
'data' => $data
]));
}
/**
* Respond with error
*/
private function _respondWithError($message, $statusCode = 400)
{
$this->output
->set_content_type('application/json')
->set_status_header($statusCode)
->set_output(json_encode([
'success' => false,
'message' => $message
]));
}
// Helper methods for document status, PDF generation, etc.
// These would be implemented based on Perfex CRM specific logic
private function _getInvoiceStatus($invoice)
{
// Implement Perfex CRM invoice status logic
if ($invoice['status'] == 2) return 'paid';
if ($invoice['status'] == 4) return 'overdue';
if ($invoice['status'] == 1) return 'unpaid';
return 'draft';
}
private function _getEstimateStatus($estimate)
{
// Implement Perfex CRM estimate status logic
if ($estimate['status'] == 4) return 'paid';
if ($estimate['status'] == 3) return 'draft';
return 'pending';
}
private function _getMoloniId($type, $perfexId)
{
$mapping = $this->desk_moloni_mapping_model->getMappingByPerfexId($type, $perfexId);
return $mapping ? (int) $mapping['moloni_id'] : null;
}
private function _documentHasPDF($type, $id)
{
$pdfPath = $this->_getPDFPath($type, $id);
return file_exists($pdfPath);
}
private function _getPDFPath($type, $id)
{
$uploadsPath = FCPATH . 'uploads/desk_moloni/pdfs/';
if (!is_dir($uploadsPath)) {
mkdir($uploadsPath, 0755, true);
}
return $uploadsPath . "{$type}_{$id}.pdf";
}
private function _generateDocumentPDF($type, $id)
{
// Implementation would depend on Perfex CRM PDF generation system
// This is a placeholder for the actual PDF generation logic
return true;
}
private function _generatePDFFilename($document)
{
$type = ucfirst($document['type']);
$number = preg_replace('/[^a-zA-Z0-9_-]/', '_', $document['number']);
return "{$type}_{$number}.pdf";
}
private function _isValidDate($date)
{
return DateTime::createFromFormat('Y-m-d', $date) !== false;
}
private function _documentMatchesSearch($document, $search)
{
$searchLower = strtolower($search);
$fields = ['number', 'clientnote', 'adminnote'];
foreach ($fields as $field) {
if (isset($document[$field]) && strpos(strtolower($document[$field]), $searchLower) !== false) {
return true;
}
}
return false;
}
private function _buildPaginationResponse($pagination, $totalCount)
{
$totalPages = ceil($totalCount / $pagination['per_page']);
return [
'current_page' => $pagination['page'],
'per_page' => $pagination['per_page'],
'total' => $totalCount,
'total_pages' => $totalPages,
'has_previous' => $pagination['page'] > 1,
'has_next' => $pagination['page'] < $totalPages
];
}
private function _getAvailableFilters()
{
return [
'available_types' => ['invoice', 'estimate'],
'available_statuses' => ['paid', 'unpaid', 'overdue', 'draft', 'pending'],
'date_range' => [
'min_date' => date('Y-01-01', strtotime('-2 years')),
'max_date' => date('Y-m-d')
]
];
}
private function _getDocumentForDownload($documentId)
{
$document = $this->_getDocumentDetails($documentId);
return $document;
}
private function _getDocumentItems($type, $id)
{
// Get line items for document
if ($type === 'invoice') {
$items = $this->db->get_where('tblinvoiceitems', ['invoiceid' => $id])->result_array();
} else {
$items = $this->db->get_where('tblestimate_items', ['estimateid' => $id])->result_array();
}
$formattedItems = [];
foreach ($items as $item) {
$formattedItems[] = [
'name' => $item['description'],
'description' => $item['long_description'] ?? '',
'quantity' => (float) $item['qty'],
'unit_price' => (float) $item['rate'],
'discount' => 0.0, // Perfex CRM handles discounts differently
'tax_rate' => (float) ($item['taxrate'] ?? 0),
'subtotal' => (float) $item['qty'] * (float) $item['rate']
];
}
return $formattedItems;
}
private function _getPaymentInfo($invoice)
{
// Get payment information for invoice
$payments = $this->db->get_where('tblinvoicepaymentrecords', ['invoiceid' => $invoice['id']])->result_array();
$totalPaid = 0;
$lastPayment = null;
foreach ($payments as $payment) {
$totalPaid += (float) $payment['amount'];
if (!$lastPayment || strtotime($payment['date']) > strtotime($lastPayment['date'])) {
$lastPayment = $payment;
}
}
return [
'payment_date' => $lastPayment ? $lastPayment['date'] : null,
'payment_method' => $lastPayment ? $lastPayment['paymentmethod'] : null,
'paid_amount' => $totalPaid,
'remaining_amount' => (float) $invoice['total'] - $totalPaid
];
}
private function _getClientNotifications($unreadOnly, $limit)
{
$clientId = $this->currentClient['userid'];
return $this->notificationService->getClientNotifications($clientId, $unreadOnly, $limit);
}
private function _getUnreadNotificationsCount()
{
$clientId = $this->currentClient['userid'];
return $this->notificationService->getUnreadCount($clientId);
}
private function _canAccessNotification($notificationId)
{
$clientId = $this->currentClient['userid'];
$notification = $this->notificationService->getNotificationById($notificationId, $clientId);
return $notification !== null;
}
private function _markNotificationAsRead($notificationId)
{
$clientId = $this->currentClient['userid'];
return $this->notificationService->markAsRead($notificationId, $clientId);
}
/**
* Health check endpoint
* GET /clients/desk_moloni/health
*/
public function health_check()
{
try {
$health = [
'status' => 'healthy',
'timestamp' => date('Y-m-d H:i:s'),
'version' => '3.0.0',
'checks' => [
'database' => $this->_checkDatabaseHealth(),
'auth' => $this->_checkAuthHealth(),
'permissions' => $this->_checkPermissionsHealth(),
'notifications' => $this->_checkNotificationsHealth()
]
];
// Overall status based on individual checks
$allHealthy = true;
foreach ($health['checks'] as $check) {
if ($check['status'] !== 'healthy') {
$allHealthy = false;
break;
}
}
$health['status'] = $allHealthy ? 'healthy' : 'degraded';
$statusCode = $allHealthy ? 200 : 503;
$this->output
->set_content_type('application/json')
->set_status_header($statusCode)
->set_output(json_encode($health));
} catch (Exception $e) {
$this->output
->set_content_type('application/json')
->set_status_header(503)
->set_output(json_encode([
'status' => 'unhealthy',
'error' => $e->getMessage(),
'timestamp' => date('Y-m-d H:i:s')
]));
}
}
/**
* Status endpoint for monitoring
* GET /clients/desk_moloni/status
*/
public function status()
{
try {
$status = [
'service' => 'Desk-Moloni Client Portal',
'version' => '3.0.0',
'status' => 'operational',
'uptime' => $this->_getUptime(),
'client_stats' => [
'active_sessions' => $this->_getActiveClientSessions(),
'documents_served_today' => $this->_getDocumentsServedToday(),
'api_requests_last_hour' => $this->_getApiRequestsLastHour()
],
'performance' => [
'avg_response_time_ms' => $this->_getAverageResponseTime(),
'cache_hit_rate' => $this->_getCacheHitRate()
]
];
$this->_respondWithSuccess($status);
} catch (Exception $e) {
$this->_respondWithError('Status check failed: ' . $e->getMessage(), 500);
}
}
// Private health check methods
private function _checkDatabaseHealth()
{
try {
$this->db->query('SELECT 1');
return ['status' => 'healthy', 'message' => 'Database connection OK'];
} catch (Exception $e) {
return ['status' => 'unhealthy', 'message' => 'Database connection failed'];
}
}
private function _checkAuthHealth()
{
try {
if (is_client_logged_in()) {
return ['status' => 'healthy', 'message' => 'Client authentication OK'];
} else {
return ['status' => 'healthy', 'message' => 'No client authenticated (normal for health check)'];
}
} catch (Exception $e) {
return ['status' => 'unhealthy', 'message' => 'Authentication system error'];
}
}
private function _checkPermissionsHealth()
{
try {
// Test document access control initialization
$accessControl = new DocumentAccessControl();
return ['status' => 'healthy', 'message' => 'Access control system OK'];
} catch (Exception $e) {
return ['status' => 'unhealthy', 'message' => 'Access control system error'];
}
}
private function _checkNotificationsHealth()
{
try {
// Test notification service initialization
$notificationService = new ClientNotificationService();
return ['status' => 'healthy', 'message' => 'Notification system OK'];
} catch (Exception $e) {
return ['status' => 'unhealthy', 'message' => 'Notification system error'];
}
}
private function _getUptime()
{
// This would track actual service uptime
// For now, return a placeholder
return '99.9%';
}
private function _getActiveClientSessions()
{
// Count active client sessions
return $this->db->where('last_activity >', date('Y-m-d H:i:s', strtotime('-30 minutes')))
->count_all_results('tblclients');
}
private function _getDocumentsServedToday()
{
// Count document access logs for today
$today = date('Y-m-d');
return count($this->desk_moloni_sync_log_model->getClientPortalAccessLogs(0, [
'start_date' => $today . ' 00:00:00',
'end_date' => $today . ' 23:59:59'
], 10000));
}
private function _getApiRequestsLastHour()
{
// Count API requests in the last hour
$lastHour = date('Y-m-d H:i:s', strtotime('-1 hour'));
return count($this->desk_moloni_sync_log_model->getClientPortalAccessLogs(0, [
'start_date' => $lastHour
], 10000));
}
private function _getAverageResponseTime()
{
// This would be tracked by application monitoring
// For now, return a placeholder
return 150; // ms
}
private function _getCacheHitRate()
{
// This would be tracked by cache monitoring
// For now, return a placeholder
return 85.5; // percentage
}
}