Fix data-layer bugs: bind_param fatals, ticket_id bindings, cache poisoning
- CustomFieldModel: assign ?? fallbacks to variables before bind_param
(by-reference args cannot be ?? expressions; fatal on PHP 8.2, custom
fields were uncreatable/uneditable)
- RecurringTicketModel::create: fix swapped bind type for schedule_type
(enum bound as int coerced 'daily' to 0, breaking the cron)
- TicketModel/CommentModel: bind varchar ticket_id as string not int so
the unique index is usable and leading-zero IDs match; ticket_watchers
(int column) left as integer
- TicketModel::deleteTicket: delete from custom_field_values (real table)
not the nonexistent ticket_custom_fields
- TicketModel search: honor literal '0'; never emit AGAINST('*') on
all-special-char input (fall back to LIKE)
- TicketModel::updateTicket: disambiguate not-found vs no-op vs genuine
optimistic-lock conflict on zero affected rows
- WorkflowModel: do not cache transitions/statuses on DB failure (a
transient error no longer blocks all status changes for the TTL)
- DependencyModel: filter linked tickets by visibility (new optional user
context params) to stop confidential metadata leaking via dependencies
- BulkOperationsModel: validate status/priority/assignee before mutating
- AuditLogModel: gate getClientIP forwarded headers on trusted proxies;
add missing action/entity types so audit-log filters work
- WorkflowModel: add transitionRequiresComment() accessor for enforcement
- CommentModel: stop leaking raw DB errors to clients (log instead)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+66
-37
@@ -26,31 +26,38 @@ class WorkflowModel
|
||||
*/
|
||||
private function getAllTransitions(): array
|
||||
{
|
||||
return CacheHelper::remember(self::$CACHE_PREFIX, 'all_transitions', function () {
|
||||
$sql = "SELECT from_status, to_status, requires_comment, requires_admin
|
||||
FROM status_transitions
|
||||
WHERE is_active = TRUE";
|
||||
$result = $this->conn->query($sql);
|
||||
$cached = CacheHelper::get(self::$CACHE_PREFIX, 'all_transitions', self::$CACHE_TTL);
|
||||
if ($cached !== null) {
|
||||
return $cached;
|
||||
}
|
||||
|
||||
if (!$result) {
|
||||
return [];
|
||||
$sql = "SELECT from_status, to_status, requires_comment, requires_admin
|
||||
FROM status_transitions
|
||||
WHERE is_active = TRUE";
|
||||
$result = $this->conn->query($sql);
|
||||
|
||||
if (!$result) {
|
||||
// A transient DB failure must NOT be cached as "no transitions" — that
|
||||
// would block every status change for the whole TTL. Fail safe by
|
||||
// returning empty without storing it, so the next call retries.
|
||||
return [];
|
||||
}
|
||||
|
||||
$transitions = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$from = $row['from_status'];
|
||||
if (!isset($transitions[$from])) {
|
||||
$transitions[$from] = [];
|
||||
}
|
||||
$transitions[$from][$row['to_status']] = [
|
||||
'to_status' => $row['to_status'],
|
||||
'requires_comment' => (bool)$row['requires_comment'],
|
||||
'requires_admin' => (bool)$row['requires_admin']
|
||||
];
|
||||
}
|
||||
|
||||
$transitions = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$from = $row['from_status'];
|
||||
if (!isset($transitions[$from])) {
|
||||
$transitions[$from] = [];
|
||||
}
|
||||
$transitions[$from][$row['to_status']] = [
|
||||
'to_status' => $row['to_status'],
|
||||
'requires_comment' => (bool)$row['requires_comment'],
|
||||
'requires_admin' => (bool)$row['requires_admin']
|
||||
];
|
||||
}
|
||||
|
||||
return $transitions;
|
||||
}, self::$CACHE_TTL);
|
||||
CacheHelper::set(self::$CACHE_PREFIX, 'all_transitions', $transitions);
|
||||
return $transitions;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -107,24 +114,29 @@ class WorkflowModel
|
||||
*/
|
||||
public function getAllStatuses(): array
|
||||
{
|
||||
return CacheHelper::remember(self::$CACHE_PREFIX, 'all_statuses', function () {
|
||||
$sql = "SELECT DISTINCT from_status as status FROM status_transitions
|
||||
UNION
|
||||
SELECT DISTINCT to_status as status FROM status_transitions
|
||||
ORDER BY status";
|
||||
$result = $this->conn->query($sql);
|
||||
$cached = CacheHelper::get(self::$CACHE_PREFIX, 'all_statuses', self::$CACHE_TTL);
|
||||
if ($cached !== null) {
|
||||
return $cached;
|
||||
}
|
||||
|
||||
if (!$result) {
|
||||
return [];
|
||||
}
|
||||
$sql = "SELECT DISTINCT from_status as status FROM status_transitions
|
||||
UNION
|
||||
SELECT DISTINCT to_status as status FROM status_transitions
|
||||
ORDER BY status";
|
||||
$result = $this->conn->query($sql);
|
||||
|
||||
$statuses = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$statuses[] = $row['status'];
|
||||
}
|
||||
if (!$result) {
|
||||
// Do not cache an empty list on a transient DB failure.
|
||||
return [];
|
||||
}
|
||||
|
||||
return $statuses;
|
||||
}, self::$CACHE_TTL);
|
||||
$statuses = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$statuses[] = $row['status'];
|
||||
}
|
||||
|
||||
CacheHelper::set(self::$CACHE_PREFIX, 'all_statuses', $statuses);
|
||||
return $statuses;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -149,6 +161,23 @@ class WorkflowModel
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a given transition requires a comment.
|
||||
*
|
||||
* Convenience accessor so callers (e.g. the update-ticket endpoint) can
|
||||
* enforce requires_comment server-side without inspecting the full row.
|
||||
* Returns false for an undefined transition or a no-op (same status).
|
||||
*
|
||||
* @param string $fromStatus Current status
|
||||
* @param string $toStatus Desired status
|
||||
* @return bool True if the transition requires a comment
|
||||
*/
|
||||
public function transitionRequiresComment(string $fromStatus, string $toStatus): bool
|
||||
{
|
||||
$requirements = $this->getTransitionRequirements($fromStatus, $toStatus);
|
||||
return $requirements !== null && !empty($requirements['requires_comment']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear workflow cache (call when transitions are modified)
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user