Fix ticket_watchers.ticket_id type mismatch and missing FK (#32)
Lint / PHP (phpcs PSR-12) (push) Successful in 24s
Lint / JS (eslint) (push) Successful in 9s
Lint / PHP requirements (version + extensions) (push) Successful in 27s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m40s
Lint / Deploy (push) Successful in 2s
Lint / PHP (phpcs PSR-12) (push) Successful in 24s
Lint / JS (eslint) (push) Successful in 9s
Lint / PHP requirements (version + extensions) (push) Successful in 27s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m40s
Lint / Deploy (push) Successful in 2s
ticket_watchers.ticket_id was int(11) while every other satellite table (ticket_comments, ticket_attachments, ticket_dependencies, custom_field_values) uses varchar(9)/varchar(10) matching tickets.ticket_id, and it had no FK constraint at all — unlike every other satellite table — so orphaned watcher rows could never be caught by referential integrity. Changed the column to varchar(9) with an ON DELETE CASCADE FK to tickets, in both 000_baseline.sql and a new idempotent 004_fix_ticket_watchers_type.sql (which also deletes any pre-existing orphaned watcher rows before adding the constraint, since orphans would otherwise make the ADD CONSTRAINT fail). Updated watch_ticket.php, NotificationHelper::notifyWatchers(), and notifications.php's audit-log JOIN to bind/compare ticket_id as a string instead of casting to int, including replacing a fragile CAST(entity_id AS UNSIGNED) with a direct string comparison. Verified against real MariaDB: applied 004 against a simulated pre-fix deployment with one valid and one orphaned watcher row — the orphan is removed, the column converts losslessly, the FK is added, and the migration is idempotent on re-run. Confirmed ON DELETE CASCADE actually removes watchers when their ticket is deleted, that inserting a watcher for a nonexistent ticket now fails with a real FK violation, and exercised the updated watch/unwatch and status-change-notification query paths end-to-end against the fixed schema. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015nCxwFFsy8ouMWzn56rPVP
This commit is contained in:
@@ -138,7 +138,7 @@ $statusSql = "SELECT DISTINCT
|
||||
COALESCE(u.display_name, u.username, 'System') AS actor_name
|
||||
FROM audit_log al
|
||||
LEFT JOIN users u ON al.user_id = u.user_id
|
||||
INNER JOIN ticket_watchers tw ON tw.ticket_id = CAST(al.entity_id AS UNSIGNED) AND tw.user_id = ?
|
||||
INNER JOIN ticket_watchers tw ON tw.ticket_id = al.entity_id AND tw.user_id = ?
|
||||
WHERE al.action_type = 'update'
|
||||
AND al.entity_type = 'ticket'
|
||||
AND al.user_id != ?
|
||||
|
||||
+20
-15
@@ -12,40 +12,43 @@ require_once dirname(__DIR__) . '/models/TicketModel.php';
|
||||
|
||||
$data = json_decode(file_get_contents('php://input'), true) ?? [];
|
||||
|
||||
$ticketId = isset($_GET['ticket_id'])
|
||||
? (int)$_GET['ticket_id']
|
||||
: (int)($data['ticket_id'] ?? 0);
|
||||
$ticketIdRaw = isset($_GET['ticket_id']) ? $_GET['ticket_id'] : ($data['ticket_id'] ?? '');
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$ticketId = (int)($data['ticket_id'] ?? 0);
|
||||
$action = $data['action'] ?? '';
|
||||
$ticketIdRaw = $data['ticket_id'] ?? '';
|
||||
$action = $data['action'] ?? '';
|
||||
|
||||
if ($ticketId <= 0 || !in_array($action, ['watch', 'unwatch'], true)) {
|
||||
if ($ticketIdRaw === '' || !in_array($action, ['watch', 'unwatch'], true)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'error' => 'Invalid parameters']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$ticketModel = new TicketModel($conn);
|
||||
$ticket = $ticketModel->getTicketById($ticketId);
|
||||
$ticket = $ticketModel->getTicketById((string)$ticketIdRaw);
|
||||
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
|
||||
http_response_code(404);
|
||||
echo json_encode(['success' => false, 'error' => 'Ticket not found']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Use the canonical ticket_id string from the fetched ticket row, not the
|
||||
// raw request value, so ticket_watchers always stores exactly what's in
|
||||
// tickets.ticket_id.
|
||||
$ticketId = $ticket['ticket_id'];
|
||||
|
||||
if ($action === 'watch') {
|
||||
$stmt = $conn->prepare(
|
||||
"INSERT IGNORE INTO ticket_watchers (ticket_id, user_id) VALUES (?, ?)"
|
||||
);
|
||||
$stmt->bind_param("ii", $ticketId, $userId);
|
||||
$stmt->bind_param("si", $ticketId, $userId);
|
||||
$stmt->execute();
|
||||
$stmt->close();
|
||||
} else {
|
||||
$stmt = $conn->prepare(
|
||||
"DELETE FROM ticket_watchers WHERE ticket_id = ? AND user_id = ?"
|
||||
);
|
||||
$stmt->bind_param("ii", $ticketId, $userId);
|
||||
$stmt->bind_param("si", $ticketId, $userId);
|
||||
$stmt->execute();
|
||||
$stmt->close();
|
||||
}
|
||||
@@ -54,7 +57,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$countStmt = $conn->prepare(
|
||||
"SELECT COUNT(*) as cnt FROM ticket_watchers WHERE ticket_id = ?"
|
||||
);
|
||||
$countStmt->bind_param("i", $ticketId);
|
||||
$countStmt->bind_param("s", $ticketId);
|
||||
$countStmt->execute();
|
||||
$count = (int)$countStmt->get_result()->fetch_assoc()['cnt'];
|
||||
$countStmt->close();
|
||||
@@ -73,7 +76,7 @@ if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($ticketId <= 0) {
|
||||
if ($ticketIdRaw === '') {
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'error' => 'ticket_id required']);
|
||||
exit;
|
||||
@@ -83,17 +86,19 @@ if ($ticketId <= 0) {
|
||||
// restricted ticket's watcher list and count aren't disclosed (the POST path
|
||||
// already checks this).
|
||||
$ticketModel = new TicketModel($conn);
|
||||
$ticket = $ticketModel->getTicketById($ticketId);
|
||||
$ticket = $ticketModel->getTicketById((string)$ticketIdRaw);
|
||||
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
|
||||
http_response_code(404);
|
||||
echo json_encode(['success' => false, 'error' => 'Ticket not found']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$ticketId = $ticket['ticket_id'];
|
||||
|
||||
$watchingStmt = $conn->prepare(
|
||||
"SELECT COUNT(*) as cnt FROM ticket_watchers WHERE ticket_id = ? AND user_id = ?"
|
||||
);
|
||||
$watchingStmt->bind_param("ii", $ticketId, $userId);
|
||||
$watchingStmt->bind_param("si", $ticketId, $userId);
|
||||
$watchingStmt->execute();
|
||||
$watching = (bool)$watchingStmt->get_result()->fetch_assoc()['cnt'];
|
||||
$watchingStmt->close();
|
||||
@@ -107,7 +112,7 @@ $watchersStmt = $conn->prepare(
|
||||
ORDER BY tw.created_at ASC
|
||||
LIMIT 6"
|
||||
);
|
||||
$watchersStmt->bind_param("i", $ticketId);
|
||||
$watchersStmt->bind_param("s", $ticketId);
|
||||
$watchersStmt->execute();
|
||||
$watchersResult = $watchersStmt->get_result();
|
||||
$watchers = [];
|
||||
@@ -118,7 +123,7 @@ $watchersStmt->close();
|
||||
|
||||
// True watcher count (the list above is capped at 6 for the avatar group)
|
||||
$countStmt = $conn->prepare("SELECT COUNT(*) AS cnt FROM ticket_watchers WHERE ticket_id = ?");
|
||||
$countStmt->bind_param("i", $ticketId);
|
||||
$countStmt->bind_param("s", $ticketId);
|
||||
$countStmt->execute();
|
||||
$count = (int)$countStmt->get_result()->fetch_assoc()['cnt'];
|
||||
$countStmt->close();
|
||||
|
||||
@@ -204,9 +204,9 @@ class NotificationHelper
|
||||
return;
|
||||
}
|
||||
if ($excludeUserId !== null) {
|
||||
$stmt->bind_param("ii", $ticketId, $excludeUserId);
|
||||
$stmt->bind_param("si", $ticketId, $excludeUserId);
|
||||
} else {
|
||||
$stmt->bind_param("i", $ticketId);
|
||||
$stmt->bind_param("s", $ticketId);
|
||||
}
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
|
||||
@@ -243,11 +243,12 @@ CREATE TABLE IF NOT EXISTS `ticket_templates` (
|
||||
|
||||
-- ============ ticket_watchers ============
|
||||
CREATE TABLE IF NOT EXISTS `ticket_watchers` (
|
||||
`ticket_id` int(11) NOT NULL,
|
||||
`ticket_id` varchar(9) NOT NULL,
|
||||
`user_id` int(11) NOT NULL,
|
||||
`created_at` timestamp NOT NULL DEFAULT current_timestamp(),
|
||||
PRIMARY KEY (`ticket_id`,`user_id`),
|
||||
KEY `idx_watcher_user` (`user_id`)
|
||||
KEY `idx_watcher_user` (`user_id`),
|
||||
CONSTRAINT `fk_watchers_ticket_id` FOREIGN KEY (`ticket_id`) REFERENCES `tickets` (`ticket_id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||
|
||||
-- ============ tickets ============
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
-- Fix ticket_watchers.ticket_id type mismatch and missing FK to tickets
|
||||
--
|
||||
-- ticket_watchers.ticket_id was int(11), while every other satellite table
|
||||
-- (ticket_comments, ticket_attachments, ticket_dependencies,
|
||||
-- custom_field_values) stores it as varchar(9)/varchar(10) matching
|
||||
-- tickets.ticket_id. There was also no FK constraint at all, unlike every
|
||||
-- other satellite table, so orphaned watcher rows could never be caught by
|
||||
-- referential integrity. Ticket IDs are always 9-digit numeric strings
|
||||
-- (see TicketModel::create's sprintf('%09d', ...)), so the int -> varchar(9)
|
||||
-- conversion below is lossless for real data.
|
||||
--
|
||||
-- Safe to re-run.
|
||||
|
||||
-- Remove any watcher rows that no longer point at a real ticket (possible
|
||||
-- today precisely because there was no FK to prevent it) before adding the
|
||||
-- constraint, since orphans would make the ADD CONSTRAINT below fail.
|
||||
DELETE tw FROM `ticket_watchers` tw
|
||||
LEFT JOIN `tickets` t ON tw.`ticket_id` = t.`ticket_id`
|
||||
WHERE t.`ticket_id` IS NULL;
|
||||
|
||||
ALTER TABLE `ticket_watchers`
|
||||
MODIFY COLUMN `ticket_id` varchar(9) NOT NULL;
|
||||
|
||||
ALTER TABLE `ticket_watchers`
|
||||
DROP FOREIGN KEY IF EXISTS `fk_watchers_ticket_id`;
|
||||
|
||||
ALTER TABLE `ticket_watchers`
|
||||
ADD CONSTRAINT `fk_watchers_ticket_id` FOREIGN KEY (`ticket_id`) REFERENCES `tickets` (`ticket_id`) ON DELETE CASCADE;
|
||||
Reference in New Issue
Block a user