getDatabase(); $query = $db->getQuery(true) ->select([ $db->quoteName('t.id'), $db->quoteName('t.subject'), $db->quoteName('t.status_id'), $db->quoteName('t.priority_id'), $db->quoteName('t.created'), $db->quoteName('t.modified'), $db->quoteName('t.contact_id'), $db->quoteName('t.sla_response_due'), $db->quoteName('t.sla_resolution_due'), $db->quoteName('t.sla_responded'), $db->quoteName('c.title', 'category_title'), $db->quoteName('u.name', 'created_by_name'), $db->quoteName('ct.name', 'contact_name'), $db->quoteName('st.title', 'status_title'), $db->quoteName('st.alias', 'status_alias'), $db->quoteName('st.color', 'status_color'), $db->quoteName('pr.title', 'priority_title'), $db->quoteName('pr.alias', 'priority_alias'), $db->quoteName('pr.color', 'priority_color'), $db->quoteName('st.is_closed', 'status_is_closed'), ]) ->from($db->quoteName('#__mokosuite_tickets', 't')) ->leftJoin($db->quoteName('#__mokosuite_ticket_categories', 'c') . ' ON c.id = t.category_id') ->leftJoin($db->quoteName('#__users', 'u') . ' ON u.id = t.created_by') ->leftJoin($db->quoteName('#__contact_details', 'ct') . ' ON ct.id = t.contact_id') ->leftJoin($db->quoteName('#__mokosuite_ticket_statuses', 'st') . ' ON st.id = t.status_id') ->leftJoin($db->quoteName('#__mokosuite_ticket_priorities', 'pr') . ' ON pr.id = t.priority_id'); if (!empty($filters['status_id'])) { $query->where($db->quoteName('t.status_id') . ' = ' . (int) $filters['status_id']); } if (!empty($filters['priority_id'])) { $query->where($db->quoteName('t.priority_id') . ' = ' . (int) $filters['priority_id']); } if (!empty($filters['assigned_to'])) { $query->where($db->quoteName('t.assigned_to') . ' = ' . (int) $filters['assigned_to']); } if (!empty($filters['category_id'])) { $query->where($db->quoteName('t.category_id') . ' = ' . (int) $filters['category_id']); } if (!empty($filters['contact_id'])) { $query->where($db->quoteName('t.contact_id') . ' = ' . (int) $filters['contact_id']); } $query->order($db->quoteName('t.created') . ' DESC'); $query->setLimit(50); $db->setQuery($query); $tickets = $db->loadObjectList() ?: []; // Load assignees for each ticket foreach ($tickets as $ticket) { $ticket->assignees = $this->getTicketAssignees((int) $ticket->id); } return $tickets; } /** * Get a single ticket with all replies. */ public function getTicket(int $id): ?object { $db = $this->getDatabase(); $query = $db->getQuery(true) ->select([ $db->quoteName('t') . '.*', $db->quoteName('c.title', 'category_title'), $db->quoteName('u.name', 'created_by_name'), $db->quoteName('u.email', 'created_by_email'), $db->quoteName('ct.name', 'contact_name'), $db->quoteName('ct.email_to', 'contact_email'), $db->quoteName('ct.telephone', 'contact_phone'), $db->quoteName('st.title', 'status_title'), $db->quoteName('st.alias', 'status_alias'), $db->quoteName('st.color', 'status_color'), $db->quoteName('st.is_closed', 'status_is_closed'), $db->quoteName('pr.title', 'priority_title'), $db->quoteName('pr.alias', 'priority_alias'), $db->quoteName('pr.color', 'priority_color'), ]) ->from($db->quoteName('#__mokosuite_tickets', 't')) ->leftJoin($db->quoteName('#__mokosuite_ticket_categories', 'c') . ' ON c.id = t.category_id') ->leftJoin($db->quoteName('#__users', 'u') . ' ON u.id = t.created_by') ->leftJoin($db->quoteName('#__contact_details', 'ct') . ' ON ct.id = t.contact_id') ->leftJoin($db->quoteName('#__mokosuite_ticket_statuses', 'st') . ' ON st.id = t.status_id') ->leftJoin($db->quoteName('#__mokosuite_ticket_priorities', 'pr') . ' ON pr.id = t.priority_id') ->where($db->quoteName('t.id') . ' = ' . $id); $db->setQuery($query); $ticket = $db->loadObject(); if (!$ticket) { return null; } // Load replies $query = $db->getQuery(true) ->select([ $db->quoteName('r') . '.*', $db->quoteName('u.name', 'user_name'), ]) ->from($db->quoteName('#__mokosuite_ticket_replies', 'r')) ->leftJoin($db->quoteName('#__users', 'u') . ' ON u.id = r.user_id') ->where($db->quoteName('r.ticket_id') . ' = ' . $id) ->order($db->quoteName('r.created') . ' ASC'); $db->setQuery($query); $ticket->replies = $db->loadObjectList() ?: []; // Reply count $ticket->reply_count = \count($ticket->replies); // Load assignees (users + groups) $ticket->assignees = $this->getTicketAssignees($id); return $ticket; } /** * Create a new ticket. */ public function createTicket(array $data): array { try { $db = $this->getDatabase(); $user = Factory::getApplication()->getIdentity(); $now = Factory::getDate()->toSql(); // Resolve default status/priority from lookup tables $defaultStatus = $this->getDefaultStatus(); $defaultPriority = $this->getDefaultPriority(); $ticket = (object) [ 'subject' => $data['subject'] ?? '', 'body' => $data['body'] ?? '', 'status' => $defaultStatus->alias ?? 'open', 'status_id' => (int) ($data['status_id'] ?? $defaultStatus->id ?? 1), 'priority' => $defaultPriority->alias ?? 'normal', 'priority_id' => (int) ($data['priority_id'] ?? $defaultPriority->id ?? 2), 'category_id' => (int) ($data['category_id'] ?? 0) ?: null, 'contact_id' => (int) ($data['contact_id'] ?? 0) ?: null, 'created_by' => $user->id, 'assigned_to' => (int) ($data['assigned_to'] ?? 0) ?: null, 'created' => $now, 'modified' => $now, ]; // Auto-assign from category if (!$ticket->assigned_to && $ticket->category_id) { $query = $db->getQuery(true) ->select($db->quoteName('auto_assign_user')) ->from($db->quoteName('#__mokosuite_ticket_categories')) ->where($db->quoteName('id') . ' = ' . (int) $ticket->category_id); $db->setQuery($query); $autoAssign = (int) $db->loadResult(); if ($autoAssign) { $ticket->assigned_to = $autoAssign; } } // SLA deadlines from category if ($ticket->category_id) { $query = $db->getQuery(true) ->select([$db->quoteName('sla_response_minutes'), $db->quoteName('sla_resolution_minutes')]) ->from($db->quoteName('#__mokosuite_ticket_categories')) ->where($db->quoteName('id') . ' = ' . (int) $ticket->category_id); $db->setQuery($query); $sla = $db->loadObject(); if ($sla) { $ticket->sla_response_due = Factory::getDate($now)->modify('+' . (int) $sla->sla_response_minutes . ' minutes')->toSql(); $ticket->sla_resolution_due = Factory::getDate($now)->modify('+' . (int) $sla->sla_resolution_minutes . ' minutes')->toSql(); } } $db->insertObject('#__mokosuite_tickets', $ticket, 'id'); // Handle multi-assignee (users and groups) $assignUsers = array_filter(array_map('intval', (array) ($data['assign_users'] ?? []))); $assignGroups = array_filter(array_map('intval', (array) ($data['assign_groups'] ?? []))); // Backward compat: single assigned_to becomes a user assignee if (empty($assignUsers) && $ticket->assigned_to) { $assignUsers = [$ticket->assigned_to]; } if (!empty($assignUsers) || !empty($assignGroups)) { $this->setTicketAssignees((int) $ticket->id, $assignUsers, $assignGroups); } // Save custom field values $fieldValues = (array) ($data['custom_fields'] ?? []); if (!empty($fieldValues)) { $this->saveFieldValues((int) $ticket->id, $fieldValues); } // Run automation + notifications $this->runAutomation('ticket_created', (int) $ticket->id); NotificationService::notify('ticket_created', $this->getTicket((int) $ticket->id)); return ['success' => true, 'message' => 'Ticket #' . $ticket->id . ' created.', 'id' => (int) $ticket->id]; } catch (\Throwable $e) { return ['success' => false, 'message' => 'Failed: ' . $e->getMessage()]; } } /** * Add a reply to a ticket. */ public function addReply(int $ticketId, string $body, bool $isInternal = false): array { try { $db = $this->getDatabase(); $user = Factory::getApplication()->getIdentity(); $now = Factory::getDate()->toSql(); $reply = (object) [ 'ticket_id' => $ticketId, 'user_id' => $user->id, 'body' => $body, 'is_internal' => $isInternal ? 1 : 0, 'created' => $now, ]; $db->insertObject('#__mokosuite_ticket_replies', $reply, 'id'); // Mark SLA as responded only for staff replies (not customer self-replies) $ticket = $this->getTicket($ticketId); $isStaffReply = $ticket && (int) $user->id !== (int) $ticket->created_by; $updateQuery = $db->getQuery(true) ->update($db->quoteName('#__mokosuite_tickets')) ->set($db->quoteName('modified') . ' = ' . $db->quote($now)) ->where($db->quoteName('id') . ' = ' . $ticketId); if ($isStaffReply) { $updateQuery->set($db->quoteName('sla_responded') . ' = 1') ->where($db->quoteName('sla_responded') . ' = 0'); } $db->setQuery($updateQuery)->execute(); // Run automation + notifications (skip internal notes) $this->runAutomation('ticket_replied', $ticketId); if (!$isInternal) { NotificationService::notify('ticket_replied', $this->getTicket($ticketId), ['reply_body' => $body]); } return ['success' => true, 'message' => 'Reply added.']; } catch (\Throwable $e) { return ['success' => false, 'message' => 'Failed: ' . $e->getMessage()]; } } /** @var bool Guard against automation recursion */ private bool $automationRunning = false; /** * Update ticket status by status ID (lookup table). */ public function updateStatus(int $ticketId, int $statusId): array { try { $db = $this->getDatabase(); $now = Factory::getDate()->toSql(); // Validate status ID against lookup table $db->setQuery( $db->getQuery(true) ->select('*') ->from($db->quoteName('#__mokosuite_ticket_statuses')) ->where($db->quoteName('id') . ' = ' . $statusId) ); $status = $db->loadObject(); if (!$status) { return ['success' => false, 'message' => 'Invalid status.']; } // Capture old status for notification $db->setQuery( $db->getQuery(true) ->select($db->quoteName('status_id')) ->from($db->quoteName('#__mokosuite_tickets')) ->where($db->quoteName('id') . ' = ' . $ticketId) ); $oldStatusId = (int) $db->loadResult(); $sets = [ $db->quoteName('status') . ' = ' . $db->quote($status->alias), $db->quoteName('status_id') . ' = ' . $statusId, $db->quoteName('modified') . ' = ' . $db->quote($now), ]; if ($status->is_closed) { $sets[] = $db->quoteName('closed') . ' = ' . $db->quote($now); } // Set resolved timestamp for "resolved" alias (backward compat) if ($status->alias === 'resolved') { $sets[] = $db->quoteName('resolved') . ' = ' . $db->quote($now); } $db->setQuery( $db->getQuery(true) ->update($db->quoteName('#__mokosuite_tickets')) ->set($sets) ->where($db->quoteName('id') . ' = ' . $ticketId) )->execute(); // Run automation + notifications (with recursion guard) if (!$this->automationRunning) { $this->automationRunning = true; $this->runAutomation('status_changed', $ticketId); NotificationService::notify('status_changed', $this->getTicket($ticketId), ['old_status_id' => $oldStatusId]); $this->automationRunning = false; } return ['success' => true, 'message' => 'Status updated to ' . $status->title . '.']; } catch (\Throwable $e) { $this->automationRunning = false; return ['success' => false, 'message' => 'Failed: ' . $e->getMessage()]; } } /** * Get all ticket categories. */ public function getCategories(): array { $db = $this->getDatabase(); $db->setQuery( $db->getQuery(true) ->select('*') ->from($db->quoteName('#__mokosuite_ticket_categories')) ->where($db->quoteName('published') . ' = 1') ->order($db->quoteName('ordering') . ' ASC') ); return $db->loadObjectList() ?: []; } /** * Get assignees for a ticket (users and groups with resolved names). */ public function getTicketAssignees(int $ticketId): array { $db = $this->getDatabase(); $db->setQuery( $db->getQuery(true) ->select('*') ->from($db->quoteName('#__mokosuite_ticket_assignees')) ->where($db->quoteName('ticket_id') . ' = ' . $ticketId) ); $rows = $db->loadObjectList() ?: []; foreach ($rows as $row) { if ($row->assignee_type === 'user') { $db->setQuery( $db->getQuery(true) ->select($db->quoteName('name')) ->from($db->quoteName('#__users')) ->where($db->quoteName('id') . ' = ' . (int) $row->assignee_id) ); $row->name = (string) $db->loadResult() ?: 'User #' . $row->assignee_id; } else { $db->setQuery( $db->getQuery(true) ->select($db->quoteName('title')) ->from($db->quoteName('#__usergroups')) ->where($db->quoteName('id') . ' = ' . (int) $row->assignee_id) ); $row->name = (string) $db->loadResult() ?: 'Group #' . $row->assignee_id; } } return $rows; } /** * Set assignees for a ticket (replaces existing assignments). * * @param int $ticketId Ticket ID * @param array $users Array of user IDs * @param array $groups Array of user group IDs */ public function setTicketAssignees(int $ticketId, array $users = [], array $groups = []): void { $db = $this->getDatabase(); // Clear existing $db->setQuery( $db->getQuery(true) ->delete($db->quoteName('#__mokosuite_ticket_assignees')) ->where($db->quoteName('ticket_id') . ' = ' . $ticketId) )->execute(); // Insert users foreach ($users as $uid) { $uid = (int) $uid; if ($uid > 0) { $db->insertObject('#__mokosuite_ticket_assignees', (object) [ 'ticket_id' => $ticketId, 'assignee_type' => 'user', 'assignee_id' => $uid, ]); } } // Insert groups foreach ($groups as $gid) { $gid = (int) $gid; if ($gid > 0) { $db->insertObject('#__mokosuite_ticket_assignees', (object) [ 'ticket_id' => $ticketId, 'assignee_type' => 'group', 'assignee_id' => $gid, ]); } } } /** * Get all published Joomla contact records for ticket linking. */ public function getContacts(): array { $db = $this->getDatabase(); $db->setQuery( $db->getQuery(true) ->select([$db->quoteName('id'), $db->quoteName('name')]) ->from($db->quoteName('#__contact_details')) ->where($db->quoteName('published') . ' = 1') ->order($db->quoteName('name') . ' ASC') ); return $db->loadObjectList() ?: []; } /** * Get the default ticket status. */ public function getDefaultStatus(): ?object { $db = $this->getDatabase(); $db->setQuery( $db->getQuery(true) ->select('*') ->from($db->quoteName('#__mokosuite_ticket_statuses')) ->where($db->quoteName('is_default') . ' = 1') ->setLimit(1) ); return $db->loadObject() ?: null; } /** * Get the default ticket priority. */ public function getDefaultPriority(): ?object { $db = $this->getDatabase(); $db->setQuery( $db->getQuery(true) ->select('*') ->from($db->quoteName('#__mokosuite_ticket_priorities')) ->where($db->quoteName('is_default') . ' = 1') ->setLimit(1) ); return $db->loadObject() ?: null; } /** * Get all ticket statuses. */ public function getStatuses(): array { $db = $this->getDatabase(); $db->setQuery( $db->getQuery(true) ->select('*') ->from($db->quoteName('#__mokosuite_ticket_statuses')) ->order($db->quoteName('ordering') . ' ASC') ); return $db->loadObjectList() ?: []; } /** * Get all ticket priorities. */ public function getPriorities(): array { $db = $this->getDatabase(); $db->setQuery( $db->getQuery(true) ->select('*') ->from($db->quoteName('#__mokosuite_ticket_priorities')) ->order($db->quoteName('ordering') . ' ASC') ); return $db->loadObjectList() ?: []; } /** * Get Joomla custom field groups assigned to a ticket category. */ public function getFieldGroupsForCategory(int $categoryId): array { $db = $this->getDatabase(); $db->setQuery( $db->getQuery(true) ->select([$db->quoteName('fg.id'), $db->quoteName('fg.title')]) ->from($db->quoteName('#__mokosuite_ticket_category_field_groups', 'cfg')) ->innerJoin($db->quoteName('#__fields_groups', 'fg') . ' ON fg.id = cfg.field_group_id') ->where($db->quoteName('cfg.category_id') . ' = ' . $categoryId) ->where($db->quoteName('fg.state') . ' = 1') ->order($db->quoteName('fg.ordering') . ' ASC') ); return $db->loadObjectList() ?: []; } /** * Get Joomla custom fields for given field group IDs (context: com_mokosuite.ticket). */ public function getFieldsForGroups(array $groupIds): array { if (empty($groupIds)) { return []; } $db = $this->getDatabase(); $ids = implode(',', array_map('intval', $groupIds)); $db->setQuery( $db->getQuery(true) ->select('*') ->from($db->quoteName('#__fields')) ->where($db->quoteName('context') . ' = ' . $db->quote('com_mokosuite.ticket')) ->where($db->quoteName('group_id') . ' IN (' . $ids . ')') ->where($db->quoteName('state') . ' = 1') ->order($db->quoteName('ordering') . ' ASC') ); return $db->loadObjectList() ?: []; } /** * Get custom field values for a ticket. */ public function getFieldValues(int $ticketId): array { $db = $this->getDatabase(); $db->setQuery( $db->getQuery(true) ->select([$db->quoteName('field_id'), $db->quoteName('value')]) ->from($db->quoteName('#__fields_values')) ->where($db->quoteName('item_id') . ' = ' . $db->quote((string) $ticketId)) ); $rows = $db->loadObjectList() ?: []; $values = []; foreach ($rows as $row) { $values[(int) $row->field_id] = $row->value; } return $values; } /** * Save custom field values for a ticket. */ public function saveFieldValues(int $ticketId, array $fieldValues): void { $db = $this->getDatabase(); foreach ($fieldValues as $fieldId => $value) { $fieldId = (int) $fieldId; // Delete existing $db->setQuery( $db->getQuery(true) ->delete($db->quoteName('#__fields_values')) ->where($db->quoteName('field_id') . ' = ' . $fieldId) ->where($db->quoteName('item_id') . ' = ' . $db->quote((string) $ticketId)) )->execute(); // Insert new value (skip empty) if ($value !== '' && $value !== null) { $db->insertObject('#__fields_values', (object) [ 'field_id' => $fieldId, 'item_id' => (string) $ticketId, 'value' => $value, ]); } } } /** * Get canned responses, optionally filtered by category. */ public function getCannedResponses(int $categoryId = 0): array { $db = $this->getDatabase(); $query = $db->getQuery(true) ->select('*') ->from($db->quoteName('#__mokosuite_ticket_canned')) ->order($db->quoteName('ordering') . ' ASC'); if ($categoryId) { $query->where('(' . $db->quoteName('category_id') . ' = ' . $categoryId . ' OR ' . $db->quoteName('category_id') . ' IS NULL)'); } $db->setQuery($query); return $db->loadObjectList() ?: []; } /** * Get ticket counts by status for dashboard. */ public function getStatusCounts(): array { $db = $this->getDatabase(); $db->setQuery( $db->getQuery(true) ->select([ $db->quoteName('s.id'), $db->quoteName('s.title'), $db->quoteName('s.alias'), $db->quoteName('s.color'), $db->quoteName('s.is_closed'), 'COUNT(' . $db->quoteName('t.id') . ') AS ' . $db->quoteName('cnt'), ]) ->from($db->quoteName('#__mokosuite_ticket_statuses', 's')) ->leftJoin($db->quoteName('#__mokosuite_tickets', 't') . ' ON t.status_id = s.id') ->group($db->quoteName('s.id')) ->order($db->quoteName('s.ordering') . ' ASC') ); return $db->loadObjectList() ?: []; } /** * Get overdue tickets (SLA breached). */ public function getOverdueTickets(): array { $db = $this->getDatabase(); $now = Factory::getDate()->toSql(); $query = $db->getQuery(true) ->select(['t.' . $db->quoteName('id'), $db->quoteName('t.subject'), $db->quoteName('t.priority'), $db->quoteName('t.sla_response_due'), $db->quoteName('t.sla_resolution_due'), $db->quoteName('t.sla_responded')]) ->from($db->quoteName('#__mokosuite_tickets', 't')) ->leftJoin($db->quoteName('#__mokosuite_ticket_statuses', 's') . ' ON s.id = t.status_id') ->where('(' . $db->quoteName('s.is_closed') . ' = 0 OR ' . $db->quoteName('s.is_closed') . ' IS NULL)') ->where('((' . $db->quoteName('sla_response_due') . ' < ' . $db->quote($now) . ' AND ' . $db->quoteName('sla_responded') . ' = 0)' . ' OR ' . $db->quoteName('sla_resolution_due') . ' < ' . $db->quote($now) . ')') ->order($db->quoteName('sla_resolution_due') . ' ASC'); $db->setQuery($query); return $db->loadObjectList() ?: []; } // ================================================================== // Automation Engine // ================================================================== /** * Run automation rules for a specific trigger event against a ticket. * * @param string $event trigger_event: ticket_created, ticket_replied, status_changed, scheduled * @param int $ticketId The ticket to evaluate */ public function runAutomation(string $event, int $ticketId): void { try { $db = $this->getDatabase(); // Load enabled rules for this event $query = $db->getQuery(true) ->select('*') ->from($db->quoteName('#__mokosuite_ticket_automation')) ->where($db->quoteName('trigger_event') . ' = ' . $db->quote($event)) ->where($db->quoteName('enabled') . ' = 1') ->order($db->quoteName('ordering') . ' ASC'); $db->setQuery($query); $rules = $db->loadObjectList() ?: []; if (empty($rules)) { return; } // Load the ticket $ticket = $this->getTicket($ticketId); if (!$ticket) { return; } // Calculate age in hours $ticket->age_hours = (time() - strtotime($ticket->created)) / 3600; foreach ($rules as $rule) { $conditions = json_decode($rule->conditions, true) ?: []; $actions = json_decode($rule->actions, true) ?: []; if ($this->evaluateConditions($conditions, $ticket)) { $this->executeActions($actions, $ticketId, $ticket); } } } catch (\Throwable $e) { \Joomla\CMS\Log\Log::add('Automation error: ' . $e->getMessage(), \Joomla\CMS\Log\Log::WARNING, 'mokosuite'); } } /** * Run all scheduled automation rules against all open tickets. */ public function runScheduledAutomation(): array { $db = $this->getDatabase(); $results = ['evaluated' => 0, 'acted' => 0]; // Load scheduled rules $query = $db->getQuery(true) ->select('*') ->from($db->quoteName('#__mokosuite_ticket_automation')) ->where($db->quoteName('trigger_event') . ' = ' . $db->quote('scheduled')) ->where($db->quoteName('enabled') . ' = 1') ->order($db->quoteName('ordering') . ' ASC'); $db->setQuery($query); $rules = $db->loadObjectList() ?: []; if (empty($rules)) { return $results; } // Load all non-closed tickets $query = $db->getQuery(true) ->select('*') ->from($db->quoteName('#__mokosuite_tickets')) ->where($db->quoteName('status') . ' != ' . $db->quote('closed')); $db->setQuery($query); $tickets = $db->loadObjectList() ?: []; foreach ($tickets as $ticket) { $ticket->age_hours = (time() - strtotime($ticket->created)) / 3600; $ticket->replies = []; $results['evaluated']++; foreach ($rules as $rule) { $conditions = json_decode($rule->conditions, true) ?: []; $actions = json_decode($rule->actions, true) ?: []; if ($this->evaluateConditions($conditions, $ticket)) { $this->executeActions($actions, (int) $ticket->id, $ticket); $results['acted']++; } } } return $results; } /** * Evaluate a set of conditions against a ticket (all must match). */ private function evaluateConditions(array $conditions, object $ticket): bool { foreach ($conditions as $cond) { $field = $cond['field'] ?? ''; $op = $cond['op'] ?? 'eq'; $value = $cond['value'] ?? ''; $ticketValue = $ticket->{$field} ?? null; if ($ticketValue === null) { return false; } switch ($op) { case 'eq': if ((string) $ticketValue !== (string) $value) return false; break; case 'neq': if ((string) $ticketValue === (string) $value) return false; break; case 'gt': if ((float) $ticketValue <= (float) $value) return false; break; case 'lt': if ((float) $ticketValue >= (float) $value) return false; break; case 'in': $list = array_map('trim', explode(',', $value)); if (!\in_array((string) $ticketValue, $list, true)) return false; break; case 'not_in': $list = array_map('trim', explode(',', $value)); if (\in_array((string) $ticketValue, $list, true)) return false; break; default: return false; } } return true; } /** * Execute a set of actions on a ticket. */ private function executeActions(array $actions, int $ticketId, object $ticket): void { $db = $this->getDatabase(); $now = Factory::getDate()->toSql(); foreach ($actions as $action) { $type = $action['type'] ?? ''; $value = $action['value'] ?? ''; switch ($type) { case 'set_status': $this->updateStatus($ticketId, $value); break; case 'set_priority': $db->setQuery( $db->getQuery(true) ->update($db->quoteName('#__mokosuite_tickets')) ->set($db->quoteName('priority') . ' = ' . $db->quote($value)) ->set($db->quoteName('modified') . ' = ' . $db->quote($now)) ->where($db->quoteName('id') . ' = ' . $ticketId) )->execute(); break; case 'assign': $db->setQuery( $db->getQuery(true) ->update($db->quoteName('#__mokosuite_tickets')) ->set($db->quoteName('assigned_to') . ' = ' . (int) $value) ->set($db->quoteName('modified') . ' = ' . $db->quote($now)) ->where($db->quoteName('id') . ' = ' . $ticketId) )->execute(); break; case 'add_note': $reply = (object) [ 'ticket_id' => $ticketId, 'user_id' => 0, 'body' => $value, 'is_internal' => 1, 'created' => $now, ]; $db->insertObject('#__mokosuite_ticket_replies', $reply, 'id'); break; case 'send_email': // value = email address or comma-separated list $emails = array_filter(array_map('trim', explode(',', $value))); foreach ($emails as $email) { try { $mailer = Factory::getMailer(); $mailer->addRecipient($email); $mailer->setSubject('[Ticket #' . $ticketId . '] Automation Alert'); $mailer->setBody('Automation rule triggered for ticket #' . $ticketId . ': ' . ($ticket->subject ?? '')); $mailer->isHtml(false); $mailer->Send(); } catch (\Throwable $e) { \Joomla\CMS\Log\Log::add('Automation email failed: ' . $e->getMessage(), \Joomla\CMS\Log\Log::WARNING, 'mokosuite'); } } break; case 'create_ticket': // value = JSON: {"subject":"...","body":"...","category_id":1,"priority":"normal","behavior":"append"} $ticketData = json_decode($value, true) ?: []; $behavior = $ticketData['behavior'] ?? 'append'; $userId = (int) ($ticket->created_by ?? 0); $catId = (int) ($ticketData['category_id'] ?? 0); if ($behavior === 'append' && $userId > 0) { // Check for existing open ticket from this user in this category $db->setQuery( $db->getQuery(true) ->select($db->quoteName('id')) ->from($db->quoteName('#__mokosuite_tickets')) ->where($db->quoteName('created_by') . ' = ' . $userId) ->where($db->quoteName('status') . ' NOT IN (' . $db->quote('resolved') . ',' . $db->quote('closed') . ')') ->where($catId ? $db->quoteName('category_id') . ' = ' . $catId : '1=1') ->order($db->quoteName('created') . ' DESC') ->setLimit(1) ); $existingId = (int) $db->loadResult(); if ($existingId) { $this->addReply($existingId, $ticketData['body'] ?? 'Automation event', true); break; } } elseif ($behavior === 'skip_if_open' && $userId > 0) { $db->setQuery( $db->getQuery(true) ->select('COUNT(*)') ->from($db->quoteName('#__mokosuite_tickets')) ->where($db->quoteName('created_by') . ' = ' . $userId) ->where($db->quoteName('status') . ' NOT IN (' . $db->quote('resolved') . ',' . $db->quote('closed') . ')') ); if ((int) $db->loadResult() > 0) { break; } } // Create new ticket $this->createTicket([ 'subject' => $ticketData['subject'] ?? 'Automation: ' . ($ticket->subject ?? 'System event'), 'body' => $ticketData['body'] ?? '', 'priority' => $ticketData['priority'] ?? 'normal', 'category_id' => $catId, ]); break; } } } /** * Run automation for a system event (not tied to a specific ticket). * Creates a virtual ticket context from event data. */ public function runSystemEventAutomation(string $event, array $eventData = []): void { try { $db = $this->getDatabase(); $query = $db->getQuery(true) ->select('*') ->from($db->quoteName('#__mokosuite_ticket_automation')) ->where($db->quoteName('trigger_event') . ' = ' . $db->quote($event)) ->where($db->quoteName('enabled') . ' = 1') ->order($db->quoteName('ordering') . ' ASC'); $db->setQuery($query); $rules = $db->loadObjectList() ?: []; if (empty($rules)) { return; } // Build a virtual ticket-like object from event data $context = (object) array_merge([ 'id' => 0, 'subject' => $eventData['subject'] ?? $event, 'body' => $eventData['body'] ?? '', 'status' => 'open', 'priority' => $eventData['priority'] ?? 'normal', 'created_by' => $eventData['user_id'] ?? 0, 'created' => gmdate('Y-m-d H:i:s'), 'age_hours' => 0, ], $eventData); foreach ($rules as $rule) { $conditions = json_decode($rule->conditions, true) ?: []; $actions = json_decode($rule->actions, true) ?: []; if (empty($conditions) || $this->evaluateConditions($conditions, $context)) { $this->executeActions($actions, 0, $context); } } } catch (\Throwable $e) { \Joomla\CMS\Log\Log::add('System event automation error: ' . $e->getMessage(), \Joomla\CMS\Log\Log::WARNING, 'mokosuite'); } } /** * Get all automation rules. */ public function getAutomationRules(): array { $db = $this->getDatabase(); $db->setQuery( $db->getQuery(true) ->select('*') ->from($db->quoteName('#__mokosuite_ticket_automation')) ->order($db->quoteName('ordering') . ' ASC') ); return $db->loadObjectList() ?: []; } // ================================================================== // Akeeba Ticket System Importer // ================================================================== /** * Check if ATS tables exist and return counts. */ public function checkAtsAvailable(): ?object { $db = $this->getDatabase(); try { $db->setQuery('SELECT COUNT(*) FROM #__ats_tickets'); $tickets = (int) $db->loadResult(); $db->setQuery('SELECT COUNT(*) FROM #__ats_posts'); $posts = (int) $db->loadResult(); $db->setQuery('SELECT COUNT(*) FROM #__ats_cannedreplies'); $canned = (int) $db->loadResult(); return (object) ['tickets' => $tickets, 'posts' => $posts, 'canned' => $canned]; } catch (\Throwable $e) { return null; } } /** * Import tickets, replies, and canned responses from Akeeba Ticket System. */ public function importFromAts(): array { $db = $this->getDatabase(); $results = ['tickets' => 0, 'replies' => 0, 'canned' => 0, 'errors' => []]; try { // Status mapping: ATS → MokoSuite $statusMap = [ 'O' => 'open', // Open 'P' => 'in_progress', // Pending (staff action needed) 'C' => 'closed', // Closed ]; // Numeric statuses 1-99 are custom — map to open for ($i = 1; $i <= 99; $i++) { $statusMap[(string) $i] = 'open'; } // Priority mapping: ATS uses 1-5, we use enum $priorityMap = [ 1 => 'low', 2 => 'low', 3 => 'normal', 4 => 'high', 5 => 'urgent', ]; // Category mapping: ATS uses Joomla categories, map catid to our category // Default all to General Support (1) — admin can reassign later $defaultCategory = 1; // Import canned replies first $db->setQuery('SELECT * FROM #__ats_cannedreplies WHERE enabled = 1 ORDER BY ordering'); $atsCanned = $db->loadObjectList() ?: []; foreach ($atsCanned as $c) { $exists = $db->setQuery( $db->getQuery(true) ->select('COUNT(*)') ->from('#__mokosuite_ticket_canned') ->where($db->quoteName('title') . ' = ' . $db->quote($c->title)) )->loadResult(); if ((int) $exists > 0) { continue; } $row = (object) [ 'title' => $c->title, 'body' => strip_tags($c->reply ?? ''), 'category_id' => null, 'ordering' => (int) ($c->ordering ?? 0), ]; $db->insertObject('#__mokosuite_ticket_canned', $row, 'id'); $results['canned']++; } // Import tickets $db->setQuery('SELECT * FROM #__ats_tickets ORDER BY id'); $atsTickets = $db->loadObjectList() ?: []; $ticketIdMap = []; // ATS id → MokoSuite id foreach ($atsTickets as $t) { // Skip if already imported (check by subject + created_by + created) $exists = $db->setQuery( $db->getQuery(true) ->select('COUNT(*)') ->from('#__mokosuite_tickets') ->where($db->quoteName('subject') . ' = ' . $db->quote($t->title)) ->where($db->quoteName('created_by') . ' = ' . (int) $t->created_by) )->loadResult(); if ((int) $exists > 0) { continue; } $status = $statusMap[$t->status] ?? 'open'; $priority = $priorityMap[(int) $t->priority] ?? 'normal'; $row = (object) [ 'subject' => $t->title, 'body' => '', 'status' => $status, 'priority' => $priority, 'category_id' => $defaultCategory, 'created_by' => (int) $t->created_by, 'assigned_to' => (int) $t->assigned_to ?: null, 'created' => $t->created ?: Factory::getDate()->toSql(), 'modified' => $t->modified, 'resolved' => $status === 'closed' ? ($t->modified ?: $t->created) : null, 'closed' => $status === 'closed' ? ($t->modified ?: $t->created) : null, 'sla_responded' => 1, ]; $db->insertObject('#__mokosuite_tickets', $row, 'id'); $ticketIdMap[(int) $t->id] = (int) $row->id; $results['tickets']++; } // Import posts (replies) $db->setQuery('SELECT * FROM #__ats_posts ORDER BY id'); $atsPosts = $db->loadObjectList() ?: []; foreach ($atsPosts as $p) { $newTicketId = $ticketIdMap[(int) $p->ticket_id] ?? null; if (!$newTicketId) { continue; } // First post of a ticket is usually the ticket body — update the ticket if (empty($results['first_post_' . $p->ticket_id])) { $results['first_post_' . $p->ticket_id] = true; $body = strip_tags($p->content_html ?? ''); $db->setQuery( $db->getQuery(true) ->update('#__mokosuite_tickets') ->set($db->quoteName('body') . ' = ' . $db->quote($body)) ->where($db->quoteName('id') . ' = ' . $newTicketId) )->execute(); continue; } $row = (object) [ 'ticket_id' => $newTicketId, 'user_id' => (int) $p->created_by, 'body' => strip_tags($p->content_html ?? ''), 'is_internal' => 0, 'created' => $p->created ?: Factory::getDate()->toSql(), ]; $db->insertObject('#__mokosuite_ticket_replies', $row, 'id'); $results['replies']++; } // Clean up temp tracking keys foreach (array_keys($results) as $k) { if (str_starts_with($k, 'first_post_')) { unset($results[$k]); } } return [ 'success' => true, 'message' => sprintf( 'Imported %d tickets, %d replies, %d canned responses from Akeeba Ticket System.', $results['tickets'], $results['replies'], $results['canned'] ), 'counts' => $results, ]; } catch (\Throwable $e) { return ['success' => false, 'message' => 'Import failed: ' . $e->getMessage()]; } } }