Files
tinker_tickets/api/ticket_custom_fields.php
T
jaredandClaude Sonnet 5 3266373cdf
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
Wire Custom Fields into ticket creation and viewing (#47)
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
2026-09-11 13:12:42 -04:00

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]);