feat: ticket contact linking, multi-assignee, and push-based pre-release

Tickets can now link to Joomla contact records (optional contact_id FK).
Replaces single assigned_to with junction table for multi-assignee
support — tickets can be assigned to multiple users and/or user groups.
Updates pre-release workflow to trigger on push to dev/alpha/beta/rc.
This commit is contained in:
Jonathan Miller
2026-06-07 10:05:06 -05:00
parent 2d9454ea3f
commit 6fedd0e993
7 changed files with 244 additions and 23 deletions
+17 -15
View File
@@ -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: |
@@ -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),
@@ -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;
@@ -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.
*/
@@ -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();
@@ -90,7 +90,25 @@ $priorityBadge = [
<tr><td class="text-muted">Priority</td><td><span class="badge <?php echo $priorityBadge[$t->priority] ?? ''; ?>"><?php echo ucfirst($t->priority); ?></span></td></tr>
<tr><td class="text-muted">Category</td><td><?php echo $this->escape($t->category_title ?? '—'); ?></td></tr>
<tr><td class="text-muted">Created By</td><td><?php echo $this->escape($t->created_by_name); ?><br><small><?php echo $this->escape($t->created_by_email ?? ''); ?></small></td></tr>
<tr><td class="text-muted">Assigned To</td><td><?php echo $this->escape($t->assigned_to_name ?? 'Unassigned'); ?></td></tr>
<tr><td class="text-muted">Assigned To</td><td><?php
if (!empty($t->assignees)) {
foreach ($t->assignees as $a) {
$icon = $a->assignee_type === 'group' ? '<span class="icon-users"></span> ' : '<span class="icon-user"></span> ';
echo '<div>' . $icon . $this->escape($a->name) . '</div>';
}
} else {
echo '<em>Unassigned</em>';
}
?></td></tr>
<?php if ($t->contact_id): ?>
<tr><td class="text-muted">Contact</td><td>
<a href="<?php echo Route::_('index.php?option=com_contact&task=contact.edit&id=' . (int) $t->contact_id); ?>">
<?php echo $this->escape($t->contact_name ?? 'Contact #' . $t->contact_id); ?>
</a>
<?php if (!empty($t->contact_email)): ?><br><small><?php echo $this->escape($t->contact_email); ?></small><?php endif; ?>
<?php if (!empty($t->contact_phone)): ?><br><small><?php echo $this->escape($t->contact_phone); ?></small><?php endif; ?>
</td></tr>
<?php endif; ?>
<tr><td class="text-muted">Created</td><td><?php echo HTMLHelper::_('date', $t->created, 'M d, Y H:i'); ?></td></tr>
<?php if ($t->resolved): ?><tr><td class="text-muted">Resolved</td><td><?php echo HTMLHelper::_('date', $t->resolved, 'M d, Y H:i'); ?></td></tr><?php endif; ?>
<?php if ($t->closed): ?><tr><td class="text-muted">Closed</td><td><?php echo HTMLHelper::_('date', $t->closed, 'M d, Y H:i'); ?></td></tr><?php endif; ?>
@@ -88,6 +88,7 @@ $priorityBadge = [
<th>Status</th>
<th>Priority</th>
<th>Category</th>
<th>Contact</th>
<th>Created By</th>
<th>Assigned To</th>
<th>Created</th>
@@ -96,7 +97,7 @@ $priorityBadge = [
</thead>
<tbody>
<?php if (empty($tickets)): ?>
<tr><td colspan="9" class="text-center text-muted py-4">No tickets found.</td></tr>
<tr><td colspan="10" class="text-center text-muted py-4">No tickets found.</td></tr>
<?php else: ?>
<?php foreach ($tickets as $t): ?>
<?php
@@ -112,8 +113,20 @@ $priorityBadge = [
<td><span class="badge <?php echo $statusBadge[$t->status] ?? 'bg-secondary'; ?>"><?php echo ucwords(str_replace('_', ' ', $t->status)); ?></span></td>
<td><span class="badge <?php echo $priorityBadge[$t->priority] ?? 'bg-secondary'; ?>"><?php echo ucfirst($t->priority); ?></span></td>
<td><?php echo $this->escape($t->category_title ?? '—'); ?></td>
<td><?php echo $t->contact_name ? '<a href="' . Route::_('index.php?option=com_contact&task=contact.edit&id=' . (int) $t->contact_id) . '">' . $this->escape($t->contact_name) . '</a>' : '—'; ?></td>
<td><?php echo $this->escape($t->created_by_name ?? ''); ?></td>
<td><?php echo $t->assigned_to_name ? $this->escape($t->assigned_to_name) : '<em>Unassigned</em>'; ?></td>
<td><?php
if (!empty($t->assignees)) {
$names = [];
foreach ($t->assignees as $a) {
$icon = $a->assignee_type === 'group' ? '<span class="icon-users"></span> ' : '';
$names[] = $icon . $this->escape($a->name);
}
echo implode(', ', $names);
} else {
echo '<em>Unassigned</em>';
}
?></td>
<td class="small"><?php echo HTMLHelper::_('date', $t->created, 'M d H:i'); ?></td>
<td class="small">
<?php if ($t->sla_response_due && !$t->sla_responded): ?>
@@ -157,7 +170,7 @@ $priorityBadge = [
<input type="text" name="subject" id="modal-subject" class="form-control" required>
</div>
<div class="row mb-3">
<div class="col-md-6">
<div class="col-md-4">
<label class="form-label">Category</label>
<select name="category_id" class="form-select">
<option value="">— Select —</option>
@@ -166,7 +179,7 @@ $priorityBadge = [
<?php endforeach; ?>
</select>
</div>
<div class="col-md-6">
<div class="col-md-4">
<label class="form-label">Priority</label>
<select name="priority" class="form-select">
<option value="normal">Normal</option>
@@ -175,6 +188,15 @@ $priorityBadge = [
<option value="urgent">Urgent</option>
</select>
</div>
<div class="col-md-4">
<label class="form-label">Contact</label>
<select name="contact_id" class="form-select">
<option value="">— None —</option>
<?php foreach ($this->contacts as $contact): ?>
<option value="<?php echo $contact->id; ?>"><?php echo $this->escape($contact->name); ?></option>
<?php endforeach; ?>
</select>
</div>
</div>
<div class="mb-3">
<label class="form-label">Description</label>