diff --git a/api/ticket_custom_fields.php b/api/ticket_custom_fields.php new file mode 100644 index 0000000..93f028a --- /dev/null +++ b/api/ticket_custom_fields.php @@ -0,0 +1,87 @@ + false, 'error' => 'Method not allowed']); +} + +$data = json_decode(file_get_contents('php://input'), true); +$ticketId = isset($data['ticket_id']) ? trim((string)$data['ticket_id']) : ''; +$values = is_array($data['values'] ?? null) ? $data['values'] : []; + +if ($ticketId === '') { + http_response_code(400); + apiRespond(['success' => false, 'error' => 'ticket_id required']); +} + +$ticketModel = new TicketModel($conn); +$ticket = $ticketModel->getTicketById($ticketId); +if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) { + http_response_code(404); + apiRespond(['success' => false, 'error' => 'Ticket not found']); +} + +$fieldModel = new CustomFieldModel($conn); +$definitions = $fieldModel->getAllDefinitions($ticket['category'], true); + +$errors = []; +$toSave = []; +foreach ($definitions as $def) { + $fieldId = (int)$def['field_id']; + $raw = $values[$fieldId] ?? ($values[(string)$fieldId] ?? null); + + if ($def['field_type'] === 'checkbox') { + $normalized = !empty($raw) ? '1' : '0'; + } else { + $normalized = is_scalar($raw) ? trim((string)$raw) : ''; + } + + if (!empty($def['is_required']) && $def['field_type'] !== 'checkbox' && $normalized === '') { + $errors[] = $def['field_label'] . ' is required'; + continue; + } + + if ($def['field_type'] === 'select' && $normalized !== '') { + $allowedOptions = $def['field_options']['options'] ?? []; + if (!in_array($normalized, $allowedOptions, true)) { + $errors[] = $def['field_label'] . ' has an invalid selection'; + continue; + } + } + + if ($def['field_type'] === 'number' && $normalized !== '' && !is_numeric($normalized)) { + $errors[] = $def['field_label'] . ' must be a number'; + continue; + } + + $toSave[$fieldId] = $normalized; +} + +if (!empty($errors)) { + http_response_code(422); + apiRespond(['success' => false, 'error' => implode('; ', $errors)]); +} + +$fieldModel->setValues($ticketId, $toSave); + +require_once dirname(__DIR__) . '/models/AuditLogModel.php'; +(new AuditLogModel($conn))->log($userId, 'update', 'ticket', $ticketId, [ + 'reason' => 'custom fields updated', +]); + +apiRespond(['success' => true]); diff --git a/controllers/TicketController.php b/controllers/TicketController.php index a03bd76..2825d29 100644 --- a/controllers/TicketController.php +++ b/controllers/TicketController.php @@ -7,6 +7,7 @@ require_once dirname(__DIR__) . '/models/AuditLogModel.php'; require_once dirname(__DIR__) . '/models/UserModel.php'; require_once dirname(__DIR__) . '/models/WorkflowModel.php'; require_once dirname(__DIR__) . '/models/TemplateModel.php'; +require_once dirname(__DIR__) . '/models/CustomFieldModel.php'; require_once dirname(__DIR__) . '/helpers/UrlHelper.php'; require_once dirname(__DIR__) . '/helpers/NotificationHelper.php'; @@ -18,6 +19,7 @@ class TicketController private $userModel; private $workflowModel; private $templateModel; + private $customFieldModel; private $conn; public function __construct($conn) @@ -29,6 +31,7 @@ class TicketController $this->userModel = new UserModel($conn); $this->workflowModel = new WorkflowModel($conn); $this->templateModel = new TemplateModel($conn); + $this->customFieldModel = new CustomFieldModel($conn); } public function view($id) @@ -60,6 +63,11 @@ class TicketController // Get allowed status transitions for this ticket $allowedTransitions = $this->workflowModel->getAllowedTransitions($ticket['status']); + // Custom fields applicable to this ticket's category, with any + // already-saved values for it + $customFieldDefs = $this->customFieldModel->getAllDefinitions($ticket['category'], true); + $customFieldValues = $this->customFieldModel->getValuesForTicket($id); + // Make $conn available to view for visibility groups $conn = $this->conn; @@ -73,6 +81,12 @@ class TicketController $currentUser = $GLOBALS['currentUser'] ?? null; $userId = $currentUser['user_id'] ?? null; + // All active custom field definitions (every category, plus + // category-less ones) — the create form renders them all and toggles + // visibility client-side as the Category select changes, since the + // ticket doesn't exist yet to scope the query to one category. + $allCustomFieldDefs = $this->customFieldModel->getAllDefinitions(null, true); + // Check if form was submitted if ($_SERVER['REQUEST_METHOD'] === 'POST') { // Validate CSRF token @@ -131,6 +145,38 @@ class TicketController return; } + // Custom fields applicable to the submitted category — validate + // is_required server-side (the form is novalidate, and a field + // hidden by the client-side category toggle must not silently + // bypass a requirement that applies to the category actually + // submitted). + $submittedCustomFields = is_array($_POST['custom_fields'] ?? null) ? $_POST['custom_fields'] : []; + $applicableFieldDefs = array_filter( + $allCustomFieldDefs, + fn($def) => $def['category'] === null || $def['category'] === $ticketData['category'] + ); + $customFieldsToSave = []; + foreach ($applicableFieldDefs as $def) { + $fieldId = (int)$def['field_id']; + $raw = $submittedCustomFields[$fieldId] ?? null; + $normalized = $def['field_type'] === 'checkbox' + ? (!empty($raw) ? '1' : '0') + : (is_scalar($raw) ? trim((string)$raw) : ''); + + if (!empty($def['is_required']) && $def['field_type'] !== 'checkbox' && $normalized === '') { + $error = $def['field_label'] . ' is required'; + $templates = $this->templateModel->getAllTemplates(); + $allUsers = $this->userModel->getAllUsers(); + $conn = $this->conn; + include dirname(__DIR__) . '/views/CreateTicketView.php'; + return; + } + + if ($normalized !== '') { + $customFieldsToSave[$fieldId] = $normalized; + } + } + // Create ticket with user tracking $result = $this->ticketModel->createTicket($ticketData, $userId); @@ -144,6 +190,12 @@ class TicketController require_once dirname(__DIR__) . '/models/StatsModel.php'; (new StatsModel($this->conn))->invalidateCache(); + // Persist custom field values for the fields applicable to + // this ticket's category + if (!empty($customFieldsToSave)) { + $this->customFieldModel->setValues($result['ticket_id'], $customFieldsToSave); + } + // Auto-link as duplicate if requested from create form $linkDupOfRaw = trim($_POST['link_duplicate_of'] ?? ''); if ($linkDupOfRaw !== '' && ctype_digit($linkDupOfRaw)) { diff --git a/views/CreateTicketView.php b/views/CreateTicketView.php index 7714d4c..178516f 100644 --- a/views/CreateTicketView.php +++ b/views/CreateTicketView.php @@ -124,7 +124,7 @@ include __DIR__ . '/layout_header.php';
Fields shown depend on the selected Category.
+