Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5709c3134f | ||
|
|
60bafae8a0 | ||
|
|
90d798966b | ||
|
|
442cd1d6f6 | ||
|
|
3664719148 | ||
|
|
d7940b1e31 | ||
|
|
fba251b85d | ||
|
|
fd777aa690 | ||
|
|
a9a39adcf8 |
+10
-3
@@ -177,9 +177,16 @@ try {
|
|||||||
$ticketTitle = $ticket['title'] ?? "Ticket #{$ticketId}";
|
$ticketTitle = $ticket['title'] ?? "Ticket #{$ticketId}";
|
||||||
$ticketVisibility = $ticket['visibility'] ?? 'public';
|
$ticketVisibility = $ticket['visibility'] ?? 'public';
|
||||||
|
|
||||||
// @mention notifications — resolve usernames → Matrix IDs via Synapse Admin API
|
// @mention notifications — resolve usernames → Matrix IDs via Synapse Admin API.
|
||||||
if (!empty($mentionedUsers)) {
|
// Only notify mentioned users who actually have access to this ticket;
|
||||||
$mentionedUsernames = array_column($mentionedUsers, 'username');
|
// otherwise a mention would DM them the ticket's title and comment text
|
||||||
|
// even though canUserAccessTicket() would deny them the ticket itself.
|
||||||
|
$accessibleMentionedUsers = array_filter(
|
||||||
|
$mentionedUsers,
|
||||||
|
fn($u) => $ticketModel->canUserAccessTicket($ticket, $u)
|
||||||
|
);
|
||||||
|
if (!empty($accessibleMentionedUsers)) {
|
||||||
|
$mentionedUsernames = array_column($accessibleMentionedUsers, 'username');
|
||||||
$mentionedMatrixIds = SynapseHelper::resolveUsernames($mentionedUsernames);
|
$mentionedMatrixIds = SynapseHelper::resolveUsernames($mentionedUsernames);
|
||||||
if (!empty($mentionedMatrixIds)) {
|
if (!empty($mentionedMatrixIds)) {
|
||||||
NotificationHelper::sendMentionNotification($ticketId, $ticketTitle, $commentText, $authorDisplay, $mentionedMatrixIds);
|
NotificationHelper::sendMentionNotification($ticketId, $ticketTitle, $commentText, $authorDisplay, $mentionedMatrixIds);
|
||||||
|
|||||||
@@ -76,7 +76,8 @@ if ($assignedTo === null || $assignedTo === '') {
|
|||||||
$ticket['title'] ?? "Ticket #{$ticketId}",
|
$ticket['title'] ?? "Ticket #{$ticketId}",
|
||||||
$assigneeName,
|
$assigneeName,
|
||||||
$assigneeMatrix,
|
$assigneeMatrix,
|
||||||
$changedByDisplay
|
$changedByDisplay,
|
||||||
|
$ticket['visibility'] ?? 'public'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ $statusSql = "SELECT DISTINCT
|
|||||||
COALESCE(u.display_name, u.username, 'System') AS actor_name
|
COALESCE(u.display_name, u.username, 'System') AS actor_name
|
||||||
FROM audit_log al
|
FROM audit_log al
|
||||||
LEFT JOIN users u ON al.user_id = u.user_id
|
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'
|
WHERE al.action_type = 'update'
|
||||||
AND al.entity_type = 'ticket'
|
AND al.entity_type = 'ticket'
|
||||||
AND al.user_id != ?
|
AND al.user_id != ?
|
||||||
|
|||||||
@@ -171,7 +171,8 @@ if ($currentStatus !== $newStatus) {
|
|||||||
$currentStatus,
|
$currentStatus,
|
||||||
$newStatus,
|
$newStatus,
|
||||||
(string)$ticket['title'],
|
(string)$ticket['title'],
|
||||||
$keyName
|
$keyName,
|
||||||
|
$ticket['visibility'] ?? 'public'
|
||||||
);
|
);
|
||||||
NotificationHelper::notifyWatchers(
|
NotificationHelper::notifyWatchers(
|
||||||
$conn,
|
$conn,
|
||||||
|
|||||||
@@ -267,7 +267,8 @@ try {
|
|||||||
$currentTicket['status'],
|
$currentTicket['status'],
|
||||||
$updateData['status'],
|
$updateData['status'],
|
||||||
$updateData['title'],
|
$updateData['title'],
|
||||||
$changedBy
|
$changedBy,
|
||||||
|
$currentTicket['visibility'] ?? 'public'
|
||||||
);
|
);
|
||||||
NotificationHelper::notifyWatchers(
|
NotificationHelper::notifyWatchers(
|
||||||
$this->conn,
|
$this->conn,
|
||||||
|
|||||||
+19
-14
@@ -12,40 +12,43 @@ require_once dirname(__DIR__) . '/models/TicketModel.php';
|
|||||||
|
|
||||||
$data = json_decode(file_get_contents('php://input'), true) ?? [];
|
$data = json_decode(file_get_contents('php://input'), true) ?? [];
|
||||||
|
|
||||||
$ticketId = isset($_GET['ticket_id'])
|
$ticketIdRaw = isset($_GET['ticket_id']) ? $_GET['ticket_id'] : ($data['ticket_id'] ?? '');
|
||||||
? (int)$_GET['ticket_id']
|
|
||||||
: (int)($data['ticket_id'] ?? 0);
|
|
||||||
|
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
$ticketId = (int)($data['ticket_id'] ?? 0);
|
$ticketIdRaw = $data['ticket_id'] ?? '';
|
||||||
$action = $data['action'] ?? '';
|
$action = $data['action'] ?? '';
|
||||||
|
|
||||||
if ($ticketId <= 0 || !in_array($action, ['watch', 'unwatch'], true)) {
|
if ($ticketIdRaw === '' || !in_array($action, ['watch', 'unwatch'], true)) {
|
||||||
http_response_code(400);
|
http_response_code(400);
|
||||||
echo json_encode(['success' => false, 'error' => 'Invalid parameters']);
|
echo json_encode(['success' => false, 'error' => 'Invalid parameters']);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
$ticketModel = new TicketModel($conn);
|
$ticketModel = new TicketModel($conn);
|
||||||
$ticket = $ticketModel->getTicketById($ticketId);
|
$ticket = $ticketModel->getTicketById((string)$ticketIdRaw);
|
||||||
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
|
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
|
||||||
http_response_code(404);
|
http_response_code(404);
|
||||||
echo json_encode(['success' => false, 'error' => 'Ticket not found']);
|
echo json_encode(['success' => false, 'error' => 'Ticket not found']);
|
||||||
exit;
|
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') {
|
if ($action === 'watch') {
|
||||||
$stmt = $conn->prepare(
|
$stmt = $conn->prepare(
|
||||||
"INSERT IGNORE INTO ticket_watchers (ticket_id, user_id) VALUES (?, ?)"
|
"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->execute();
|
||||||
$stmt->close();
|
$stmt->close();
|
||||||
} else {
|
} else {
|
||||||
$stmt = $conn->prepare(
|
$stmt = $conn->prepare(
|
||||||
"DELETE FROM ticket_watchers WHERE ticket_id = ? AND user_id = ?"
|
"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->execute();
|
||||||
$stmt->close();
|
$stmt->close();
|
||||||
}
|
}
|
||||||
@@ -54,7 +57,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
$countStmt = $conn->prepare(
|
$countStmt = $conn->prepare(
|
||||||
"SELECT COUNT(*) as cnt FROM ticket_watchers WHERE ticket_id = ?"
|
"SELECT COUNT(*) as cnt FROM ticket_watchers WHERE ticket_id = ?"
|
||||||
);
|
);
|
||||||
$countStmt->bind_param("i", $ticketId);
|
$countStmt->bind_param("s", $ticketId);
|
||||||
$countStmt->execute();
|
$countStmt->execute();
|
||||||
$count = (int)$countStmt->get_result()->fetch_assoc()['cnt'];
|
$count = (int)$countStmt->get_result()->fetch_assoc()['cnt'];
|
||||||
$countStmt->close();
|
$countStmt->close();
|
||||||
@@ -73,7 +76,7 @@ if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($ticketId <= 0) {
|
if ($ticketIdRaw === '') {
|
||||||
http_response_code(400);
|
http_response_code(400);
|
||||||
echo json_encode(['success' => false, 'error' => 'ticket_id required']);
|
echo json_encode(['success' => false, 'error' => 'ticket_id required']);
|
||||||
exit;
|
exit;
|
||||||
@@ -83,17 +86,19 @@ if ($ticketId <= 0) {
|
|||||||
// restricted ticket's watcher list and count aren't disclosed (the POST path
|
// restricted ticket's watcher list and count aren't disclosed (the POST path
|
||||||
// already checks this).
|
// already checks this).
|
||||||
$ticketModel = new TicketModel($conn);
|
$ticketModel = new TicketModel($conn);
|
||||||
$ticket = $ticketModel->getTicketById($ticketId);
|
$ticket = $ticketModel->getTicketById((string)$ticketIdRaw);
|
||||||
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
|
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
|
||||||
http_response_code(404);
|
http_response_code(404);
|
||||||
echo json_encode(['success' => false, 'error' => 'Ticket not found']);
|
echo json_encode(['success' => false, 'error' => 'Ticket not found']);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$ticketId = $ticket['ticket_id'];
|
||||||
|
|
||||||
$watchingStmt = $conn->prepare(
|
$watchingStmt = $conn->prepare(
|
||||||
"SELECT COUNT(*) as cnt FROM ticket_watchers WHERE ticket_id = ? AND user_id = ?"
|
"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();
|
$watchingStmt->execute();
|
||||||
$watching = (bool)$watchingStmt->get_result()->fetch_assoc()['cnt'];
|
$watching = (bool)$watchingStmt->get_result()->fetch_assoc()['cnt'];
|
||||||
$watchingStmt->close();
|
$watchingStmt->close();
|
||||||
@@ -107,7 +112,7 @@ $watchersStmt = $conn->prepare(
|
|||||||
ORDER BY tw.created_at ASC
|
ORDER BY tw.created_at ASC
|
||||||
LIMIT 6"
|
LIMIT 6"
|
||||||
);
|
);
|
||||||
$watchersStmt->bind_param("i", $ticketId);
|
$watchersStmt->bind_param("s", $ticketId);
|
||||||
$watchersStmt->execute();
|
$watchersStmt->execute();
|
||||||
$watchersResult = $watchersStmt->get_result();
|
$watchersResult = $watchersStmt->get_result();
|
||||||
$watchers = [];
|
$watchers = [];
|
||||||
@@ -118,7 +123,7 @@ $watchersStmt->close();
|
|||||||
|
|
||||||
// True watcher count (the list above is capped at 6 for the avatar group)
|
// 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 = $conn->prepare("SELECT COUNT(*) AS cnt FROM ticket_watchers WHERE ticket_id = ?");
|
||||||
$countStmt->bind_param("i", $ticketId);
|
$countStmt->bind_param("s", $ticketId);
|
||||||
$countStmt->execute();
|
$countStmt->execute();
|
||||||
$count = (int)$countStmt->get_result()->fetch_assoc()['cnt'];
|
$count = (int)$countStmt->get_result()->fetch_assoc()['cnt'];
|
||||||
$countStmt->close();
|
$countStmt->close();
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ header('Content-Type: application/json');
|
|||||||
error_reporting(E_ALL);
|
error_reporting(E_ALL);
|
||||||
ini_set('display_errors', 0);
|
ini_set('display_errors', 0);
|
||||||
|
|
||||||
|
require_once __DIR__ . '/middleware/RateLimitMiddleware.php';
|
||||||
|
RateLimitMiddleware::apply('api');
|
||||||
|
|
||||||
// Load environment variables with error check
|
// Load environment variables with error check
|
||||||
$envFile = __DIR__ . '/.env';
|
$envFile = __DIR__ . '/.env';
|
||||||
if (!file_exists($envFile)) {
|
if (!file_exists($envFile)) {
|
||||||
|
|||||||
@@ -40,20 +40,40 @@ class NotificationHelper
|
|||||||
return array_values(array_filter(array_map('trim', explode(',', $raw))));
|
return array_values(array_filter(array_map('trim', explode(',', $raw))));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redact a ticket title for the shared Matrix notify list when the
|
||||||
|
* ticket isn't public, matching how sendCommentNotification() and
|
||||||
|
* notifyWatchers() already redact comment/activity previews for the
|
||||||
|
* same list.
|
||||||
|
*/
|
||||||
|
private static function redactedTitle(string $title, string $visibility): string
|
||||||
|
{
|
||||||
|
return $visibility === 'public' ? $title : '(restricted ticket — title hidden)';
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Public event methods ─────────────────────────────────────────────────
|
// ─── Public event methods ─────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* New ticket created (manual or automated/API).
|
* New ticket created (manual or automated/API).
|
||||||
|
*
|
||||||
|
* $ticketData['visibility'] ('public', 'internal', or 'confidential') is
|
||||||
|
* used to redact the title sent to the shared MATRIX_NOTIFY_USERS list
|
||||||
|
* for non-public tickets, same as sendCommentNotification()'s preview
|
||||||
|
* redaction. Defaults to 'public' for callers (e.g. the hwmonDaemon
|
||||||
|
* Bearer-API paths) that never set a non-default visibility.
|
||||||
*/
|
*/
|
||||||
public static function sendTicketNotification($ticketId, array $ticketData, string $trigger = 'manual'): void
|
public static function sendTicketNotification($ticketId, array $ticketData, string $trigger = 'manual'): void
|
||||||
{
|
{
|
||||||
preg_match('/^\[([^\]]+)\]/', $ticketData['title'] ?? '', $m);
|
$visibility = $ticketData['visibility'] ?? 'public';
|
||||||
|
$title = $ticketData['title'] ?? 'Untitled';
|
||||||
|
|
||||||
|
preg_match('/^\[([^\]]+)\]/', $title, $m);
|
||||||
$source = $m[1] ?? ($trigger === 'automated' ? 'Automated' : 'Manual');
|
$source = $m[1] ?? ($trigger === 'automated' ? 'Automated' : 'Manual');
|
||||||
|
|
||||||
self::fire([
|
self::fire([
|
||||||
'event' => 'ticket_created',
|
'event' => 'ticket_created',
|
||||||
'ticket_id' => $ticketId,
|
'ticket_id' => $ticketId,
|
||||||
'title' => $ticketData['title'] ?? 'Untitled',
|
'title' => self::redactedTitle($title, $visibility),
|
||||||
'priority' => (int)($ticketData['priority'] ?? 4),
|
'priority' => (int)($ticketData['priority'] ?? 4),
|
||||||
'category' => $ticketData['category'] ?? 'General',
|
'category' => $ticketData['category'] ?? 'General',
|
||||||
'type' => $ticketData['type'] ?? 'Issue',
|
'type' => $ticketData['type'] ?? 'Issue',
|
||||||
@@ -73,13 +93,16 @@ class NotificationHelper
|
|||||||
* @param string $newStatus
|
* @param string $newStatus
|
||||||
* @param string $ticketTitle
|
* @param string $ticketTitle
|
||||||
* @param string|null $changedByDisplay Display name of the user who changed status
|
* @param string|null $changedByDisplay Display name of the user who changed status
|
||||||
|
* @param string $visibility Ticket visibility; non-public titles are
|
||||||
|
* redacted before being sent to the shared
|
||||||
|
* notify list, same as sendTicketNotification().
|
||||||
*/
|
*/
|
||||||
public static function sendStatusChangeNotification($ticketId, string $oldStatus, string $newStatus, string $ticketTitle, ?string $changedByDisplay = null): void
|
public static function sendStatusChangeNotification($ticketId, string $oldStatus, string $newStatus, string $ticketTitle, ?string $changedByDisplay = null, string $visibility = 'public'): void
|
||||||
{
|
{
|
||||||
self::fire([
|
self::fire([
|
||||||
'event' => 'status_changed',
|
'event' => 'status_changed',
|
||||||
'ticket_id' => $ticketId,
|
'ticket_id' => $ticketId,
|
||||||
'title' => $ticketTitle,
|
'title' => self::redactedTitle($ticketTitle, $visibility),
|
||||||
'old_status' => $oldStatus,
|
'old_status' => $oldStatus,
|
||||||
'new_status' => $newStatus,
|
'new_status' => $newStatus,
|
||||||
'changed_by' => $changedByDisplay,
|
'changed_by' => $changedByDisplay,
|
||||||
@@ -166,11 +189,12 @@ class NotificationHelper
|
|||||||
* @param array $extraData Merged into the payload (old_status/new_status, author, etc.)
|
* @param array $extraData Merged into the payload (old_status/new_status, author, etc.)
|
||||||
* @param int|null $excludeUserId Don't notify the actor themselves
|
* @param int|null $excludeUserId Don't notify the actor themselves
|
||||||
* @param string $visibility Ticket visibility: 'public', 'internal', or
|
* @param string $visibility Ticket visibility: 'public', 'internal', or
|
||||||
* 'confidential'. notify_users includes the
|
* 'confidential'. The shared notify list may
|
||||||
* shared list, which may contain users without
|
* contain users without access to non-public
|
||||||
* access to non-public tickets, so any comment
|
* tickets, so for those tickets it's excluded
|
||||||
* body preview in $extraData is redacted for
|
* entirely (only actual watchers are notified)
|
||||||
* non-public tickets.
|
* and both the title and any comment/body
|
||||||
|
* preview in $extraData are redacted.
|
||||||
*/
|
*/
|
||||||
public static function notifyWatchers(\mysqli $conn, $ticketId, string $ticketTitle, string $event, array $extraData = [], ?int $excludeUserId = null, string $visibility = 'public'): void
|
public static function notifyWatchers(\mysqli $conn, $ticketId, string $ticketTitle, string $event, array $extraData = [], ?int $excludeUserId = null, string $visibility = 'public'): void
|
||||||
{
|
{
|
||||||
@@ -204,9 +228,9 @@ class NotificationHelper
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if ($excludeUserId !== null) {
|
if ($excludeUserId !== null) {
|
||||||
$stmt->bind_param("ii", $ticketId, $excludeUserId);
|
$stmt->bind_param("si", $ticketId, $excludeUserId);
|
||||||
} else {
|
} else {
|
||||||
$stmt->bind_param("i", $ticketId);
|
$stmt->bind_param("s", $ticketId);
|
||||||
}
|
}
|
||||||
$stmt->execute();
|
$stmt->execute();
|
||||||
$result = $stmt->get_result();
|
$result = $stmt->get_result();
|
||||||
@@ -230,13 +254,17 @@ class NotificationHelper
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove the global notify list duplicates and build payload
|
// The shared notify list may include users without access to
|
||||||
$allNotify = array_unique(array_merge($matrixIds, self::notifyUsers()));
|
// non-public tickets, so only mix it in for public tickets — for
|
||||||
|
// internal/confidential tickets, notify actual watchers only.
|
||||||
|
$allNotify = $visibility === 'public'
|
||||||
|
? array_unique(array_merge($matrixIds, self::notifyUsers()))
|
||||||
|
: $matrixIds;
|
||||||
|
|
||||||
$payload = array_merge($extraData, [
|
$payload = array_merge($extraData, [
|
||||||
'event' => $event,
|
'event' => $event,
|
||||||
'ticket_id' => $ticketId,
|
'ticket_id' => $ticketId,
|
||||||
'title' => $ticketTitle,
|
'title' => self::redactedTitle($ticketTitle, $visibility),
|
||||||
'url' => UrlHelper::ticketUrl($ticketId),
|
'url' => UrlHelper::ticketUrl($ticketId),
|
||||||
'notify_users' => array_values($allNotify),
|
'notify_users' => array_values($allNotify),
|
||||||
]);
|
]);
|
||||||
@@ -252,8 +280,14 @@ class NotificationHelper
|
|||||||
* @param string|null $assigneeName Display name of new assignee
|
* @param string|null $assigneeName Display name of new assignee
|
||||||
* @param string|null $assigneeMatrix Matrix user ID of new assignee (to DM)
|
* @param string|null $assigneeMatrix Matrix user ID of new assignee (to DM)
|
||||||
* @param string|null $changedByDisplay
|
* @param string|null $changedByDisplay
|
||||||
|
* @param string $visibility Ticket visibility; non-public titles are
|
||||||
|
* redacted before being sent to the shared
|
||||||
|
* notify list, same as sendTicketNotification().
|
||||||
|
* The assignee is DMed directly regardless,
|
||||||
|
* since they now have standing access to the
|
||||||
|
* ticket by virtue of being assigned to it.
|
||||||
*/
|
*/
|
||||||
public static function sendAssignmentNotification($ticketId, string $ticketTitle, ?string $assigneeName, ?string $assigneeMatrix, ?string $changedByDisplay = null): void
|
public static function sendAssignmentNotification($ticketId, string $ticketTitle, ?string $assigneeName, ?string $assigneeMatrix, ?string $changedByDisplay = null, string $visibility = 'public'): void
|
||||||
{
|
{
|
||||||
$notifyUsers = self::notifyUsers();
|
$notifyUsers = self::notifyUsers();
|
||||||
// Also notify the assignee directly if we know their Matrix ID
|
// Also notify the assignee directly if we know their Matrix ID
|
||||||
@@ -267,7 +301,7 @@ class NotificationHelper
|
|||||||
self::fire([
|
self::fire([
|
||||||
'event' => 'assigned',
|
'event' => 'assigned',
|
||||||
'ticket_id' => $ticketId,
|
'ticket_id' => $ticketId,
|
||||||
'title' => $ticketTitle,
|
'title' => self::redactedTitle($ticketTitle, $visibility),
|
||||||
'assignee' => $assigneeName,
|
'assignee' => $assigneeName,
|
||||||
'changed_by' => $changedByDisplay,
|
'changed_by' => $changedByDisplay,
|
||||||
'url' => UrlHelper::ticketUrl($ticketId),
|
'url' => UrlHelper::ticketUrl($ticketId),
|
||||||
|
|||||||
@@ -243,11 +243,12 @@ CREATE TABLE IF NOT EXISTS `ticket_templates` (
|
|||||||
|
|
||||||
-- ============ ticket_watchers ============
|
-- ============ ticket_watchers ============
|
||||||
CREATE TABLE IF NOT EXISTS `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,
|
`user_id` int(11) NOT NULL,
|
||||||
`created_at` timestamp NOT NULL DEFAULT current_timestamp(),
|
`created_at` timestamp NOT NULL DEFAULT current_timestamp(),
|
||||||
PRIMARY KEY (`ticket_id`,`user_id`),
|
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;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||||
|
|
||||||
-- ============ tickets ============
|
-- ============ 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;
|
||||||
+68
-11
@@ -46,6 +46,23 @@ if (!$conn->query($createTable)) {
|
|||||||
exit(1);
|
exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tracks per-statement progress within a migration file. MySQL DDL statements
|
||||||
|
// (ALTER/CREATE TABLE, etc.) cause an implicit commit, so begin_transaction()/
|
||||||
|
// rollback() around a whole file can't actually undo DDL already executed
|
||||||
|
// earlier in that same file. This table lets a re-run after a partial failure
|
||||||
|
// resume from the statement after the last one that succeeded, instead of
|
||||||
|
// re-executing already-applied DDL and wedging on "already exists" errors.
|
||||||
|
$createProgressTable = "CREATE TABLE IF NOT EXISTS migration_progress (
|
||||||
|
filename VARCHAR(255) NOT NULL PRIMARY KEY,
|
||||||
|
last_statement_index INT NOT NULL,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||||
|
)";
|
||||||
|
|
||||||
|
if (!$conn->query($createProgressTable)) {
|
||||||
|
echo "Error: Could not create migration_progress table: " . $conn->error . "\n";
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
// Get list of completed migrations
|
// Get list of completed migrations
|
||||||
$completed = [];
|
$completed = [];
|
||||||
$result = $conn->query("SELECT filename FROM migrations ORDER BY id");
|
$result = $conn->query("SELECT filename FROM migrations ORDER BY id");
|
||||||
@@ -114,47 +131,87 @@ foreach ($pending as $file) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Execute migration - handle multiple statements
|
// Execute migration statement-by-statement, tracking progress as we go.
|
||||||
$conn->begin_transaction();
|
// No begin_transaction()/rollback() here: DDL statements auto-commit in
|
||||||
|
// MySQL/MariaDB regardless, so a transaction wrapper around the whole
|
||||||
|
// file would only create the illusion of atomicity while giving no real
|
||||||
|
// protection. Instead, each statement commits immediately (autocommit),
|
||||||
|
// and its index is durably recorded so a later re-run can resume exactly
|
||||||
|
// where a previous run left off rather than re-executing already-applied
|
||||||
|
// DDL.
|
||||||
try {
|
try {
|
||||||
// Split by semicolon but respect statements properly
|
// Split by semicolon but respect statements properly
|
||||||
// Note: This doesn't handle semicolons in strings, but our migrations are simple
|
// Note: This doesn't handle semicolons in strings, but our migrations are simple
|
||||||
$statements = array_filter(
|
$statements = array_values(array_filter(
|
||||||
array_map('trim', explode(';', $sql)),
|
array_map('trim', explode(';', $sql)),
|
||||||
function($stmt) {
|
function($stmt) {
|
||||||
// Remove comments and check if there's actual SQL
|
// Remove comments and check if there's actual SQL
|
||||||
$cleaned = preg_replace('/--.*$/m', '', $stmt);
|
$cleaned = preg_replace('/--.*$/m', '', $stmt);
|
||||||
return !empty(trim($cleaned));
|
return !empty(trim($cleaned));
|
||||||
}
|
}
|
||||||
);
|
));
|
||||||
|
|
||||||
|
$resumeFrom = 0;
|
||||||
|
$progressStmt = $conn->prepare(
|
||||||
|
"SELECT last_statement_index FROM migration_progress WHERE filename = ?"
|
||||||
|
);
|
||||||
|
$progressStmt->bind_param('s', $filename);
|
||||||
|
$progressStmt->execute();
|
||||||
|
$progressRow = $progressStmt->get_result()->fetch_assoc();
|
||||||
|
$progressStmt->close();
|
||||||
|
if ($progressRow) {
|
||||||
|
$resumeFrom = (int)$progressRow['last_statement_index'] + 1;
|
||||||
|
echo "\n Resuming from statement " . ($resumeFrom + 1) . " of " . count($statements)
|
||||||
|
. " after a previous partial failure... ";
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($statements as $index => $statement) {
|
||||||
|
if ($index < $resumeFrom) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
foreach ($statements as $statement) {
|
|
||||||
if (!$conn->query($statement)) {
|
if (!$conn->query($statement)) {
|
||||||
// Some "errors" are acceptable (like "index already exists")
|
// Some "errors" are acceptable (like "index already exists")
|
||||||
$error = $conn->error;
|
$error = $conn->error;
|
||||||
if (strpos($error, 'Duplicate key name') !== false ||
|
if (strpos($error, 'Duplicate key name') !== false ||
|
||||||
strpos($error, 'already exists') !== false) {
|
strpos($error, 'already exists') !== false) {
|
||||||
// Index already exists, that's fine
|
// Index already exists, that's fine
|
||||||
continue;
|
} else {
|
||||||
}
|
|
||||||
throw new Exception($error);
|
throw new Exception($error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Record the migration
|
// Record progress after every statement so a later run can
|
||||||
|
// resume from here even if a subsequent statement fails.
|
||||||
|
$upsert = $conn->prepare(
|
||||||
|
"INSERT INTO migration_progress (filename, last_statement_index) VALUES (?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE last_statement_index = VALUES(last_statement_index)"
|
||||||
|
);
|
||||||
|
$upsert->bind_param('si', $filename, $index);
|
||||||
|
$upsert->execute();
|
||||||
|
$upsert->close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record the migration as fully complete and clear its progress marker
|
||||||
$stmt = $conn->prepare("INSERT INTO migrations (filename) VALUES (?)");
|
$stmt = $conn->prepare("INSERT INTO migrations (filename) VALUES (?)");
|
||||||
$stmt->bind_param('s', $filename);
|
$stmt->bind_param('s', $filename);
|
||||||
if (!$stmt->execute()) {
|
if (!$stmt->execute()) {
|
||||||
throw new Exception("Could not record migration: " . $conn->error);
|
throw new Exception("Could not record migration: " . $conn->error);
|
||||||
}
|
}
|
||||||
|
|
||||||
$conn->commit();
|
$clearProgress = $conn->prepare("DELETE FROM migration_progress WHERE filename = ?");
|
||||||
|
$clearProgress->bind_param('s', $filename);
|
||||||
|
$clearProgress->execute();
|
||||||
|
$clearProgress->close();
|
||||||
|
|
||||||
echo "OK\n";
|
echo "OK\n";
|
||||||
$success++;
|
$success++;
|
||||||
|
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
$conn->rollback();
|
// Nothing to roll back: every statement up to the failure already
|
||||||
|
// committed (DDL implicitly, everything else via autocommit). The
|
||||||
|
// progress marker recorded above reflects exactly how far this file
|
||||||
|
// got, so the next run will resume right after the last success.
|
||||||
echo "FAILED (" . $e->getMessage() . ")\n";
|
echo "FAILED (" . $e->getMessage() . ")\n";
|
||||||
$failed++;
|
$failed++;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ class CommentModel
|
|||||||
}
|
}
|
||||||
|
|
||||||
$placeholders = str_repeat('?,', count($usernames) - 1) . '?';
|
$placeholders = str_repeat('?,', count($usernames) - 1) . '?';
|
||||||
$sql = "SELECT user_id, username, display_name FROM users WHERE username IN ($placeholders)";
|
$sql = "SELECT user_id, username, display_name, is_admin, `groups` FROM users WHERE username IN ($placeholders)";
|
||||||
$stmt = $this->conn->prepare($sql);
|
$stmt = $this->conn->prepare($sql);
|
||||||
|
|
||||||
$types = str_repeat('s', count($usernames));
|
$types = str_repeat('s', count($usernames));
|
||||||
|
|||||||
@@ -726,7 +726,10 @@ class TicketModel
|
|||||||
$groupConditions = [];
|
$groupConditions = [];
|
||||||
foreach ($userGroups as $group) {
|
foreach ($userGroups as $group) {
|
||||||
$groupConditions[] = "FIND_IN_SET(?, REPLACE(t.visibility_groups, ' ', ''))";
|
$groupConditions[] = "FIND_IN_SET(?, REPLACE(t.visibility_groups, ' ', ''))";
|
||||||
$params[] = $group;
|
// Strip spaces from the bound value too, matching the REPLACE()
|
||||||
|
// applied to the column, so a group name like "IT Support" is
|
||||||
|
// normalized the same way on both sides of the comparison.
|
||||||
|
$params[] = str_replace(' ', '', $group);
|
||||||
$types .= 's';
|
$types .= 's';
|
||||||
}
|
}
|
||||||
$conditions[] = "(t.visibility = 'internal' AND (" . implode(' OR ', $groupConditions) . "))";
|
$conditions[] = "(t.visibility = 'internal' AND (" . implode(' OR ', $groupConditions) . "))";
|
||||||
|
|||||||
Reference in New Issue
Block a user