Files
tinker_tickets/services/AssignmentService.php
T
jaredandClaude Opus 5.5 9e462f7f00 Extract ticket assignment from assign_ticket.php into AssignmentService (#111)
The access check, the admin/creator/current-assignee permission rule,
unassign/assign, the audit log, the optional Matrix assignment
notification and the stats-cache invalidation move into
services/AssignmentService.php for reuse by the MCP assign_ticket tool.
Error messages and status codes are unchanged.

One deliberate difference: assign_ticket.php's early error responses
(400/403/404) used a bare echo and so omitted the CSRF token that
bootstrap had just rotated. Every response now goes through
apiRespond(), which includes it. The front end already resyncs its
token from any response body, so this is compatible, and a failed
assign can no longer leave the page holding a stale token.

Verified over real HTTP: the assignee can reassign (200); a user who can
see the ticket but isn't admin/creator/assignee gets 403 'Permission
denied'.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X
2026-09-24 19:13:31 -04:00

99 lines
4.3 KiB
PHP

<?php
/**
* Assigning / unassigning a ticket: access check, permission (admin, creator,
* or current assignee), audit log, optional Matrix assignment notification,
* stats-cache invalidation.
*
* Shared by the web UI (api/assign_ticket.php) and the MCP assign_ticket tool
* so both run one code path (tinker_tickets#111). Extracted verbatim from
* assign_ticket.php. Returns result arrays; failures carry 'http_status'.
*/
require_once dirname(__DIR__) . '/models/TicketModel.php';
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
require_once dirname(__DIR__) . '/models/UserModel.php';
require_once dirname(__DIR__) . '/models/StatsModel.php';
require_once dirname(__DIR__) . '/helpers/NotificationHelper.php';
require_once dirname(__DIR__) . '/helpers/SynapseHelper.php';
class AssignmentService
{
/**
* @param array $currentUser Authenticated user row (user_id, username, display_name, is_admin, ...)
* @param array $data ticket_id, assigned_to (user_id; null/'' to unassign)
*/
public static function assign(mysqli $conn, array $currentUser, array $data): array
{
$userId = $currentUser['user_id'];
$isAdmin = $currentUser['is_admin'] ?? false;
$ticketIdRaw = isset($data['ticket_id']) ? trim((string)$data['ticket_id']) : '';
$assignedTo = $data['assigned_to'] ?? null;
if (!ctype_digit($ticketIdRaw) || (int)$ticketIdRaw <= 0) {
return ['success' => false, 'error' => 'Ticket ID required', 'http_status' => 400];
}
$ticketId = $ticketIdRaw;
$ticketModel = new TicketModel($conn);
$auditLogModel = new AuditLogModel($conn);
$userModel = new UserModel($conn);
// Verify ticket exists and user can access it
$ticket = $ticketModel->getTicketById($ticketId);
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
return ['success' => false, 'error' => 'Ticket not found', 'http_status' => 404];
}
// Authorization: only admins or the ticket creator/assignee can reassign
if (!$isAdmin && (int)$ticket['created_by'] !== (int)$userId && (int)$ticket['assigned_to'] !== (int)$userId) {
return ['success' => false, 'error' => 'Permission denied', 'http_status' => 403];
}
if ($assignedTo === null || $assignedTo === '') {
// Unassign ticket
$success = $ticketModel->unassignTicket($ticketId, $userId);
if ($success) {
$auditLogModel->log($userId, 'unassign', 'ticket', $ticketId);
}
} else {
// Validate assigned_to is a valid user ID
$assignedTo = (int)$assignedTo;
$targetUser = $userModel->getUserById($assignedTo);
if (!$targetUser) {
return ['success' => false, 'error' => 'Invalid user ID', 'http_status' => 400];
}
// Assign ticket
$success = $ticketModel->assignTicket($ticketId, $assignedTo, $userId);
if ($success) {
$auditLogModel->log($userId, 'assign', 'ticket', $ticketId, ['assigned_to' => $assignedTo]);
if (!empty($GLOBALS['config']['MATRIX_NOTIFY_ASSIGNMENTS'])) {
$changedByDisplay = $currentUser['display_name'] ?? $currentUser['username'] ?? null;
$assigneeName = $targetUser['display_name'] ?? $targetUser['username'] ?? null;
$assigneeMatrix = isset($targetUser['username'])
? SynapseHelper::resolveUsername($targetUser['username'])
: null;
NotificationHelper::sendAssignmentNotification(
$ticketId,
$ticket['title'] ?? "Ticket #{$ticketId}",
$assigneeName,
$assigneeMatrix,
$changedByDisplay,
$ticket['visibility'] ?? 'public'
);
}
}
}
if (!$success) {
return ['success' => false, 'error' => 'Failed to update ticket assignment', 'http_status' => 500];
}
(new StatsModel($conn))->invalidateCache();
return ['success' => true];
}
}