/** * Descomplicar® Crescimento Digital * https://descomplicar.pt */ 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(): void { // 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(int $documentId): void { // 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(int $documentId): void { // 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(int $documentId): void { // 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(): void { // 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(): void { // 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 } }