*/ private static array $columnCache = []; private function requireAdmin(): ?Response { if (!Auth::check() || !Auth::requireRole('admin')) { return $this->redirect('/login'); } return null; } public static function statusLabel(string $s): string { return match ($s) { 'open' => 'Açık', 'pending' => 'Beklemede', 'answered' => 'Yanıtlandı', 'closed' => 'Kapalı', default => $s }; } private function isAjaxRequest(): bool { $requestedWith = strtolower((string)($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '')); $accept = strtolower((string)($_SERVER['HTTP_ACCEPT'] ?? '')); return $requestedWith === 'xmlhttprequest' || str_contains($accept, 'application/json'); } /** * @param array $data */ private function json(array $data, int $statusCode = 200): never { http_response_code($statusCode); header('Content-Type: application/json; charset=utf-8'); echo json_encode($data, JSON_UNESCAPED_UNICODE); exit; } private function hasColumn(string $table, string $column): bool { $key = $table . '.' . $column; if (array_key_exists($key, self::$columnCache)) { return self::$columnCache[$key]; } try { $st = DB::pdo()->prepare("SHOW COLUMNS FROM `{$table}` LIKE ?"); $st->execute([$column]); self::$columnCache[$key] = (bool)$st->fetch(); } catch (\Throwable $e) { self::$columnCache[$key] = false; } return self::$columnCache[$key]; } /** * @param array $t */ private function computeProductHasNewReply(array $t): int { $lastSenderType = (string)($t['last_sender_type'] ?? ''); if ($lastSenderType !== 'user') { return 0; } if (array_key_exists('admin_last_seen_at', $t)) { $adminSeen = trim((string)($t['admin_last_seen_at'] ?? '')); $lastMsgAt = trim((string)($t['last_msg_at'] ?? $t['last_message_at'] ?? '')); if ($adminSeen === '') { return 1; } if ($lastMsgAt !== '' && strtotime($lastMsgAt) > strtotime($adminSeen)) { return 1; } return 0; } return 1; } /** * @return array> */ private function fetchSupportTickets(string $status = '', string $q = '', string $type = ''): array { $where = []; $params = []; if ($type === 'product') { $where[] = "1=0"; } if ($status !== '' && in_array($status, ['open', 'pending', 'answered', 'closed'], true)) { $where[] = "t.status=?"; $params[] = $status; } if ($q !== '') { $where[] = "(t.subject LIKE ? OR t.id = ? OR u.name LIKE ? OR u.email LIKE ?)"; $params[] = "%$q%"; $params[] = (int)$q; $params[] = "%$q%"; $params[] = "%$q%"; } $whereSql = $where ? ('WHERE ' . implode(' AND ', $where)) : ''; $st = DB::pdo()->prepare(" SELECT 'support' AS type, t.id, t.subject, t.status, t.priority, t.created_at, t.updated_at, t.user_last_seen_at, t.admin_last_seen_at, t.last_sender_type, t.last_message_at, u.name AS user_name, u.email AS user_email, (SELECT COUNT(*) FROM support_messages m WHERE m.ticket_id=t.id) AS msg_count, (SELECT MAX(m2.created_at) FROM support_messages m2 WHERE m2.ticket_id=t.id) AS last_msg_at, CASE WHEN t.admin_last_seen_at IS NULL AND t.last_sender_type='user' THEN 1 WHEN t.admin_last_seen_at IS NOT NULL AND t.last_message_at > t.admin_last_seen_at AND t.last_sender_type='user' THEN 1 ELSE 0 END AS has_new_reply FROM support_tickets t LEFT JOIN users u ON u.id=t.user_id {$whereSql} "); $st->execute($params); $rows = $st->fetchAll() ?: []; foreach ($rows as $i => $row) { $rows[$i]['sla'] = $this->getSlaLevel($row); } return $rows; } /** * @return array> */ private function fetchProductTickets(string $status = '', string $q = '', string $type = ''): array { $where = []; $params = []; if ($type === 'support') { $where[] = "1=0"; } if ($status !== '' && in_array($status, ['open', 'pending', 'answered', 'closed'], true)) { $where[] = "t.status=?"; $params[] = $status; } if ($q !== '') { $where[] = "(t.subject LIKE ? OR t.id = ? OR u.name LIKE ? OR u.email LIKE ?)"; $params[] = "%$q%"; $params[] = (int)$q; $params[] = "%$q%"; $params[] = "%$q%"; } $whereSql = $where ? ('WHERE ' . implode(' AND ', $where)) : ''; $hasAdminSeen = $this->hasColumn('tickets', 'admin_last_seen_at'); $adminSeenSelect = $hasAdminSeen ? "t.admin_last_seen_at AS admin_last_seen_at," : "NULL AS admin_last_seen_at,"; $st = DB::pdo()->prepare(" SELECT 'product' AS type, t.id, t.subject, t.status, t.priority, t.created_at, t.updated_at, t.user_last_seen_at, {$adminSeenSelect} t.last_sender_type, t.last_message_at, u.name AS user_name, u.email AS user_email, (SELECT COUNT(*) FROM ticket_messages m WHERE m.ticket_id=t.id) AS msg_count, (SELECT MAX(m2.created_at) FROM ticket_messages m2 WHERE m2.ticket_id=t.id) AS last_msg_at FROM tickets t LEFT JOIN users u ON u.id=t.user_id {$whereSql} "); $st->execute($params); $rows = $st->fetchAll() ?: []; foreach ($rows as $i => $row) { $row['has_new_reply'] = $this->computeProductHasNewReply($row); $row['sla'] = $this->getSlaLevel($row); $rows[$i] = $row; } return $rows; } /** * @return array> */ private function fetchAllTickets(string $status = '', string $q = '', string $type = ''): array { $supportTickets = $this->fetchSupportTickets($status, $q, $type); $productTickets = $this->fetchProductTickets($status, $q, $type); $tickets = array_merge($supportTickets, $productTickets); usort($tickets, function ($a, $b) { $aTime = strtotime((string)($a['updated_at'] ?? $a['created_at'] ?? 'now')); $bTime = strtotime((string)($b['updated_at'] ?? $b['created_at'] ?? 'now')); return $bTime <=> $aTime; }); return $tickets; } /** * @param array> $tickets * @return array */ private function buildTicketStats(array $tickets): array { $supportCount = 0; $productCount = 0; $adminLastCount = 0; $userLastCount = 0; $needsReplyCount = 0; $avgMsgCount = 0.0; $avgFirstReplyMinutes = 0; $staleCount = 0; $total = count($tickets); foreach ($tickets as $tk) { $tt = (string)($tk['type'] ?? 'support'); $ls = (string)($tk['last_sender_type'] ?? ''); $nr = (int)($tk['has_new_reply'] ?? 0); if ($tt === 'product') $productCount++; else $supportCount++; if ($ls === 'admin') $adminLastCount++; if ($ls === 'user') $userLastCount++; if ($nr === 1) $needsReplyCount++; } if ($total > 0) { $sumMsg = 0; foreach ($tickets as $tk) { $sumMsg += (int)($tk['msg_count'] ?? 0); } $avgMsgCount = round($sumMsg / max(1, $total), 1); } $st = DB::pdo()->query(" SELECT AVG(TIMESTAMPDIFF(MINUTE, t.created_at, first_admin.first_reply_at)) AS avg_first_reply_minutes FROM support_tickets t INNER JOIN ( SELECT ticket_id, MIN(created_at) AS first_reply_at FROM support_messages WHERE sender_type='admin' GROUP BY ticket_id ) first_admin ON first_admin.ticket_id = t.id "); $avgFirstReplyMinutes = (int)($st->fetchColumn() ?? 0); foreach ($tickets as $tk) { $stTime = strtotime((string)($tk['last_msg_at'] ?? $tk['updated_at'] ?? '')); $statusNow = (string)($tk['status'] ?? 'open'); if ($stTime > 0 && in_array($statusNow, ['open', 'pending', 'answered'], true)) { if ((time() - $stTime) > 86400) { $staleCount++; } } } return [ 'supportCount' => $supportCount, 'productCount' => $productCount, 'adminLastCount' => $adminLastCount, 'userLastCount' => $userLastCount, 'needsReplyCount' => $needsReplyCount, 'avgMsgCount' => $avgMsgCount, 'avgFirstReplyMinutes' => $avgFirstReplyMinutes, 'staleCount' => $staleCount, ]; } private function markAdminSeen(string $type, int $ticketId): void { if ($ticketId <= 0) { return; } if ($type === 'support') { DB::pdo()->prepare(" UPDATE support_tickets SET admin_last_seen_at = NOW() WHERE id=? LIMIT 1 ")->execute([$ticketId]); return; } if ($type === 'product' && $this->hasColumn('tickets', 'admin_last_seen_at')) { DB::pdo()->prepare(" UPDATE tickets SET admin_last_seen_at = NOW() WHERE id=? LIMIT 1 ")->execute([$ticketId]); } } private function markRelatedNotificationsRead(string $type, int $ticketId): void { if ($ticketId <= 0) { return; } $supportLink = Url::to('/my/tickets/show?id=' . $ticketId); $productLink = Url::to('/my/tickets/show?type=product&id=' . $ticketId); if ($type === 'support') { DB::pdo()->prepare(" UPDATE notifications SET is_read = 1 WHERE link = ? AND is_read = 0 ")->execute([$supportLink]); return; } DB::pdo()->prepare(" UPDATE notifications SET is_read = 1 WHERE link = ? AND is_read = 0 ")->execute([$productLink]); } /** * @return array */ private function fetchTicketByType(int $id, string $type): array { if ($type === 'support') { $st = DB::pdo()->prepare(" SELECT t.*, u.name AS user_name, u.email AS user_email FROM support_tickets t LEFT JOIN users u ON u.id=t.user_id WHERE t.id=? LIMIT 1 "); $st->execute([$id]); $ticket = $st->fetch() ?: []; if ($ticket) { $ticket['type'] = 'support'; } return $ticket; } $hasAdminSeen = $this->hasColumn('tickets', 'admin_last_seen_at'); $adminSeenSelect = $hasAdminSeen ? ", t.admin_last_seen_at" : ""; $st = DB::pdo()->prepare(" SELECT t.*, u.name AS user_name, u.email AS user_email {$adminSeenSelect} FROM tickets t LEFT JOIN users u ON u.id=t.user_id WHERE t.id=? LIMIT 1 "); $st->execute([$id]); $ticket = $st->fetch() ?: []; if ($ticket) { $ticket['type'] = 'product'; } return $ticket; } /** * @return array> */ private function fetchMessagesByType(int $id, string $type, int $afterId = 0): array { if ($type === 'support') { $sql = " SELECT id, ticket_id, sender_type, sender_id, message, created_at FROM support_messages WHERE ticket_id=? "; $params = [$id]; if ($afterId > 0) { $sql .= " AND id > ?"; $params[] = $afterId; } $sql .= " ORDER BY id ASC"; $st = DB::pdo()->prepare($sql); $st->execute($params); return $st->fetchAll() ?: []; } $sql = " SELECT id, ticket_id, sender, message, created_at FROM ticket_messages WHERE ticket_id=? "; $params = [$id]; if ($afterId > 0) { $sql .= " AND id > ?"; $params[] = $afterId; } $sql .= " ORDER BY id ASC"; $st = DB::pdo()->prepare($sql); $st->execute($params); return $st->fetchAll() ?: []; } /** * @param array $ticket * @param array> $messages * @return array */ private function buildShowStats(array $ticket, array $messages): array { $adminCount = 0; $userCount = 0; $lastMessageId = 0; foreach ($messages as $m) { $isAdmin = (($m['sender_type'] ?? '') === 'admin') || (($m['sender'] ?? '') === 'admin'); if ($isAdmin) $adminCount++; else $userCount++; $mid = (int)($m['id'] ?? 0); if ($mid > $lastMessageId) { $lastMessageId = $mid; } } $ticket['msg_count'] = count($messages); $ticket['last_msg_at'] = $ticket['last_message_at'] ?? ($ticket['updated_at'] ?? null); $ticket['has_new_reply'] = $ticket['type'] === 'support' ? ( (((string)($ticket['last_sender_type'] ?? '') === 'user') && ( empty($ticket['admin_last_seen_at']) || strtotime((string)($ticket['last_message_at'] ?? '')) > strtotime((string)($ticket['admin_last_seen_at'] ?? '1970-01-01')) )) ? 1 : 0 ) : $this->computeProductHasNewReply($ticket); return [ 'messageCount' => count($messages), 'adminCount' => $adminCount, 'userCount' => $userCount, 'status' => (string)($ticket['status'] ?? 'open'), 'statusLabel' => self::statusLabel((string)($ticket['status'] ?? 'open')), 'lastMessageId' => $lastMessageId, 'lastMessageAt' => (string)($ticket['last_message_at'] ?? ''), 'lastSenderType' => (string)($ticket['last_sender_type'] ?? ''), 'hasNewReply' => (int)($ticket['has_new_reply'] ?? 0), 'sla' => $this->getSlaLevel($ticket), ]; } /** * @param array $m * @return array */ private function normalizeMessage(array $m, string $type): array { $isAdmin = (($m['sender_type'] ?? '') === 'admin') || (($m['sender'] ?? '') === 'admin'); return [ 'id' => (int)($m['id'] ?? 0), 'ticket_id' => (int)($m['ticket_id'] ?? 0), 'is_admin' => $isAdmin, 'sender_name' => $isAdmin ? 'Yönetim' : 'Kullanıcı', 'sender_role' => $isAdmin ? ($type === 'product' ? 'Satıcı / Yönetim yanıtı' : 'Yönetim yanıtı') : 'Müşteri mesajı', 'avatar_text' => $isAdmin ? 'YN' : 'KR', 'message' => (string)($m['message'] ?? ''), 'created_at' => (string)($m['created_at'] ?? ''), 'sender_type' => (string)($m['sender_type'] ?? $m['sender'] ?? ''), ]; } /** * @param array $ticket */ private function sendUserReplyMailIfAvailable(array $ticket, string $type, int $ticketId, string $message): void { $userId = (int)($ticket['user_id'] ?? 0); $userEmail = trim((string)($ticket['user_email'] ?? '')); if ($userId <= 0 || $userEmail === '') { return; } if (!class_exists(TicketMailService::class)) { return; } $mailType = $type === 'product' ? 'product_reply_to_user' : 'support_reply_to_user'; if (class_exists(MailPreferenceService::class)) { try { if (!MailPreferenceService::allowsUser($mailType, $userId)) { return; } } catch (\Throwable $e) { } } $ticketUrl = $type === 'product' ? Url::to('/my/tickets/show?type=product&id=' . $ticketId) : Url::to('/my/tickets/show?id=' . $ticketId); try { TicketMailService::sendUserReplyMail( $userEmail, trim((string)($ticket['user_name'] ?? '')), $type, $ticketId, trim((string)($ticket['subject'] ?? '')), $message, $ticketUrl ); } catch (\Throwable $e) { } } /** * @return array */ private function fetchUserForAdminCreate(int $userId): array { $st = DB::pdo()->prepare(" SELECT id, name, email, is_active, role FROM users WHERE id=? LIMIT 1 "); $st->execute([$userId]); return $st->fetch() ?: []; } /** * @return array> */ private function searchUsersForAdminCreate(string $q): array { $q = trim($q); if ($q === '' || mb_strlen($q, 'UTF-8') < 2) { return []; } $st = DB::pdo()->prepare(" SELECT id, name, email FROM users WHERE role='user' AND is_active=1 AND ( name LIKE ? OR email LIKE ? OR id = ? ) ORDER BY name ASC, id DESC LIMIT 12 "); $st->execute(['%' . $q . '%', '%' . $q . '%', (int)$q]); return $st->fetchAll() ?: []; } /** * @return array */ private function fetchProductForAdminCreate(int $productId): array { $st = DB::pdo()->prepare(" SELECT id, name FROM products WHERE id=? LIMIT 1 "); $st->execute([$productId]); return $st->fetch() ?: []; } /** * @return array> */ private function searchProductsForAdminCreate(string $q): array { $q = trim($q); if ($q === '' || mb_strlen($q, 'UTF-8') < 2) { return []; } $st = DB::pdo()->prepare(" SELECT id, name, slug, stock_qty FROM products WHERE is_active=1 AND ( name LIKE ? OR slug LIKE ? OR id = ? ) ORDER BY name ASC, id DESC LIMIT 12 "); $st->execute(['%' . $q . '%', '%' . $q . '%', (int)$q]); return $st->fetchAll() ?: []; } /** * @return array> */ private function fetchActiveTemplateOptions(): array { if (!$this->hasColumn('ticket_templates', 'id')) { return []; } try { $st = DB::pdo()->query(" SELECT id, name, category, ticket_type, tone, subject_template, is_active, sort_order FROM ticket_templates WHERE is_active=1 ORDER BY sort_order ASC, id DESC "); return $st->fetchAll() ?: []; } catch (\Throwable $e) { return []; } } /** * @return array */ private function fetchTemplateById(int $templateId): array { if ($templateId <= 0 || !$this->hasColumn('ticket_templates', 'id')) { return []; } try { $st = DB::pdo()->prepare(" SELECT * FROM ticket_templates WHERE id=? AND is_active=1 LIMIT 1 "); $st->execute([$templateId]); return $st->fetch() ?: []; } catch (\Throwable $e) { return []; } } /** * @param array $template * @param array $ctx * @return array */ private function renderTemplatePayload(array $template, array $ctx): array { $subject = (string)($template['subject_template'] ?? ''); $message = (string)($template['message_template'] ?? ''); $map = [ '{{user_name}}' => (string)($ctx['user_name'] ?? 'Değerli müşterimiz'), '{{user_email}}' => (string)($ctx['user_email'] ?? ''), '{{product_name}}' => (string)($ctx['product_name'] ?? 'ilgili ürün'), '{{product_id}}' => (string)($ctx['product_id'] ?? ''), '{{admin_name}}' => (string)($ctx['admin_name'] ?? 'Yönetim'), '{{today}}' => date('d.m.Y'), '{{order_no}}' => (string)($ctx['order_no'] ?? ''), ]; $subject = strtr($subject, $map); $message = strtr($message, $map); return [ 'id' => (int)($template['id'] ?? 0), 'name' => (string)($template['name'] ?? ''), 'category' => (string)($template['category'] ?? ''), 'ticket_type' => (string)($template['ticket_type'] ?? 'support'), 'tone' => (string)($template['tone'] ?? 'formal'), 'subject' => $subject, 'message' => $message, 'variables_json' => (string)($template['variables_json'] ?? '[]'), ]; } public function createForUser(): Response { if ($r = $this->requireAdmin()) return $r; return $this->view('admin/tickets/create_for_user', [ 'title' => 'Kullanıcı Adına Ticket Aç', 'templateOptions' => $this->fetchActiveTemplateOptions(), ]); } public function userLookup(): Response { if ($r = $this->requireAdmin()) return $r; $q = trim((string)($_GET['q'] ?? '')); $items = $this->searchUsersForAdminCreate($q); $this->json([ 'ok' => true, 'items' => array_values($items) ]); } public function productLookup(): Response { if ($r = $this->requireAdmin()) return $r; $q = trim((string)($_GET['q'] ?? '')); $items = $this->searchProductsForAdminCreate($q); $this->json([ 'ok' => true, 'items' => array_values($items) ]); } public function templatePayload(): Response { if ($r = $this->requireAdmin()) return $r; $templateId = (int)($_GET['template_id'] ?? 0); $userId = (int)($_GET['user_id'] ?? 0); $productId = (int)($_GET['product_id'] ?? 0); $template = $this->fetchTemplateById($templateId); if (!$template) { $this->json([ 'ok' => false, 'message' => 'Şablon bulunamadı.' ], 404); } $user = $userId > 0 ? $this->fetchUserForAdminCreate($userId) : []; $product = $productId > 0 ? $this->fetchProductForAdminCreate($productId) : []; $admin = Auth::user() ?? []; $payload = $this->renderTemplatePayload($template, [ 'user_name' => (string)($user['name'] ?? 'Değerli müşterimiz'), 'user_email' => (string)($user['email'] ?? ''), 'product_name' => (string)($product['name'] ?? 'ilgili ürün'), 'product_id' => (string)($product['id'] ?? ''), 'admin_name' => (string)($admin['name'] ?? 'Yönetim'), 'today' => date('d.m.Y'), ]); $this->json([ 'ok' => true, 'template' => $payload ]); } /** * @param array $user */ private function sendAdminCreatedTicketMailIfAvailable(array $user, string $type, int $ticketId, string $subject, string $message): void { $userId = (int)($user['id'] ?? 0); $userEmail = trim((string)($user['email'] ?? '')); if ($userId <= 0 || $userEmail === '') { return; } if (!class_exists(TicketMailService::class)) { return; } if (class_exists(MailPreferenceService::class)) { try { if (!MailPreferenceService::allowsUser('admin_ticket_created_for_user', $userId)) { return; } } catch (\Throwable $e) { } } $ticketUrl = $type === 'product' ? Url::to('/my/tickets/show?type=product&id=' . $ticketId) : Url::to('/my/tickets/show?id=' . $ticketId); try { if (method_exists(TicketMailService::class, 'sendAdminCreatedTicketMail')) { TicketMailService::sendAdminCreatedTicketMail( $userEmail, trim((string)($user['name'] ?? '')), $type, $ticketId, $subject, $message, $ticketUrl ); } } catch (\Throwable $e) { } } public function storeForUser(): Response { if ($r = $this->requireAdmin()) return $r; $userId = (int)($_POST['user_id'] ?? 0); $type = trim((string)($_POST['ticket_type'] ?? 'support')); $subject = trim((string)($_POST['subject'] ?? '')); $message = trim((string)($_POST['message'] ?? '')); $priority = trim((string)($_POST['priority'] ?? 'normal')); $productId = (int)($_POST['product_id'] ?? 0); $status = trim((string)($_POST['status'] ?? 'answered')); if (!in_array($type, ['support', 'product'], true)) $type = 'support'; if (!in_array($priority, ['low', 'normal', 'high'], true)) $priority = 'normal'; if (!in_array($status, ['open', 'pending', 'answered', 'closed'], true)) $status = 'answered'; if ($userId <= 0 || $subject === '' || $message === '') { $_SESSION['flash_error'] = 'Kullanıcı, konu ve mesaj zorunludur.'; return $this->redirect('/admin/tickets/create-for-user'); } $user = $this->fetchUserForAdminCreate($userId); if (!$user || (int)($user['is_active'] ?? 0) !== 1) { $_SESSION['flash_error'] = 'Geçerli aktif kullanıcı bulunamadı.'; return $this->redirect('/admin/tickets/create-for-user'); } $product = []; if ($type === 'product') { if ($productId <= 0) { $_SESSION['flash_error'] = 'Ürün ticketı için ürün seçmelisin.'; return $this->redirect('/admin/tickets/create-for-user'); } $product = $this->fetchProductForAdminCreate($productId); if (!$product) { $_SESSION['flash_error'] = 'Ürün bulunamadı.'; return $this->redirect('/admin/tickets/create-for-user'); } } $pdo = DB::pdo(); $admin = Auth::user() ?? []; $adminId = (int)($admin['id'] ?? 0); try { $pdo->beginTransaction(); if ($type === 'product') { $subjectStored = 'Ürün #' . $productId . ' - ' . trim((string)($product['name'] ?? 'Ürün')); if ($subject !== '') { $subjectStored .= ' | ' . $subject; } if ($this->hasColumn('tickets', 'admin_last_seen_at')) { $pdo->prepare(" INSERT INTO tickets ( user_id, subject, status, priority, created_at, updated_at, admin_last_seen_at, last_sender_type, last_message_at ) VALUES (?, ?, ?, ?, NOW(), NOW(), NOW(), 'admin', NOW()) ")->execute([$userId, $subjectStored, $status, $priority]); } else { $pdo->prepare(" INSERT INTO tickets ( user_id, subject, status, priority, created_at, updated_at, last_sender_type, last_message_at ) VALUES (?, ?, ?, ?, NOW(), NOW(), 'admin', NOW()) ")->execute([$userId, $subjectStored, $status, $priority]); } $ticketId = (int)$pdo->lastInsertId(); $pdo->prepare(" INSERT INTO ticket_messages (ticket_id, sender, message, created_at) VALUES (?, 'admin', ?, NOW()) ")->execute([$ticketId, $message]); $notifTitle = 'Yönetim sizin adınıza ürün ticketı oluşturdu'; $notifBody = '“' . $subjectStored . '” başlıklı yeni bir ticket oluşturuldu. Görmek için tıklayın.'; $pdo->prepare(" INSERT INTO notifications (user_id, title, body, link, is_read, created_at) VALUES (?, ?, ?, ?, 0, NOW()) ")->execute([ $userId, $notifTitle, $notifBody, Url::to('/my/tickets/show?type=product&id=' . $ticketId) ]); AdminLog::add($adminId, 'admin_create_product_ticket', [ 'table' => 'tickets', 'ticket_id' => $ticketId, 'user_id' => $userId, 'product_id' => $productId, 'status' => $status ]); } else { $pdo->prepare(" INSERT INTO support_tickets ( user_id, subject, status, priority, created_at, updated_at, admin_last_seen_at, last_sender_type, last_message_at ) VALUES (?, ?, ?, ?, NOW(), NOW(), NOW(), 'admin', NOW()) ")->execute([$userId, $subject, $status, $priority]); $ticketId = (int)$pdo->lastInsertId(); $pdo->prepare(" INSERT INTO support_messages (ticket_id, sender_type, sender_id, message, created_at) VALUES (?, 'admin', ?, ?, NOW()) ")->execute([$ticketId, $adminId, $message]); $notifTitle = 'Yönetim sizin adınıza destek talebi oluşturdu'; $notifBody = '“' . $subject . '” başlıklı yeni bir destek talebi oluşturuldu. Görmek için tıklayın.'; $pdo->prepare(" INSERT INTO notifications (user_id, title, body, link, is_read, created_at) VALUES (?, ?, ?, ?, 0, NOW()) ")->execute([ $userId, $notifTitle, $notifBody, Url::to('/my/tickets/show?id=' . $ticketId) ]); AdminLog::add($adminId, 'admin_create_support_ticket', [ 'table' => 'support_tickets', 'ticket_id' => $ticketId, 'user_id' => $userId, 'status' => $status ]); } $pdo->commit(); $this->sendAdminCreatedTicketMailIfAvailable($user, $type, $ticketId, $subject, $message); $_SESSION['flash_success'] = 'Kullanıcı adına ticket oluşturuldu.'; return $this->redirect('/admin/tickets/show?id=' . $ticketId . '&type=' . $type); } catch (\Throwable $e) { if ($pdo->inTransaction()) { $pdo->rollBack(); } $_SESSION['flash_error'] = 'Ticket oluşturulamadı: ' . $e->getMessage(); return $this->redirect('/admin/tickets/create-for-user'); } } public function index(): Response { if ($r = $this->requireAdmin()) return $r; $status = trim((string)($_GET['status'] ?? '')); $q = trim((string)($_GET['q'] ?? '')); $type = trim((string)($_GET['type'] ?? '')); if (!in_array($type, ['support', 'product'], true)) { $type = ''; } $page = max(1, (int)($_GET['page'] ?? 1)); $perPage = 20; $offset = ($page - 1) * $perPage; $tickets = $this->fetchAllTickets($status, $q, $type); $total = count($tickets); $stats = $this->buildTicketStats($tickets); $totalPages = max(1, (int)ceil($total / $perPage)); if ($page > $totalPages) { $page = $totalPages; $offset = ($page - 1) * $perPage; } $tickets = array_slice($tickets, $offset, $perPage); return $this->view('admin/tickets/index', [ 'title' => 'Destek Talepleri', 'tickets' => $tickets, 'filters' => [ 'status' => $status, 'q' => $q, 'type' => $type ], 'pagination' => [ 'page' => $page, 'perPage' => $perPage, 'total' => $total, 'totalPages' => $totalPages, ], 'supportCount' => $stats['supportCount'], 'productCount' => $stats['productCount'], 'adminLastCount' => $stats['adminLastCount'], 'userLastCount' => $stats['userLastCount'], 'needsReplyCount' => $stats['needsReplyCount'], 'avgMsgCount' => $stats['avgMsgCount'], 'avgFirstReplyMinutes' => $stats['avgFirstReplyMinutes'], 'staleCount' => $stats['staleCount'], ]); } public function show(int $id): Response { if ($r = $this->requireAdmin()) return $r; $type = trim((string)($_GET['type'] ?? 'support')); if (!in_array($type, ['support', 'product'], true)) { $type = 'support'; } $ticket = $this->fetchTicketByType($id, $type); if (!$ticket) { $_SESSION['flash_error'] = $type === 'product' ? 'Ürün ticket bulunamadı.' : 'Ticket bulunamadı.'; return $this->redirect('/admin/tickets'); } $this->markAdminSeen($type, $id); $this->markRelatedNotificationsRead($type, $id); $ticket = $this->fetchTicketByType($id, $type); $messages = $this->fetchMessagesByType($id, $type); return $this->view('admin/tickets/show', [ 'title' => 'Ticket', 'ticket' => $ticket, 'messages' => $messages ]); } public function reply(int $id): Response { if ($r = $this->requireAdmin()) return $r; $msg = trim((string)($_POST['message'] ?? '')); $newStatus = trim((string)($_POST['status'] ?? 'answered')); $type = trim((string)($_GET['type'] ?? 'support')); if (!in_array($type, ['support', 'product'], true)) { $type = 'support'; } if ($msg === '') { if ($this->isAjaxRequest()) { $this->json([ 'ok' => false, 'message' => 'Mesaj boş olamaz.' ], 422); } return $this->redirect('/admin/tickets/show?id=' . $id . '&type=' . $type); } if (!in_array($newStatus, ['open', 'pending', 'answered', 'closed'], true)) { $newStatus = 'answered'; } $me = Auth::user() ?? []; $adminId = (int)($me['id'] ?? 0); $pdo = DB::pdo(); try { $pdo->beginTransaction(); if ($type === 'support') { $ticket = $this->fetchTicketByType($id, 'support'); if (!$ticket) { throw new \RuntimeException('Ticket bulunamadı.'); } $pdo->prepare(" INSERT INTO support_messages (ticket_id, sender_type, sender_id, message) VALUES (?, ?, ?, ?) ")->execute([$id, 'admin', $adminId, $msg]); $pdo->prepare(" UPDATE support_tickets SET status = ?, updated_at = NOW(), admin_last_seen_at = NOW(), last_sender_type = 'admin', last_message_at = NOW() WHERE id = ? LIMIT 1 ")->execute([$newStatus, $id]); $notifTitle = 'Destek talebinize yanıt geldi'; $notifBody = 'Talebiniz yanıtlandı. Görmek için tıklayın.'; if (!empty($ticket['subject'])) { $notifBody = '“' . (string)$ticket['subject'] . '” talebiniz yanıtlandı. Görmek için tıklayın.'; } $pdo->prepare(" INSERT INTO notifications (user_id, title, body, link, is_read, created_at) VALUES (?, ?, ?, ?, 0, NOW()) ")->execute([ (int)$ticket['user_id'], $notifTitle, $notifBody, Url::to('/my/tickets/show?id=' . (int)$ticket['id']) ]); AdminLog::add($adminId, 'ticket_reply', [ 'table' => 'support_tickets', 'ticket_id' => $id, 'status' => $newStatus ]); } else { $ticket = $this->fetchTicketByType($id, 'product'); if (!$ticket) { throw new \RuntimeException('Ürün ticket bulunamadı.'); } $pdo->prepare(" INSERT INTO ticket_messages (ticket_id, sender, message, created_at) VALUES (?, 'admin', ?, NOW()) ")->execute([$id, $msg]); if ($this->hasColumn('tickets', 'admin_last_seen_at')) { $pdo->prepare(" UPDATE tickets SET status = ?, updated_at = NOW(), admin_last_seen_at = NOW(), last_sender_type = 'admin', last_message_at = NOW() WHERE id = ? LIMIT 1 ")->execute([$newStatus, $id]); } else { $pdo->prepare(" UPDATE tickets SET status = ?, updated_at = NOW(), last_sender_type = 'admin', last_message_at = NOW() WHERE id = ? LIMIT 1 ")->execute([$newStatus, $id]); } $notifTitle = 'Ürün sorunuza yanıt verildi'; $notifBody = 'Sorunuz yanıtlandı. Görmek için tıklayın.'; if (!empty($ticket['subject'])) { $notifBody = '“' . (string)$ticket['subject'] . '” ürün sorunuz yanıtlandı. Görmek için tıklayın.'; } $pdo->prepare(" INSERT INTO notifications (user_id, title, body, link, is_read, created_at) VALUES (?, ?, ?, ?, 0, NOW()) ")->execute([ (int)$ticket['user_id'], $notifTitle, $notifBody, Url::to('/my/tickets/show?type=product&id=' . (int)$ticket['id']) ]); AdminLog::add($adminId, 'product_ticket_reply', [ 'table' => 'tickets', 'ticket_id' => $id, 'status' => $newStatus ]); } $pdo->commit(); $this->sendUserReplyMailIfAvailable((array)$ticket, $type, $id, $msg); $ticket = $this->fetchTicketByType($id, $type); $messages = $this->fetchMessagesByType($id, $type); $showStats = $this->buildShowStats($ticket, $messages); $lastMessage = end($messages); if ($lastMessage === false) { $lastMessage = []; } if ($this->isAjaxRequest()) { $this->json([ 'ok' => true, 'message' => 'Yanıt kaydedildi.', 'ticket' => [ 'id' => (int)$id, 'type' => $type, 'status' => $showStats['status'], 'statusLabel' => $showStats['statusLabel'], 'sla' => $showStats['sla'], 'lastMessageAt' => $showStats['lastMessageAt'], 'lastSenderType' => $showStats['lastSenderType'], 'hasNewReply' => $showStats['hasNewReply'], ], 'stats' => $showStats, 'newMessage' => $this->normalizeMessage((array)$lastMessage, $type) ]); } $_SESSION['flash_success'] = 'Yanıt kaydedildi.'; } catch (\Throwable $e) { if ($pdo->inTransaction()) { $pdo->rollBack(); } if ($this->isAjaxRequest()) { $this->json([ 'ok' => false, 'message' => 'Yanıt kaydedilemedi: ' . $e->getMessage() ], 500); } $_SESSION['flash_error'] = 'Yanıt kaydedilemedi: ' . $e->getMessage(); } return $this->redirect('/admin/tickets/show?id=' . $id . '&type=' . $type); } public function setStatus(int $id): Response { if ($r = $this->requireAdmin()) return $r; $status = trim((string)($_POST['status'] ?? '')); $type = trim((string)($_GET['type'] ?? 'support')); if (!in_array($type, ['support', 'product'], true)) { $type = 'support'; } if (!in_array($status, ['open', 'pending', 'answered', 'closed'], true)) { return $this->redirect('/admin/tickets/show?id=' . $id . '&type=' . $type); } if ($type === 'support') { DB::pdo()->prepare(" UPDATE support_tickets SET status=?, updated_at=NOW() WHERE id=? LIMIT 1 ")->execute([$status, $id]); $table = 'support_tickets'; $logAction = 'ticket_status'; } else { DB::pdo()->prepare(" UPDATE tickets SET status=?, updated_at=NOW() WHERE id=? LIMIT 1 ")->execute([$status, $id]); $table = 'tickets'; $logAction = 'product_ticket_status'; } $adminId = (int)(Auth::user()['id'] ?? 0); AdminLog::add($adminId, $logAction, [ 'table' => $table, 'ticket_id' => $id, 'status' => $status ]); $_SESSION['flash_success'] = 'Durum güncellendi.'; return $this->redirect('/admin/tickets/show?id=' . $id . '&type=' . $type); } public function liveIndexData(): Response { if ($r = $this->requireAdmin()) return $r; $status = trim((string)($_GET['status'] ?? '')); $q = trim((string)($_GET['q'] ?? '')); $type = trim((string)($_GET['type'] ?? '')); if (!in_array($type, ['support', 'product'], true)) { $type = ''; } $allTickets = $this->fetchAllTickets($status, $q, $type); $tickets = array_slice($allTickets, 0, 20); $stats = $this->buildTicketStats($allTickets); $this->json([ 'ok' => true, 'tickets' => array_values($tickets), 'stats' => $stats ]); } public function liveShowData(): Response { if ($r = $this->requireAdmin()) return $r; $id = (int)($_GET['id'] ?? 0); $type = trim((string)($_GET['type'] ?? 'support')); $lastMessageId = max(0, (int)($_GET['last_message_id'] ?? 0)); if (!in_array($type, ['support', 'product'], true)) { $type = 'support'; } if ($id <= 0) { $this->json([ 'ok' => false, 'message' => 'Geçersiz ticket.' ], 422); } $ticket = $this->fetchTicketByType($id, $type); if (!$ticket) { $this->json([ 'ok' => false, 'message' => 'Ticket bulunamadı.' ], 404); } $this->markAdminSeen($type, $id); $ticket = $this->fetchTicketByType($id, $type); $newMessages = $this->fetchMessagesByType($id, $type, $lastMessageId); $allMessages = $this->fetchMessagesByType($id, $type); $stats = $this->buildShowStats($ticket, $allMessages); $normalized = []; foreach ($newMessages as $m) { $normalized[] = $this->normalizeMessage($m, $type); } $this->json([ 'ok' => true, 'ticket' => [ 'id' => (int)$id, 'type' => $type, 'status' => (string)($ticket['status'] ?? 'open'), 'statusLabel' => self::statusLabel((string)($ticket['status'] ?? 'open')), 'lastMessageAt' => (string)($ticket['last_message_at'] ?? ''), 'lastSenderType' => (string)($ticket['last_sender_type'] ?? ''), 'sla' => $this->getSlaLevel($ticket), ], 'stats' => $stats, 'messages' => $normalized ]); } public function liveNotify(): Response { if ($r = $this->requireAdmin()) return $r; // 1) Ticket bildirimleri $tickets = $this->fetchAllTickets('', '', ''); $ticketItems = []; $ticketUnread = 0; foreach ($tickets as $tk) { $type = (string)($tk['type'] ?? 'support'); $ticketId = (int)($tk['id'] ?? 0); $subject = trim((string)($tk['subject'] ?? '')); $hasNewReply = (int)($tk['has_new_reply'] ?? 0) === 1; $status = (string)($tk['status'] ?? 'open'); $lastAt = (string)($tk['last_msg_at'] ?? $tk['updated_at'] ?? $tk['created_at'] ?? ''); $lastSenderType = (string)($tk['last_sender_type'] ?? ''); $eventType = $type === 'product' ? 'product_ticket' : 'support_ticket'; if ($lastSenderType === 'user') { $eventType = $type === 'product' ? 'product_message' : 'support_message'; } $title = $subject !== '' ? $subject : ($type === 'product' ? 'Ürün ticketı' : 'Destek ticketı'); $text = $lastSenderType === 'user' ? 'Müşteri mesajı bekliyor' : 'Son işlem yönetim tarafından yapıldı'; $ticketItems[] = [ 'id' => $ticketId, 'type' => $type, 'event_type' => $eventType, 'subject' => $title, 'text' => $text, 'status' => $status, 'has_new' => $hasNewReply ? 1 : 0, 'created_at' => $lastAt, 'link' => Url::to('/admin/tickets/show?id=' . $ticketId . '&type=' . $type), 'icon' => '', ]; if ($hasNewReply) { $ticketUnread++; } } usort($ticketItems, function ($a, $b) { return strtotime((string)($b['created_at'] ?? 'now')) <=> strtotime((string)($a['created_at'] ?? 'now')); }); // 2) Notifications tablosundan admin bildirimleri - DIRECT SQL $genericItems = []; $genericUnread = 0; try { $st = DB::pdo()->prepare(" SELECT id, type, title, body, link, icon, is_read, created_at, updated_at FROM notifications WHERE audience = ? ORDER BY COALESCE(updated_at, created_at) DESC, id DESC LIMIT 4 "); $st->execute(['admin']); $rows = $st->fetchAll() ?: []; foreach ($rows as $row) { $type = trim((string)($row['type'] ?? 'generic')); $eventType = $type === 'new_user_register' ? 'new_user' : 'system'; $isRead = (int)($row['is_read'] ?? 0) === 1; $genericItems[] = [ 'id' => (int)($row['id'] ?? 0), 'type' => 'generic', 'event_type' => $eventType, 'subject' => trim((string)($row['title'] ?? 'Bildirim')) ?: 'Bildirim', 'text' => trim((string)($row['body'] ?? '')) ?: 'Sistem bildirimi.', 'status' => 'open', 'has_new' => $isRead ? 0 : 1, 'created_at' => (string)($row['updated_at'] ?? $row['created_at'] ?? ''), 'link' => trim((string)($row['link'] ?? '')) ?: Url::to('/admin/notifications'), 'icon' => trim((string)($row['icon'] ?? '')), ]; if (!$isRead) { $genericUnread++; } } } catch (\Throwable $e) { $genericItems = []; $genericUnread = 0; } // 3) Dropdown'da garanti görünüm: // Önce notifications, sonra ticket $items = array_merge($genericItems, array_slice($ticketItems, 0, 4)); $this->json([ 'ok' => true, 'unread_count' => $ticketUnread + $genericUnread, 'total_count' => count($items), 'items' => $items, ]); } public function stream(): void { if ($this->requireAdmin()) { return; } @ini_set('output_buffering', 'off'); @ini_set('zlib.output_compression', '0'); header('Content-Type: text/event-stream; charset=utf-8'); header('Cache-Control: no-cache'); header('Connection: keep-alive'); while (true) { $allTickets = $this->fetchAllTickets('', '', ''); $supportNew = 0; $productNew = 0; $supportTickets = 0; $productTickets = 0; foreach ($allTickets as $tk) { $type = (string)($tk['type'] ?? 'support'); $hasNew = (int)($tk['has_new_reply'] ?? 0) === 1; if ($type === 'support') { $supportTickets++; if ($hasNew) $supportNew++; } else { $productTickets++; if ($hasNew) $productNew++; } } $payload = [ 'ok' => true, 'supportNew' => $supportNew, 'productNew' => $productNew, 'supportTickets' => $supportTickets, 'productTickets' => $productTickets, 'total' => $supportNew + $productNew, 'unread_count' => $supportNew + $productNew, ]; echo 'data: ' . json_encode($payload, JSON_UNESCAPED_UNICODE) . "\n\n"; @ob_flush(); @flush(); if (connection_aborted()) { break; } sleep(8); } exit; } /** * @param array $t */ private function getSlaLevel(array $t): string { $last = strtotime((string)($t['last_msg_at'] ?? $t['last_message_at'] ?? $t['updated_at'] ?? 'now')); $diff = time() - $last; if ($diff > 3600) return 'critical'; if ($diff > 1800) return 'high'; if ($diff > 600) return 'medium'; return 'normal'; } private function detectPriority(string $text): string { $text = mb_strtolower(trim($text), 'UTF-8'); if ($text === '') return 'normal'; if ( str_contains($text, 'acil') || str_contains($text, 'hemen') || str_contains($text, 'çalışmıyor') || str_contains($text, 'bozuk') || str_contains($text, 'arıza') || str_contains($text, 'hata') ) { return 'high'; } if (mb_strlen($text, 'UTF-8') > 220) { return 'medium'; } return 'normal'; }} Sunucu Hatası

500

Sunucu tarafında bir hata oluştu.

İşlem tamamlanamadı. Lütfen sayfayı yenileyip tekrar deneyin.