Lint / PHP (phpcs PSR-12) (push) Successful in 42s
Lint / JS (eslint) (push) Successful in 13s
Lint / PHP requirements (version + extensions) (push) Successful in 27s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m24s
Lint / Deploy (push) Successful in 3s
CustomFieldModel::setValue()/setValues()/getValuesForTicket() were never called anywhere outside api/custom_fields.php's own admin CRUD for field *definitions* — CreateTicketView.php never rendered custom fields, TicketController::create() never collected or saved them, and TicketView.php never displayed them. The whole feature (admin defines fields at /admin/custom-fields, including marking them Required) was config-only with zero consumer; is_required was enforced nowhere. Added: - api/ticket_custom_fields.php: new endpoint (bootstrap.php-based, so any ticket editor can use it, not just admins) that saves values for a ticket. Only considers fields applicable to the ticket's current category (or category-less fields); enforces is_required, validates select values against the field's configured options, and validates number fields are numeric. Values for fields that don't apply are silently ignored rather than persisted, so a value typed before a category change can't linger as orphaned data. - CreateTicketView.php: renders every active field definition (all categories, since the ticket doesn't exist yet), grouped with a data-custom-field-category attribute and toggled client-side as the Category select changes, matching the existing visibility-groups toggle pattern. Submitted as part of the same form. - TicketController::create(): validates is_required server-side against the fields applicable to the *submitted* category (not just whatever was visible client-side) before creating the ticket, then persists via CustomFieldModel::setValues() after a successful create. - TicketView.php: new "Custom Fields" tab (only shown when the ticket's category has applicable fields) rendering current values with a single Save button, calling the new endpoint — a panel-plus-save interaction rather than per-field inline auto-save, to keep scope contained across 6 field types. Verified against real MariaDB and a real running server: a required field left blank is correctly rejected (both at ticket-creation time and when editing an existing ticket) with no partial write; a valid submission persists exactly the fields applicable to that ticket's category; an invalid select value is rejected without touching previously-saved values; and each rejection correctly returns a rotated recovery csrf_token (initially missed on the validation-error paths, since they used a plain echo/exit instead of the bootstrap.php apiRespond() helper every other endpoint's error paths use for this). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lhz7pGMaoTfL5sdYS5XiKv
88 lines
2.8 KiB
PHP
88 lines
2.8 KiB
PHP
<?php
|
|
|
|
/**
|
|
* Save custom field values for a ticket.
|
|
*
|
|
* POST { ticket_id, values: { [field_id]: value, ... } }
|
|
*
|
|
* Only fields applicable to the ticket's current category (or category-less
|
|
* fields) are considered; anything else in `values` is ignored rather than
|
|
* persisted, so a value typed for a field that no longer applies (e.g. the
|
|
* category changed) can't linger as orphaned/misleading data.
|
|
*/
|
|
|
|
require_once __DIR__ . '/bootstrap.php';
|
|
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
|
require_once dirname(__DIR__) . '/models/CustomFieldModel.php';
|
|
|
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
|
http_response_code(405);
|
|
apiRespond(['success' => 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]);
|