Files
tinker_tickets/models/CustomFieldModel.php
T
jaredandClaude Sonnet 5 fca0b42726
Lint / PHP (phpcs PSR-12) (push) Successful in 31s
Lint / JS (eslint) (push) Successful in 13s
Lint / PHP requirements (version + extensions) (push) Successful in 39s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 2m1s
Lint / Deploy (push) Successful in 6s
Validate field_type against the allowed enum in custom field definitions (#50)
The setValue()/is_required/select-options half of this issue was
already fixed incidentally by #47's new api/ticket_custom_fields.php
endpoint. The remaining gap: createDefinition()/updateDefinition()
never validated field_type against the six values the schema's
enum() actually allows (text/textarea/select/checkbox/date/number), so
a malformed type could be stored via the admin API and break whatever
UI renders it later.

Added an ALLOWED_FIELD_TYPES allowlist check at the top of both
methods, returning the same ['success' => false, 'error' => ...] shape
they already use for a DB failure — api/custom_fields.php already
propagates that shape correctly with no changes needed there. Verified
against real MariaDB: an invalid field_type is rejected on both create
and update, while a valid one still succeeds.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lhz7pGMaoTfL5sdYS5XiKv
2026-09-11 13:31:33 -04:00

263 lines
7.6 KiB
PHP

<?php
/**
* CustomFieldModel - Manages custom field definitions and values
*/
class CustomFieldModel
{
private $conn;
// Must match custom_field_definitions.field_type's enum() in the schema.
private const ALLOWED_FIELD_TYPES = ['text', 'textarea', 'select', 'checkbox', 'date', 'number'];
public function __construct($conn)
{
$this->conn = $conn;
}
// ========================================
// Field Definitions
// ========================================
/**
* Get all field definitions
*/
public function getAllDefinitions($category = null, $activeOnly = true)
{
$sql = "SELECT * FROM custom_field_definitions WHERE 1=1";
$params = [];
$types = '';
if ($activeOnly) {
$sql .= " AND is_active = 1";
}
if ($category !== null) {
$sql .= " AND (category = ? OR category IS NULL)";
$params[] = $category;
$types .= 's';
}
$sql .= " ORDER BY display_order ASC, field_id ASC";
if (!empty($params)) {
$stmt = $this->conn->prepare($sql);
$stmt->bind_param($types, ...$params);
$stmt->execute();
$result = $stmt->get_result();
} else {
$result = $this->conn->query($sql);
}
$fields = [];
while ($row = $result->fetch_assoc()) {
if ($row['field_options']) {
$row['field_options'] = json_decode($row['field_options'], true);
}
$fields[] = $row;
}
if (isset($stmt)) {
$stmt->close();
}
return $fields;
}
/**
* Get a single field definition
*/
public function getDefinition($fieldId)
{
$sql = "SELECT * FROM custom_field_definitions WHERE field_id = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param('i', $fieldId);
$stmt->execute();
$result = $stmt->get_result();
$row = $result->fetch_assoc();
$stmt->close();
if ($row && $row['field_options']) {
$row['field_options'] = json_decode($row['field_options'], true);
}
return $row;
}
/**
* Create a new field definition
*/
public function createDefinition($data)
{
if (!in_array($data['field_type'] ?? '', self::ALLOWED_FIELD_TYPES, true)) {
return ['success' => false, 'error' => 'Invalid field_type'];
}
$options = null;
if (isset($data['field_options']) && !empty($data['field_options'])) {
$options = json_encode($data['field_options']);
}
$sql = "INSERT INTO custom_field_definitions
(field_name, field_label, field_type, field_options, category, is_required, display_order, is_active)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)";
$isRequired = $data['is_required'] ?? 0;
$displayOrder = $data['display_order'] ?? 0;
$isActive = $data['is_active'] ?? 1;
$stmt = $this->conn->prepare($sql);
$stmt->bind_param(
'sssssiii',
$data['field_name'],
$data['field_label'],
$data['field_type'],
$options,
$data['category'],
$isRequired,
$displayOrder,
$isActive
);
if ($stmt->execute()) {
$id = $this->conn->insert_id;
$stmt->close();
return ['success' => true, 'field_id' => $id];
}
$error = $stmt->error;
$stmt->close();
return ['success' => false, 'error' => $error];
}
/**
* Update a field definition
*/
public function updateDefinition($fieldId, $data)
{
if (!in_array($data['field_type'] ?? '', self::ALLOWED_FIELD_TYPES, true)) {
return ['success' => false, 'error' => 'Invalid field_type'];
}
$options = null;
if (isset($data['field_options']) && !empty($data['field_options'])) {
$options = json_encode($data['field_options']);
}
$sql = "UPDATE custom_field_definitions SET
field_name = ?, field_label = ?, field_type = ?, field_options = ?,
category = ?, is_required = ?, display_order = ?, is_active = ?
WHERE field_id = ?";
$isRequired = $data['is_required'] ?? 0;
$displayOrder = $data['display_order'] ?? 0;
$isActive = $data['is_active'] ?? 1;
$stmt = $this->conn->prepare($sql);
$stmt->bind_param(
'sssssiiii',
$data['field_name'],
$data['field_label'],
$data['field_type'],
$options,
$data['category'],
$isRequired,
$displayOrder,
$isActive,
$fieldId
);
$success = $stmt->execute();
$stmt->close();
return ['success' => $success];
}
/**
* Delete a field definition
*/
public function deleteDefinition($fieldId)
{
// This will cascade delete all values due to FK constraint
$sql = "DELETE FROM custom_field_definitions WHERE field_id = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param('i', $fieldId);
$success = $stmt->execute();
$stmt->close();
return ['success' => $success];
}
// ========================================
// Field Values
// ========================================
/**
* Get all field values for a ticket
*/
public function getValuesForTicket($ticketId)
{
$sql = "SELECT cfv.*, cfd.field_name, cfd.field_label, cfd.field_type, cfd.field_options
FROM custom_field_values cfv
JOIN custom_field_definitions cfd ON cfv.field_id = cfd.field_id
WHERE cfv.ticket_id = ?
ORDER BY cfd.display_order ASC";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param('s', $ticketId);
$stmt->execute();
$result = $stmt->get_result();
$values = [];
while ($row = $result->fetch_assoc()) {
if ($row['field_options']) {
$row['field_options'] = json_decode($row['field_options'], true);
}
$values[$row['field_name']] = $row;
}
$stmt->close();
return $values;
}
/**
* Set a field value for a ticket (insert or update)
*/
public function setValue($ticketId, $fieldId, $value)
{
$sql = "INSERT INTO custom_field_values (ticket_id, field_id, field_value)
VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE field_value = VALUES(field_value), updated_at = CURRENT_TIMESTAMP";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param('sis', $ticketId, $fieldId, $value);
$success = $stmt->execute();
$stmt->close();
return ['success' => $success];
}
/**
* Set multiple field values for a ticket
*/
public function setValues($ticketId, $values)
{
$results = [];
foreach ($values as $fieldId => $value) {
$results[$fieldId] = $this->setValue($ticketId, $fieldId, $value);
}
return $results;
}
/**
* Delete all field values for a ticket
*/
public function deleteValuesForTicket($ticketId)
{
$sql = "DELETE FROM custom_field_values WHERE ticket_id = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param('s', $ticketId);
$success = $stmt->execute();
$stmt->close();
return ['success' => $success];
}
}