Lint / PHP (phpcs PSR-12) (push) Successful in 21s
Lint / JS (eslint) (push) Successful in 12s
Lint / PHP requirements (version + extensions) (push) Successful in 40s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m41s
Lint / Deploy (push) Successful in 2s
Two concurrent hwmonDaemon reports carrying the same dedup hash could both read the same pre-update ticket snapshot and each independently apply a priority escalation (losing one), or both attempt to INSERT a new ticket for a hash that didn't exist yet and have the loser's request dropped with a "Duplicate ticket" error instead of falling through to the update/escalate path. Wrap the hash lookup through the update-or-insert in one transaction, with the lookup taking SELECT ... FOR UPDATE. For an existing row this serializes the read-modify-write so a second request observes the first's committed state. For a not-yet-existing hash, InnoDB's gap lock there is shared rather than exclusive, so two concurrent inserts can both reach the INSERT and deadlock (1213) instead of one blocking cleanly on the other's row; retry the whole lookup once on that deadlock (or a lock-wait-timeout, 1205) so the retry's own SELECT finds the winner's committed row and takes the update path instead of erroring. Verified against real MariaDB with two concurrent OS processes for both scenarios: (1) same existing active ticket — the second process blocked ~1.1s on the first's held row lock, then correctly escalated from the first's committed priority rather than a stale value; (2) same not-yet-existing hash — reproduced the 1213 deadlock deterministically across 5/5 runs with the original code, then confirmed the retry resolves it every time (5/5), leaving exactly one ticket row created and no dropped/erroring request. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0117oBw2jN4kALYeS8HPq4zV
585 lines
25 KiB
PHP
585 lines
25 KiB
PHP
<?php
|
|
|
|
header('Content-Type: application/json');
|
|
|
|
require_once __DIR__ . '/helpers/ErrorHandler.php';
|
|
ErrorHandler::init();
|
|
|
|
require_once __DIR__ . '/middleware/RateLimitMiddleware.php';
|
|
RateLimitMiddleware::apply('api');
|
|
|
|
// Early friendly JSON error if .env is missing, before config.php's own
|
|
// (plain-text die()) handling would otherwise run — this is a JSON API
|
|
// endpoint and must always respond with a JSON body.
|
|
if (!file_exists(__DIR__ . '/.env')) {
|
|
echo json_encode([
|
|
'success' => false,
|
|
'error' => 'Configuration file not found'
|
|
]);
|
|
exit;
|
|
}
|
|
|
|
// Load application config so UrlHelper can resolve APP_DOMAIN, and so the
|
|
// DB connection below (via Database::getConnection()) gets the same
|
|
// charset/timezone sync as every other endpoint instead of a hand-rolled
|
|
// second connection.
|
|
require_once __DIR__ . '/config/config.php';
|
|
require_once __DIR__ . '/helpers/Database.php';
|
|
|
|
try {
|
|
$conn = Database::getConnection();
|
|
} catch (\Throwable $e) {
|
|
error_log('create_ticket_api: DB connection failed: ' . $e->getMessage());
|
|
http_response_code(500);
|
|
echo json_encode([
|
|
'success' => false,
|
|
'error' => 'Internal server error'
|
|
]);
|
|
exit;
|
|
}
|
|
|
|
// Authenticate via API key
|
|
require_once __DIR__ . '/middleware/ApiKeyAuth.php';
|
|
require_once __DIR__ . '/models/AuditLogModel.php';
|
|
require_once __DIR__ . '/models/StatsModel.php';
|
|
require_once __DIR__ . '/models/TicketModel.php';
|
|
require_once __DIR__ . '/models/WorkflowModel.php';
|
|
require_once __DIR__ . '/helpers/UrlHelper.php';
|
|
|
|
$apiKeyAuth = new ApiKeyAuth($conn);
|
|
|
|
try {
|
|
$systemUser = $apiKeyAuth->authenticate();
|
|
} catch (Exception $e) {
|
|
// Authentication failed - ApiKeyAuth already sent the response
|
|
exit;
|
|
}
|
|
|
|
// Ticket creation is a write — a read-only key must be rejected with 403.
|
|
$apiKeyAuth->requireScope('read_write');
|
|
|
|
$userId = $systemUser['user_id'];
|
|
|
|
// Parse input regardless of content-type header
|
|
$rawInput = file_get_contents('php://input');
|
|
$data = json_decode($rawInput, true);
|
|
|
|
// Validate required fields before any processing
|
|
if (!is_array($data) || empty($data['title'])) {
|
|
// Try URL-encoded fallback
|
|
if (empty($data['title'])) {
|
|
parse_str($rawInput, $urlData);
|
|
if (!empty($urlData['title'])) {
|
|
$data = $urlData;
|
|
}
|
|
}
|
|
if (!is_array($data) || empty($data['title'])) {
|
|
http_response_code(400);
|
|
echo json_encode(['success' => false, 'error' => 'title is required']);
|
|
exit;
|
|
}
|
|
}
|
|
|
|
// Generate hash from stable components
|
|
function generateTicketHash($data)
|
|
{
|
|
$title = (string)($data['title'] ?? '');
|
|
|
|
// Prefer explicit serial from payload; fall back to extracting device path from title
|
|
// for backwards compatibility with older hwmonDaemon versions.
|
|
$serial = isset($data['serial']) && $data['serial'] !== null && $data['serial'] !== ''
|
|
? (string)$data['serial']
|
|
: null;
|
|
|
|
// Extract device name if present (matches /dev/sdX, /dev/nvmeXnY patterns)
|
|
preg_match('/\/dev\/(sd[a-z]+|nvme\d+n\d+)/', $title, $deviceMatches);
|
|
$isDriveTicket = !empty($deviceMatches) || $serial !== null;
|
|
|
|
// Extract first bracketed tag as hostname/source
|
|
preg_match('/^\[([^\]]+)\]/', $title, $hostMatches);
|
|
$hostname = $hostMatches[1] ?? '';
|
|
|
|
// Detect issue category and optional sub-type
|
|
$issueCategory = '';
|
|
$issueSubtype = '';
|
|
$isClusterWide = false;
|
|
|
|
if (stripos($title, 'SMART issues') !== false) {
|
|
$issueCategory = 'smart';
|
|
} elseif (stripos($title, 'ZFS pool') !== false) {
|
|
$issueCategory = 'zfs';
|
|
// Extract pool name so each pool gets its own ticket
|
|
if (preg_match("/ZFS pool '([^']+)'/i", $title, $poolMatch)) {
|
|
$poolName = strtolower(preg_replace('/[^a-z0-9_]/i', '_', $poolMatch[1]));
|
|
if (stripos($title, 'state:') !== false || preg_match('/DEGRADED|FAULTED|UNAVAIL|OFFLINE/i', $title)) {
|
|
$issueSubtype = 'pool_state_' . $poolName;
|
|
} elseif (stripos($title, 'usage') !== false) {
|
|
$issueSubtype = 'pool_usage_' . $poolName;
|
|
} elseif (stripos($title, 'errors') !== false) {
|
|
$issueSubtype = 'pool_errors_' . $poolName;
|
|
} else {
|
|
$issueSubtype = 'pool_' . $poolName;
|
|
}
|
|
}
|
|
} elseif (stripos($title, 'LXC') !== false || stripos($title, 'storage usage') !== false) {
|
|
$issueCategory = 'storage';
|
|
// Include the LXC container ID so each container gets its own ticket
|
|
if (preg_match('/LXC\s+(\d+)/i', $title, $lxcMatch)) {
|
|
$issueSubtype = 'lxc_' . $lxcMatch[1];
|
|
}
|
|
} elseif (stripos($title, 'memory') !== false) {
|
|
$issueCategory = 'memory';
|
|
} elseif (stripos($title, 'cpu') !== false) {
|
|
$issueCategory = 'cpu';
|
|
} elseif (stripos($title, 'network') !== false) {
|
|
$issueCategory = 'network';
|
|
} elseif (stripos($title, 'Ceph') !== false || stripos($title, '[ceph]') !== false) {
|
|
$issueCategory = 'ceph';
|
|
if (
|
|
stripos($title, '[cluster-wide]') !== false ||
|
|
stripos($title, 'HEALTH_ERR') !== false ||
|
|
stripos($title, 'HEALTH_WARN') !== false ||
|
|
stripos($title, 'cluster usage') !== false
|
|
) {
|
|
$isClusterWide = true;
|
|
}
|
|
// Normalize the specific Ceph warning type so different warnings get distinct tickets
|
|
if (stripos($title, 'slow') !== false && stripos($title, 'BlueStore') !== false) {
|
|
$issueSubtype = 'bluestore_slow';
|
|
} elseif (stripos($title, 'clock skew') !== false) {
|
|
$issueSubtype = 'clock_skew';
|
|
} elseif (stripos($title, 'cluster usage') !== false) {
|
|
$issueSubtype = 'usage';
|
|
} elseif (stripos($title, 'OSD down') !== false || preg_match('/osd\.\d+\s+is\s+DOWN/i', $title)) {
|
|
// Include the specific OSD ID so each individual OSD gets its own ticket
|
|
if (preg_match('/osd\.(\d+)/i', $title, $osdMatch)) {
|
|
$issueSubtype = 'osd_down_' . $osdMatch[1];
|
|
} else {
|
|
$issueSubtype = 'osd_down';
|
|
}
|
|
} elseif (stripos($title, 'HEALTH_ERR') !== false) {
|
|
$issueSubtype = 'health_err';
|
|
}
|
|
}
|
|
|
|
// Include source type so automated tickets never collide with manual ones
|
|
$sourceType = stripos($title, '[auto]') !== false ? 'auto' : 'manual';
|
|
|
|
// Build stable components
|
|
$stableComponents = [
|
|
'source_type' => $sourceType,
|
|
'issue_category' => $issueCategory,
|
|
'issue_subtype' => $issueSubtype,
|
|
'environment_tags' => (function () use ($title) {
|
|
// Extract each [bracketed] tag, then keep the known environment ones.
|
|
// (explode('][') leaves brackets stuck to the first/last tag, so e.g.
|
|
// "[production] ..." never matched and the env tag was dropped from the
|
|
// dedup hash — letting prod and staging issues collide onto one ticket.)
|
|
preg_match_all('/\[([^\]]+)\]/', $title, $m);
|
|
return array_values(array_filter(
|
|
$m[1],
|
|
fn($tag) => in_array($tag, ['production', 'development', 'staging', 'single-node', 'cluster-wide'], true)
|
|
));
|
|
})(),
|
|
];
|
|
|
|
// Manual tickets should be unique by title (so different software installs don't collide)
|
|
if ($sourceType === 'manual') {
|
|
$stableComponents['title'] = $title;
|
|
}
|
|
|
|
// Include hostname for node-specific issues
|
|
if (!$isClusterWide) {
|
|
$stableComponents['hostname'] = $hostname;
|
|
}
|
|
|
|
// Include drive identifier for drive-specific tickets.
|
|
// Use serial when available (stable across reboots/reshuffles); fall back to
|
|
// device path for tickets created before serial was added to the payload.
|
|
if ($isDriveTicket) {
|
|
$stableComponents['drive'] = $serial ?? ($deviceMatches[0] ?? '');
|
|
}
|
|
|
|
sort($stableComponents['environment_tags']);
|
|
|
|
return hash('sha256', json_encode($stableComponents, JSON_UNESCAPED_SLASHES));
|
|
}
|
|
|
|
// Shared ticket data
|
|
$title = (string)($data['title'] ?? '');
|
|
$description = (string)($data['description'] ?? '');
|
|
$status = (string)($data['status'] ?? 'Open');
|
|
$priority = $data['priority'] ?? '4';
|
|
$category = (string)($data['category'] ?? 'General');
|
|
$type = (string)($data['type'] ?? 'Issue');
|
|
|
|
// Validate externally-supplied status and priority. (category/type are free-form
|
|
// in this schema.) A non-numeric priority would otherwise cast to 0 and escalate
|
|
// the ticket below P1 on the dedup/update path.
|
|
$validStatuses = $GLOBALS['config']['TICKET_STATUSES'] ?? ['Open', 'Pending', 'In Progress', 'Closed'];
|
|
if (!in_array($status, $validStatuses, true)) {
|
|
http_response_code(400);
|
|
echo json_encode(['success' => false, 'error' => 'Invalid status']);
|
|
exit;
|
|
}
|
|
if (!is_numeric($priority) || (int)$priority < 1 || (int)$priority > 5) {
|
|
http_response_code(400);
|
|
echo json_encode(['success' => false, 'error' => 'Invalid priority (must be 1-5)']);
|
|
exit;
|
|
}
|
|
$priority = (int)$priority;
|
|
|
|
$ticketHash = generateTicketHash($data);
|
|
$auditLog = new AuditLogModel($conn);
|
|
|
|
// Everything from here through either updating/reopening the matched ticket
|
|
// or inserting a brand-new one runs inside one transaction with a row lock
|
|
// on the hash lookup. Without this, two concurrent requests carrying the
|
|
// same dedup hash (e.g. overlapping monitoring runs) could both read the
|
|
// same pre-update snapshot and each independently apply an escalation. FOR
|
|
// UPDATE on this equality lookup against the unique-indexed hash column also
|
|
// takes a lock on the "gap" where no row currently exists, so two concurrent
|
|
// requests for a genuinely new hash are still safe from a duplicate row —
|
|
// but that gap lock is shared, not exclusive, so both can reach the INSERT
|
|
// below and deadlock with each other rather than one blocking cleanly on the
|
|
// other's row. See the retry loop and comment near the INSERT's catch block
|
|
// for how that case is handled.
|
|
// Retried once if the INSERT below deadlocks with another connection's
|
|
// concurrent insert into the same not-yet-existing hash (see comment
|
|
// above the INSERT's catch block) — the retry's own SELECT ... FOR UPDATE
|
|
// will then find the winner's already-committed row and take the
|
|
// update/escalate branch instead of erroring out.
|
|
$maxDedupAttempts = 2;
|
|
for ($dedupAttempt = 1; $dedupAttempt <= $maxDedupAttempts; $dedupAttempt++) {
|
|
$conn->begin_transaction();
|
|
|
|
// Look up any existing ticket with this hash (open OR closed)
|
|
$checkStmt = $conn->prepare("SELECT ticket_id, status, title, priority FROM tickets WHERE hash = ? ORDER BY created_at DESC LIMIT 1 FOR UPDATE");
|
|
$checkStmt->bind_param("s", $ticketHash);
|
|
$checkStmt->execute();
|
|
$existing = $checkStmt->get_result()->fetch_assoc();
|
|
$checkStmt->close();
|
|
|
|
if ($existing) {
|
|
$existingId = $existing['ticket_id'];
|
|
$existingStatus = $existing['status'];
|
|
$existingTitle = $existing['title'];
|
|
$existingPriority = (int)$existing['priority'];
|
|
$newPriority = (int)$priority;
|
|
|
|
if ($existingStatus !== 'Closed') {
|
|
// Ticket is still active — update title, escalate priority, and refresh
|
|
// description with latest sensor data.
|
|
$changes = [];
|
|
$updateSql = "UPDATE tickets SET updated_at = NOW(), updated_by = ?";
|
|
$bindTypes = "i";
|
|
$bindVals = [$userId];
|
|
|
|
if ($title !== $existingTitle) {
|
|
$updateSql .= ", title = ?";
|
|
$bindTypes .= "s";
|
|
$bindVals[] = $title;
|
|
$changes['title'] = ['from' => $existingTitle, 'to' => $title];
|
|
}
|
|
|
|
if ($newPriority < $existingPriority) {
|
|
$updateSql .= ", priority = ?";
|
|
$bindTypes .= "i";
|
|
$bindVals[] = $newPriority;
|
|
$changes['priority'] = ['from' => $existingPriority, 'to' => $newPriority];
|
|
}
|
|
|
|
// Always refresh the description so the ticket body shows current sensor data
|
|
if (!empty($description)) {
|
|
$updateSql .= ", description = ?";
|
|
$bindTypes .= "s";
|
|
$bindVals[] = $description;
|
|
$changes['description_refreshed'] = true;
|
|
}
|
|
|
|
if (!empty($changes)) {
|
|
$updateSql .= " WHERE ticket_id = ?";
|
|
$bindTypes .= "s";
|
|
$bindVals[] = $existingId;
|
|
|
|
$updStmt = $conn->prepare($updateSql);
|
|
$updStmt->bind_param($bindTypes, ...$bindVals);
|
|
$updStmt->execute();
|
|
$updStmt->close();
|
|
|
|
// Only post a comment on priority escalation — title and description updates
|
|
// are silent (title changes like rising counters would spam a comment every run).
|
|
// Keep it short: the full sensor data is refreshed in the ticket description,
|
|
// so the comment just records the bump + a brief reason (no ASCII dump).
|
|
if (isset($changes['priority'])) {
|
|
$pLabels = [1 => 'P1 (Critical)', 2 => 'P2 (High)', 3 => 'P3 (Medium)', 4 => 'P4 (Low)', 5 => 'P5 (Minimal)'];
|
|
$fromP = (int)$changes['priority']['from'];
|
|
$toP = (int)$changes['priority']['to'];
|
|
$fromL = $pLabels[$fromP] ?? "P{$fromP}";
|
|
$toL = $pLabels[$toP] ?? "P{$toP}";
|
|
$commentText = "**hwmonDaemon raised priority {$fromL} → {$toL}.**\n\n"
|
|
. "The latest monitoring scan reported a more severe condition for this issue, "
|
|
. "so it now needs faster attention. Current sensor data is in the ticket description above.";
|
|
$commentStmt = $conn->prepare(
|
|
"INSERT INTO ticket_comments (ticket_id, user_id, user_name, comment_text, markdown_enabled) VALUES (?, ?, 'hwmonDaemon', ?, 1)"
|
|
);
|
|
$commentStmt->bind_param("sis", $existingId, $userId, $commentText);
|
|
$commentStmt->execute();
|
|
$commentStmt->close();
|
|
}
|
|
|
|
$auditLog->log($userId, 'update', 'ticket', $existingId, array_merge(
|
|
array_diff_key($changes, ['description_refreshed' => true]),
|
|
['reason' => 'auto-updated by hwmonDaemon (condition worsened)']
|
|
));
|
|
|
|
// Only notify on priority escalation — title-only updates (e.g. rising
|
|
// Power_On_Hours counter) should not generate a Matrix ping every hour.
|
|
if (isset($changes['priority'])) {
|
|
require_once __DIR__ . '/helpers/NotificationHelper.php';
|
|
NotificationHelper::sendTicketNotification($existingId, [
|
|
'title' => $title,
|
|
'priority' => $changes['priority']['to'],
|
|
'category' => $category,
|
|
'type' => $type,
|
|
'status' => $existingStatus,
|
|
], 'automated');
|
|
}
|
|
|
|
// Ticket state (priority/title/description) changed — refresh dashboard stats.
|
|
(new StatsModel($conn))->invalidateCache();
|
|
}
|
|
|
|
$conn->commit();
|
|
Database::close();
|
|
echo json_encode([
|
|
'success' => true,
|
|
'ticket_id' => $existingId,
|
|
'message' => empty($changes) ? 'Duplicate — no change' : 'Existing ticket updated',
|
|
'action' => empty($changes) ? 'deduplicated' : 'updated',
|
|
'changes' => $changes,
|
|
]);
|
|
exit;
|
|
}
|
|
|
|
// Ticket was closed — reopen it and add a recurrence comment. Route
|
|
// through the Workflow Designer like every other status-write path in
|
|
// the app, rather than forcing status='Open' via raw SQL regardless of
|
|
// configured transition rules.
|
|
$workflowModel = new WorkflowModel($conn);
|
|
$reopenStatus = 'Open';
|
|
if (!$workflowModel->isTransitionAllowed('Closed', 'Open', false)) {
|
|
// Direct Closed->Open isn't configured — fall back to any transition
|
|
// the Workflow Designer does allow from Closed that this unattended,
|
|
// non-admin automation can actually satisfy (no comment prompt, no
|
|
// admin elevation). If even that doesn't exist, leave the ticket
|
|
// Closed rather than force an unconfigured state.
|
|
$reopenStatus = null;
|
|
foreach ($workflowModel->getAllowedTransitions('Closed') as $transition) {
|
|
if (!$transition['requires_comment'] && !$transition['requires_admin']) {
|
|
$reopenStatus = $transition['to_status'];
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if ($reopenStatus !== null) {
|
|
$ticketModel = new TicketModel($conn);
|
|
$ticketModel->updateTicket([
|
|
'ticket_id' => $existingId,
|
|
'title' => $title,
|
|
'description' => $description,
|
|
'category' => $category,
|
|
'type' => $type,
|
|
'status' => $reopenStatus,
|
|
'priority' => $priority,
|
|
], $userId);
|
|
} else {
|
|
error_log("create_ticket_api: hwmonDaemon recurrence for ticket $existingId — "
|
|
. "no admin-free, comment-free transition from Closed is configured; leaving ticket Closed");
|
|
}
|
|
|
|
$commentText = "**Issue recurred — ticket reopened automatically.**\n\n" .
|
|
"hwmonDaemon detected this condition again. The ticket description reflects the "
|
|
. "original report; see this comment's timestamp for when the issue recurred.";
|
|
if ($reopenStatus === null) {
|
|
$commentText = "**Issue recurred, but the ticket could not be reopened automatically.**\n\n"
|
|
. "hwmonDaemon detected this condition again. No Workflow Designer transition from "
|
|
. "Closed is configured that this automation can perform unattended (no comment/admin "
|
|
. "requirement); the ticket remains Closed. Please review and reopen manually if appropriate.";
|
|
}
|
|
$commentStmt = $conn->prepare(
|
|
"INSERT INTO ticket_comments (ticket_id, user_id, user_name, comment_text, markdown_enabled) VALUES (?, ?, 'hwmonDaemon', ?, 1)"
|
|
);
|
|
$commentStmt->bind_param("sis", $existingId, $userId, $commentText);
|
|
$commentStmt->execute();
|
|
$commentStmt->close();
|
|
|
|
if ($reopenStatus !== null) {
|
|
$auditLog->log($userId, 'update', 'ticket', $existingId, [
|
|
'status' => ['from' => 'Closed', 'to' => $reopenStatus],
|
|
'reason' => 'auto-reopened by hwmonDaemon (issue recurred)',
|
|
]);
|
|
|
|
// Ticket reopened — refresh dashboard stats.
|
|
(new StatsModel($conn))->invalidateCache();
|
|
} else {
|
|
$auditLog->log($userId, 'update', 'ticket', $existingId, [
|
|
'reason' => 'hwmonDaemon recurrence detected but no valid reopen transition configured; ticket left Closed',
|
|
]);
|
|
}
|
|
|
|
$conn->commit();
|
|
Database::close();
|
|
|
|
if ($reopenStatus !== null) {
|
|
require_once __DIR__ . '/helpers/NotificationHelper.php';
|
|
NotificationHelper::sendTicketNotification($existingId, [
|
|
'title' => $title,
|
|
'priority' => $priority,
|
|
'category' => $category,
|
|
'type' => $type,
|
|
'status' => $reopenStatus,
|
|
], 'automated');
|
|
}
|
|
|
|
echo json_encode([
|
|
'success' => true,
|
|
'ticket_id' => $existingId,
|
|
'message' => $reopenStatus !== null
|
|
? 'Existing closed ticket reopened'
|
|
: 'Recurrence noted; ticket left Closed (no valid workflow transition configured)',
|
|
'action' => $reopenStatus !== null ? 'reopened' : 'recurrence_noted',
|
|
]);
|
|
exit;
|
|
}
|
|
|
|
// No existing ticket — create a new one. Still inside the transaction opened
|
|
// above, so a concurrent request for the same hash is blocked on its own
|
|
// SELECT ... FOR UPDATE until this one commits or rolls back (see comment
|
|
// there) rather than racing this INSERT.
|
|
//
|
|
// Note on FOR UPDATE over a not-yet-existing key: InnoDB's gap lock in that
|
|
// case is a shared lock, not exclusive — two concurrent transactions can
|
|
// both acquire it and both reach this INSERT. The conflict only surfaces
|
|
// when they each request the insert-intention lock for the same gap,
|
|
// which InnoDB resolves as a deadlock (error 1213), not by blocking one
|
|
// of the SELECTs. The outer loop above retries that case: the loser rolls
|
|
// back and re-runs its own SELECT ... FOR UPDATE, which by then finds the
|
|
// winner's committed row and takes the update/escalate branch instead.
|
|
//
|
|
// Generate a collision-safe unique ticket_id with a pre-check + retry loop (same
|
|
// approach as TicketModel::createTicket) so a ticket_id clash cannot happen. That
|
|
// way a 1062 on INSERT below can only be the unique_hash (dedup) key — and with
|
|
// the FOR UPDATE lock above, only in the unlikely case of a hash collision from
|
|
// two genuinely different reports, not the same-hash race this used to be.
|
|
$ticket_id = null;
|
|
$maxAttempts = 50;
|
|
$attempts = 0;
|
|
do {
|
|
try {
|
|
$candidateId = sprintf('%09d', random_int(100000000, 999999999));
|
|
} catch (Exception $e) {
|
|
$candidateId = sprintf('%09d', mt_rand(100000000, 999999999));
|
|
}
|
|
|
|
$idCheckStmt = $conn->prepare("SELECT ticket_id FROM tickets WHERE ticket_id = ? LIMIT 1");
|
|
$idCheckStmt->bind_param("s", $candidateId);
|
|
$idCheckStmt->execute();
|
|
$idExists = $idCheckStmt->get_result()->num_rows > 0;
|
|
$idCheckStmt->close();
|
|
|
|
if (!$idExists) {
|
|
$ticket_id = $candidateId;
|
|
}
|
|
$attempts++;
|
|
} while ($ticket_id === null && $attempts < $maxAttempts);
|
|
|
|
if ($ticket_id === null) {
|
|
$conn->rollback();
|
|
error_log('create_ticket_api: failed to generate a unique ticket_id after ' . $maxAttempts . ' attempts');
|
|
http_response_code(500);
|
|
echo json_encode(['success' => false, 'error' => 'Internal server error']);
|
|
exit;
|
|
}
|
|
|
|
$insertStmt = $conn->prepare(
|
|
"INSERT INTO tickets (ticket_id, title, description, status, priority, category, type, hash, created_by)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
|
);
|
|
$insertStmt->bind_param(
|
|
"ssssssssi",
|
|
$ticket_id,
|
|
$title,
|
|
$description,
|
|
$status,
|
|
$priority,
|
|
$category,
|
|
$type,
|
|
$ticketHash,
|
|
$userId
|
|
);
|
|
|
|
try {
|
|
$inserted = $insertStmt->execute();
|
|
} catch (mysqli_sql_exception $e) {
|
|
$insertStmt->close();
|
|
$conn->rollback();
|
|
if (in_array($e->getCode(), [1213, 1205], true) && $dedupAttempt < $maxDedupAttempts) {
|
|
// Deadlock (1213) or lock wait timeout (1205) from a concurrent
|
|
// insert into the same not-yet-existing hash gap — see the note
|
|
// above. Retry: the next iteration's own SELECT ... FOR UPDATE
|
|
// will find whichever side won and take the update/escalate path.
|
|
continue;
|
|
}
|
|
if ($e->getCode() === 1062) {
|
|
// Should be unreachable in the same-hash race this issue was filed
|
|
// for now that the SELECT above takes FOR UPDATE — kept as a
|
|
// defensive fallback in case of a genuine hash collision between two
|
|
// different reports.
|
|
echo json_encode(['success' => false, 'error' => 'Duplicate ticket']);
|
|
} else {
|
|
error_log('create_ticket_api: insert failed: ' . $e->getMessage());
|
|
http_response_code(500);
|
|
echo json_encode(['success' => false, 'error' => 'Internal server error']);
|
|
}
|
|
exit;
|
|
}
|
|
$insertStmt->close();
|
|
|
|
if ($inserted) {
|
|
$auditLog->logTicketCreate($userId, $ticket_id, [
|
|
'title' => $title,
|
|
'priority' => $priority,
|
|
'category' => $category,
|
|
'type' => $type,
|
|
]);
|
|
|
|
// New ticket created — refresh dashboard stats.
|
|
(new StatsModel($conn))->invalidateCache();
|
|
|
|
$conn->commit();
|
|
Database::close();
|
|
|
|
require_once __DIR__ . '/helpers/NotificationHelper.php';
|
|
NotificationHelper::sendTicketNotification($ticket_id, [
|
|
'title' => $title,
|
|
'priority' => $priority,
|
|
'category' => $category,
|
|
'type' => $type,
|
|
'status' => $status,
|
|
], 'automated');
|
|
|
|
echo json_encode([
|
|
'success' => true,
|
|
'ticket_id' => $ticket_id,
|
|
'message' => 'Ticket created successfully',
|
|
]);
|
|
} else {
|
|
$conn->rollback();
|
|
error_log('create_ticket_api: ticket insert reported failure: ' . $conn->error);
|
|
http_response_code(500);
|
|
echo json_encode(['success' => false, 'error' => 'Internal server error']);
|
|
}
|
|
}
|