getVisibilityFilter($currentUser); // Candidates: the whole title as a substring, a SOUNDEX match, or any // significant word (4+ letters) in common. The scoring below decides // what counts as similar; without the word candidates its // word-overlap branch could never match anything, so e.g. "Printer // jammed again" never surfaced "Printer is jammed". $words = array_slice(array_values(array_filter($titleWords, fn($w) => mb_strlen($w) >= 4)), 0, 8); $wordSql = str_repeat(' OR t.title LIKE ?', count($words)); $wordParams = array_map(fn($w) => '%' . addcslashes($w, '%_\\') . '%', $words); // Aliased as `t`: the visibility filter's SQL refers to t.* columns. // Without the alias the query failed for every non-admin and the // error was swallowed below, so they never saw any matches. // SOUNDEX is compared in SQL on both sides: PHP's soundex() keeps only // 4 characters (effectively the first word), which never equalled // MariaDB's full-length code in the WHERE and, in the scoring, made any // two titles sharing a first word score as "sounds alike". $sql = "SELECT t.ticket_id, t.title, t.status, t.priority, t.created_at, SOUNDEX(t.title) = SOUNDEX(?) AS sounds_alike FROM tickets t WHERE ( t.title LIKE ? OR SOUNDEX(t.title) = SOUNDEX(?){$wordSql} ) AND t.status != 'Closed' AND ({$visFilter['sql']}) ORDER BY t.created_at DESC LIMIT 50"; $types = "sss" . str_repeat('s', count($words)) . $visFilter['types']; $params = array_merge([$title, $searchTerm, $title], $wordParams, $visFilter['params']); // Duplicate detection is advisory (it must not block ticket creation), so on any // DB error degrade gracefully to "no duplicates" rather than fataling the request. // mysqli may throw (default exception mode) or return false depending on config. try { $stmt = $conn->prepare($sql); if (!$stmt) { throw new RuntimeException('prepare failed: ' . $conn->error); } if (!empty($params)) { $stmt->bind_param($types, ...$params); } $stmt->execute(); $result = $stmt->get_result(); if ($result === false) { // Non-exception mysqli mode: execute/get_result return false instead of // throwing. Treat as a query failure so we don't fatal on $result below. throw new RuntimeException('query failed: ' . $conn->error); } } catch (Throwable $e) { error_log('check_duplicates: ' . $e->getMessage()); return []; } while ($row = $result->fetch_assoc()) { // Calculate similarity score $similarity = 0; // Check for exact substring match if (stripos($row['title'], $title) !== false) { $similarity = 90; // Check SOUNDEX match } elseif (!empty($row['sounds_alike'])) { $similarity = 70; // Check word overlap } else { // At least two shared words: one common word (e.g. the // "[problem]" tag every automated ticket carries) is not // similarity, however short the searched title is. $matchingWords = array_intersect($titleWords, self::words($row['title'])); if (count($matchingWords) >= 2) { $similarity = (count($matchingWords) / max(count($titleWords), 1)) * 60; } } if ($similarity >= 30) { $duplicates[] = [ 'ticket_id' => $row['ticket_id'], 'title' => $row['title'], 'status' => $row['status'], 'priority' => $row['priority'], 'created_at' => $row['created_at'], 'similarity' => round($similarity) ]; } } $stmt->close(); // Sort by similarity descending usort($duplicates, function ($a, $b) { return $b['similarity'] - $a['similarity']; }); // Keep the best matches return array_slice($duplicates, 0, $limit); } /** * Distinct lowercase words of a title, split on anything that isn't a * letter or digit (so "[ceph]" and "ceph" are the same word). * * @return list */ private static function words(string $title): array { return array_values(array_unique(array_filter( preg_split('/[^\p{L}\p{N}]+/u', mb_strtolower($title)), fn($w) => $w !== '' ))); } }