diff --git a/.mokogitea/workflows/pre-release.yml b/.mokogitea/workflows/pre-release.yml index 86908c20..7de57b4a 100644 --- a/.mokogitea/workflows/pre-release.yml +++ b/.mokogitea/workflows/pre-release.yml @@ -8,19 +8,17 @@ # REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform # PATH: /templates/workflows/universal/pre-release.yml.template # VERSION: 05.01.00 -# BRIEF: Manual pre-release -- builds dev/alpha/beta/rc packages from any branch +# BRIEF: Auto pre-release on push to dev/alpha/beta/rc branches name: "Universal: Pre-Release" on: - pull_request: - types: [closed] + push: branches: - dev - pull_request_target: - types: [synchronize, opened, reopened] - branches: - - main + - alpha + - beta + - rc workflow_dispatch: inputs: stability: @@ -43,12 +41,11 @@ env: jobs: build: - name: "Build Pre-Release (${{ inputs.stability || 'development' }})" + name: "Build Pre-Release (${{ inputs.stability || github.ref_name }})" runs-on: release if: >- github.event_name == 'workflow_dispatch' || - (github.event_name == 'pull_request' && github.event.pull_request.merged == true && github.event.pull_request.base.ref == 'dev') || - (github.event_name == 'pull_request_target' && github.event.pull_request.base.ref == 'main') + github.event_name == 'push' steps: - name: Checkout @@ -56,7 +53,7 @@ jobs: with: fetch-depth: 0 token: ${{ secrets.MOKOGITEA_TOKEN }} - ref: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || '' }} + ref: ${{ github.ref_name }} - name: Setup moko-platform tools env: @@ -87,9 +84,14 @@ jobs: - name: Resolve metadata and bump version id: meta run: | - # Auto-detect stability: RC for PRs targeting main, else use input or default to development - if [ "${{ github.event_name }}" = "pull_request_target" ] && [ "${{ github.event.pull_request.base.ref }}" = "main" ]; then - STABILITY="release-candidate" + # Auto-detect stability from branch name on push, or use input on dispatch + if [ "${{ github.event_name }}" = "push" ]; then + case "${{ github.ref_name }}" in + rc) STABILITY="release-candidate" ;; + alpha) STABILITY="alpha" ;; + beta) STABILITY="beta" ;; + *) STABILITY="development" ;; + esac else STABILITY="${{ inputs.stability || 'development' }}" fi @@ -164,7 +166,7 @@ jobs: php ${MOKO_CLI}/release_create.php \ --path . --version "$VERSION" --tag "$TAG" \ --token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \ - --repo "${GITEA_REPO}" --branch dev --prerelease + --repo "${GITEA_REPO}" --branch "${{ github.ref_name }}" --prerelease - name: Update release notes from CHANGELOG.md run: | diff --git a/source/packages/com_mokosuite/admin/sql/install.mysql.sql b/source/packages/com_mokosuite/admin/sql/install.mysql.sql index a299de6a..6096ec40 100644 --- a/source/packages/com_mokosuite/admin/sql/install.mysql.sql +++ b/source/packages/com_mokosuite/admin/sql/install.mysql.sql @@ -23,6 +23,7 @@ CREATE TABLE IF NOT EXISTS `#__mokosuite_tickets` ( `status` ENUM('open','in_progress','waiting','resolved','closed') NOT NULL DEFAULT 'open', `priority` ENUM('low','normal','high','urgent') NOT NULL DEFAULT 'normal', `category_id` INT UNSIGNED DEFAULT NULL, + `contact_id` INT UNSIGNED DEFAULT NULL, `created_by` INT NOT NULL DEFAULT 0, `assigned_to` INT DEFAULT NULL, `created` DATETIME NOT NULL, @@ -37,6 +38,7 @@ CREATE TABLE IF NOT EXISTS `#__mokosuite_tickets` ( KEY `idx_priority` (`priority`), KEY `idx_assigned` (`assigned_to`), KEY `idx_category` (`category_id`), + KEY `idx_contact` (`contact_id`), KEY `idx_created` (`created`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; @@ -72,6 +74,17 @@ CREATE TABLE IF NOT EXISTS `#__mokosuite_ticket_automation` ( PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +CREATE TABLE IF NOT EXISTS `#__mokosuite_ticket_assignees` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `ticket_id` INT UNSIGNED NOT NULL, + `assignee_type` ENUM('user','group') NOT NULL DEFAULT 'user', + `assignee_id` INT NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `idx_unique_assignment` (`ticket_id`, `assignee_type`, `assignee_id`), + KEY `idx_ticket` (`ticket_id`), + KEY `idx_assignee` (`assignee_type`, `assignee_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + -- Default automation rules INSERT IGNORE INTO `#__mokosuite_ticket_automation` (`id`, `title`, `trigger_event`, `conditions`, `actions`, `enabled`, `ordering`) VALUES (1, 'Auto-close resolved tickets after 7 days', 'scheduled', '[{"field":"status","op":"eq","value":"resolved"},{"field":"age_hours","op":"gt","value":"168"}]', '[{"type":"set_status","value":"closed"},{"type":"add_note","value":"Auto-closed after 7 days with no response."}]', 1, 1), diff --git a/source/packages/com_mokosuite/admin/sql/updates/mysql/02.35.01.sql b/source/packages/com_mokosuite/admin/sql/updates/mysql/02.35.01.sql new file mode 100644 index 00000000..286e0714 --- /dev/null +++ b/source/packages/com_mokosuite/admin/sql/updates/mysql/02.35.01.sql @@ -0,0 +1,20 @@ +-- Add contact link to tickets (optional FK to #__contact_details) +ALTER TABLE `#__mokosuite_tickets` + ADD COLUMN `contact_id` INT UNSIGNED DEFAULT NULL AFTER `category_id`, + ADD KEY `idx_contact` (`contact_id`); + +-- Multi-assignee junction table (replaces single assigned_to column) +CREATE TABLE IF NOT EXISTS `#__mokosuite_ticket_assignees` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `ticket_id` INT UNSIGNED NOT NULL, + `assignee_type` ENUM('user','group') NOT NULL DEFAULT 'user', + `assignee_id` INT NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `idx_unique_assignment` (`ticket_id`, `assignee_type`, `assignee_id`), + KEY `idx_ticket` (`ticket_id`), + KEY `idx_assignee` (`assignee_type`, `assignee_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- Migrate existing single-assignee data to junction table +INSERT IGNORE INTO `#__mokosuite_ticket_assignees` (`ticket_id`, `assignee_type`, `assignee_id`) +SELECT `id`, 'user', `assigned_to` FROM `#__mokosuite_tickets` WHERE `assigned_to` IS NOT NULL AND `assigned_to` > 0; diff --git a/source/packages/com_mokosuite/admin/src/Model/TicketsModel.php b/source/packages/com_mokosuite/admin/src/Model/TicketsModel.php index e77e0736..351a25b5 100644 --- a/source/packages/com_mokosuite/admin/src/Model/TicketsModel.php +++ b/source/packages/com_mokosuite/admin/src/Model/TicketsModel.php @@ -30,17 +30,18 @@ class TicketsModel extends BaseDatabaseModel $db->quoteName('t.priority'), $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('a.name', 'assigned_to_name'), + $db->quoteName('ct.name', 'contact_name'), ]) ->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('#__users', 'a') . ' ON a.id = t.assigned_to'); + ->leftJoin($db->quoteName('#__contact_details', 'ct') . ' ON ct.id = t.contact_id'); if (!empty($filters['status'])) { @@ -62,12 +63,24 @@ class TicketsModel extends BaseDatabaseModel $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() ?: []; - return $db->loadObjectList() ?: []; + // Load assignees for each ticket + foreach ($tickets as $ticket) + { + $ticket->assignees = $this->getTicketAssignees((int) $ticket->id); + } + + return $tickets; } /** @@ -83,11 +96,15 @@ class TicketsModel extends BaseDatabaseModel $db->quoteName('u.name', 'created_by_name'), $db->quoteName('u.email', 'created_by_email'), $db->quoteName('a.name', 'assigned_to_name'), + $db->quoteName('ct.name', 'contact_name'), + $db->quoteName('ct.email_to', 'contact_email'), + $db->quoteName('ct.telephone', 'contact_phone'), ]) ->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('#__users', 'a') . ' ON a.id = t.assigned_to') + ->leftJoin($db->quoteName('#__contact_details', 'ct') . ' ON ct.id = t.contact_id') ->where($db->quoteName('t.id') . ' = ' . $id); $db->setQuery($query); $ticket = $db->loadObject(); @@ -113,6 +130,9 @@ class TicketsModel extends BaseDatabaseModel // Reply count $ticket->reply_count = \count($ticket->replies); + // Load assignees (users + groups) + $ticket->assignees = $this->getTicketAssignees($id); + return $ticket; } @@ -133,6 +153,7 @@ class TicketsModel extends BaseDatabaseModel 'status' => 'open', 'priority' => $data['priority'] ?? 'normal', '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, @@ -174,6 +195,21 @@ class TicketsModel extends BaseDatabaseModel $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); + } + // Run automation + notifications $this->runAutomation('ticket_created', (int) $ticket->id); NotificationService::notify('ticket_created', $this->getTicket((int) $ticket->id)); @@ -317,6 +353,113 @@ class TicketsModel extends BaseDatabaseModel 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 canned responses, optionally filtered by category. */ diff --git a/source/packages/com_mokosuite/admin/src/View/Tickets/HtmlView.php b/source/packages/com_mokosuite/admin/src/View/Tickets/HtmlView.php index 590418c0..e52b415b 100644 --- a/source/packages/com_mokosuite/admin/src/View/Tickets/HtmlView.php +++ b/source/packages/com_mokosuite/admin/src/View/Tickets/HtmlView.php @@ -22,6 +22,7 @@ class HtmlView extends BaseHtmlView protected $statusCounts; protected $overdue = []; protected $atsAvailable = null; + protected $contacts = []; public function display($tpl = null) { @@ -32,6 +33,7 @@ class HtmlView extends BaseHtmlView 'status' => $app->getInput()->getString('filter_status', ''), 'priority' => $app->getInput()->getString('filter_priority', ''), 'category_id' => $app->getInput()->getInt('filter_category', 0), + 'contact_id' => $app->getInput()->getInt('filter_contact', 0), ]; $this->tickets = $model->getTickets($filters); @@ -39,6 +41,7 @@ class HtmlView extends BaseHtmlView $this->statusCounts = $model->getStatusCounts(); $this->overdue = $model->getOverdueTickets(); $this->atsAvailable = $model->checkAtsAvailable(); + $this->contacts = $model->getContacts(); $this->addToolbar(); diff --git a/source/packages/com_mokosuite/admin/tmpl/ticket/default.php b/source/packages/com_mokosuite/admin/tmpl/ticket/default.php index b43b9715..3a303062 100644 --- a/source/packages/com_mokosuite/admin/tmpl/ticket/default.php +++ b/source/packages/com_mokosuite/admin/tmpl/ticket/default.php @@ -90,7 +90,25 @@ $priorityBadge = [