feat: ticket reply improvements + priority + templates + Telegram

Reply form:
- Mailbox/sender selection dropdown (choose which email to reply from)
- CC field (auto-filled from incoming email CC, editable)
- Reply templates dropdown (quick insert pre-made responses)

Priority system:
- Three levels: normaali, tärkeä, urgent
- Priority dropdown in ticket detail view
- Priority-based sorting (urgent/tärkeä always on top)
- Visual indicators in ticket list (colored rows, emoji badges)
- Priority emails: per-company email list that auto-sets "tärkeä"

Response templates:
- CRUD management in Settings tab
- Dropdown selector in reply form
- Templates insert into textarea

Telegram alerts:
- Bot token + chat ID configuration in Settings
- Test button to verify connection
- Auto-alert on urgent tickets (both manual and from email fetch)
- Alert on priority email matches

Database changes:
- New tables: reply_templates, customer_priority_emails
- New columns: tickets.cc, tickets.priority
- ALTER TABLE migration in initDatabase()

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-10 17:42:05 +02:00
parent 3b7def1186
commit 8485da8cbf
5 changed files with 591 additions and 23 deletions

212
api.php
View File

@@ -262,6 +262,10 @@ class ImapClient {
$date = $dateStr ? @date('Y-m-d H:i:s', strtotime($dateStr)) : date('Y-m-d H:i:s');
if (!$date) $date = date('Y-m-d H:i:s');
// Parse CC
$ccRaw = $this->decodeMimeHeader($headers['cc'] ?? '');
$ccEmails = $this->parseCcAddresses($ccRaw);
// Fetch body (text part)
$body = $this->fetchBody($num);
@@ -274,6 +278,7 @@ class ImapClient {
'references' => $references,
'date' => $date,
'body' => $body,
'cc' => $ccEmails,
];
}
@@ -376,6 +381,22 @@ class ImapClient {
return ['name' => '', 'email' => $from];
}
private function parseCcAddresses(string $cc): string {
$cc = trim($cc);
if (!$cc) return '';
// Parse "Name <email>, Name2 <email2>" or "email1, email2"
$emails = [];
// Split on comma, but be careful with quoted strings
$parts = preg_split('/,\s*(?=(?:[^"]*"[^"]*")*[^"]*$)/', $cc);
foreach ($parts as $part) {
$part = trim($part);
if (!$part) continue;
$parsed = $this->parseFrom($part);
if ($parsed['email']) $emails[] = $parsed['email'];
}
return implode(', ', $emails);
}
private function decodeMimeHeader(string $str): string {
if (strpos($str, '=?') === false) return trim($str);
$decoded = '';
@@ -496,7 +517,39 @@ class ImapClient {
// ==================== TICKETS HELPER ====================
function sendTicketMail(string $to, string $subject, string $body, string $inReplyTo = '', string $references = '', ?array $mailbox = null): bool {
function sendTelegramAlert(string $companyId, array $ticket): void {
$config = dbLoadConfig();
$botToken = $config['telegram_bot_token'] ?? '';
$chatId = $config['telegram_chat_id'] ?? '';
if (!$botToken || !$chatId) return;
$text = "🚨 *URGENT TIKETTI*\n\n";
$text .= "📋 *" . ($ticket['subject'] ?? '(Ei aihetta)') . "*\n";
$text .= "👤 " . ($ticket['from_name'] ?? $ticket['from_email'] ?? 'Tuntematon') . "\n";
$text .= "📧 " . ($ticket['from_email'] ?? '') . "\n";
$text .= "🏢 " . $companyId . "\n";
$text .= "🕐 " . date('d.m.Y H:i');
$url = "https://api.telegram.org/bot{$botToken}/sendMessage";
$data = [
'chat_id' => $chatId,
'text' => $text,
'parse_mode' => 'Markdown',
];
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($data),
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 5,
]);
curl_exec($ch);
curl_close($ch);
}
function sendTicketMail(string $to, string $subject, string $body, string $inReplyTo = '', string $references = '', ?array $mailbox = null, string $cc = ''): bool {
$fromEmail = $mailbox['smtp_from_email'] ?? $mailbox['imap_user'] ?? MAIL_FROM;
$fromName = $mailbox['smtp_from_name'] ?? $mailbox['nimi'] ?? 'Asiakaspalvelu';
@@ -504,6 +557,9 @@ function sendTicketMail(string $to, string $subject, string $body, string $inRep
$headers .= "Content-Type: text/plain; charset=UTF-8\r\n";
$headers .= "From: {$fromName} <{$fromEmail}>\r\n";
$headers .= "Reply-To: {$fromEmail}\r\n";
if ($cc) {
$headers .= "Cc: {$cc}\r\n";
}
if ($inReplyTo) {
$headers .= "In-Reply-To: {$inReplyTo}\r\n";
$headers .= "References: " . ($references ? $references . ' ' : '') . $inReplyTo . "\r\n";
@@ -599,9 +655,12 @@ switch ($action) {
case 'config':
requireAdmin();
$companyId = requireCompany();
$globalConf = dbLoadConfig();
echo json_encode([
'api_key' => dbGetCompanyApiKey($companyId),
'cors_origins' => dbGetCompanyCorsOrigins($companyId),
'telegram_bot_token' => $globalConf['telegram_bot_token'] ?? '',
'telegram_chat_id' => $globalConf['telegram_chat_id'] ?? '',
]);
break;
@@ -617,13 +676,46 @@ switch ($action) {
$origins = array_filter(array_map('trim', explode("\n", $input['cors_origins'])));
dbSetCompanyCorsOrigins($companyId, array_values($origins));
}
dbAddLog($companyId, currentUser(), 'config_update', '', '', 'Päivitti API-asetukset');
// Telegram-asetukset (globaalit, tallennetaan config-tauluun)
if (isset($input['telegram_bot_token'])) {
dbSaveConfig(['telegram_bot_token' => trim($input['telegram_bot_token'])]);
}
if (isset($input['telegram_chat_id'])) {
dbSaveConfig(['telegram_chat_id' => trim($input['telegram_chat_id'])]);
}
dbAddLog($companyId, currentUser(), 'config_update', '', '', 'Päivitti asetuksia');
echo json_encode([
'api_key' => dbGetCompanyApiKey($companyId),
'cors_origins' => dbGetCompanyCorsOrigins($companyId),
]);
break;
case 'telegram_test':
requireAdmin();
if ($method !== 'POST') break;
$config = dbLoadConfig();
$botToken = $config['telegram_bot_token'] ?? '';
$chatId = $config['telegram_chat_id'] ?? '';
if (!$botToken || !$chatId) {
http_response_code(400);
echo json_encode(['error' => 'Telegram Bot Token ja Chat ID vaaditaan']);
break;
}
$url = "https://api.telegram.org/bot{$botToken}/sendMessage";
$data = ['chat_id' => $chatId, 'text' => '✅ Noxus Intra Telegram-hälytys toimii!', 'parse_mode' => 'Markdown'];
$ch = curl_init($url);
curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_POSTFIELDS => json_encode($data), CURLOPT_HTTPHEADER => ['Content-Type: application/json'], CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 5]);
$resp = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode === 200) {
echo json_encode(['success' => true]);
} else {
http_response_code(400);
echo json_encode(['error' => 'Telegram virhe: ' . $resp]);
}
break;
case 'generate_api_key':
requireAdmin();
$companyId = requireCompany();
@@ -1639,6 +1731,12 @@ switch ($action) {
}
}
} else {
// Tarkista onko lähettäjä priority-listalla
$ticketPriority = 'normaali';
if (dbIsPriorityEmail($companyId, $email['from_email'])) {
$ticketPriority = 'tärkeä';
}
$ticket = [
'id' => generateId(),
'subject' => $email['subject'] ?: '(Ei aihetta)',
@@ -1650,6 +1748,8 @@ switch ($action) {
'customer_id' => '',
'customer_name' => '',
'tags' => [],
'cc' => $email['cc'] ?? '',
'priority' => $ticketPriority,
'auto_close_at' => '',
'mailbox_id' => $mailbox['id'],
'created' => $email['date'],
@@ -1692,6 +1792,10 @@ switch ($action) {
}
dbSaveTicket($companyId, $ticket);
// Telegram-hälytys tärkeille/urgentille
if ($ticket['priority'] === 'urgent' || $ticket['priority'] === 'tärkeä') {
sendTelegramAlert($companyId, $ticket);
}
$newCount++;
}
@@ -1714,6 +1818,8 @@ switch ($action) {
$input = json_decode(file_get_contents('php://input'), true);
$id = $input['id'] ?? '';
$body = trim($input['body'] ?? '');
$replyMailboxId = $input['mailbox_id'] ?? '';
$replyCc = trim($input['cc'] ?? '');
if (empty($body)) {
http_response_code(400);
echo json_encode(['error' => 'Viesti ei voi olla tyhjä']);
@@ -1734,10 +1840,12 @@ switch ($action) {
}
// Send email — hae postilaatikon asetukset
// Käytä frontendistä valittua mailboxia tai tiketin oletusta
$companyConf = dbGetCompanyConfig($companyId);
$useMailboxId = $replyMailboxId ?: ($t['mailbox_id'] ?? '');
$replyMailbox = null;
foreach ($companyConf['mailboxes'] ?? [] as $mb) {
if ($mb['id'] === ($t['mailbox_id'] ?? '')) { $replyMailbox = $mb; break; }
if ($mb['id'] === $useMailboxId) { $replyMailbox = $mb; break; }
}
// Fallback: käytä ensimmäistä postilaatikkoa
if (!$replyMailbox && !empty($companyConf['mailboxes'])) {
@@ -1745,7 +1853,7 @@ switch ($action) {
}
// Hae käyttäjän allekirjoitus tälle postilaatikolle
$mailboxId = $t['mailbox_id'] ?? '';
$mailboxId = $replyMailbox['id'] ?? '';
$signature = '';
$sigUser = dbGetUser($_SESSION['user_id']);
if ($sigUser) {
@@ -1753,8 +1861,11 @@ switch ($action) {
}
$emailBody = $signature ? $body . "\n\n-- \n" . $signature : $body;
// CC: käytä frontendistä annettua CC:tä, tai tiketin alkuperäistä CC:tä
$ccToSend = $replyCc !== '' ? $replyCc : ($t['cc'] ?? '');
$subject = 'Re: ' . $t['subject'];
$sent = sendTicketMail($t['from_email'], $subject, $emailBody, $lastMsgId, trim($allRefs), $replyMailbox);
$sent = sendTicketMail($t['from_email'], $subject, $emailBody, $lastMsgId, trim($allRefs), $replyMailbox, $ccToSend);
if (!$sent) {
http_response_code(500);
@@ -1762,6 +1873,11 @@ switch ($action) {
break 2;
}
// Päivitä tiketin CC jos muuttunut
if ($replyCc !== '' && $replyCc !== ($t['cc'] ?? '')) {
$t['cc'] = $replyCc;
}
// Add reply to ticket (tallennetaan allekirjoituksen kanssa)
$reply = [
'id' => generateId(),
@@ -2082,6 +2198,92 @@ switch ($action) {
echo json_encode(['success' => true]);
break;
case 'ticket_priority':
requireAuth();
$companyId = requireCompanyOrParam();
if ($method !== 'POST') break;
$input = json_decode(file_get_contents('php://input'), true);
$id = $input['id'] ?? '';
$priority = $input['priority'] ?? 'normaali';
if (!in_array($priority, ['normaali', 'tärkeä', 'urgent'])) $priority = 'normaali';
$tickets = dbLoadTickets($companyId);
foreach ($tickets as $t) {
if ($t['id'] === $id) {
$t['priority'] = $priority;
$t['updated'] = date('Y-m-d H:i:s');
dbSaveTicket($companyId, $t);
// Telegram-hälytys urgentille
if ($priority === 'urgent') {
sendTelegramAlert($companyId, $t);
}
echo json_encode($t);
break 2;
}
}
http_response_code(404);
echo json_encode(['error' => 'Tikettiä ei löydy']);
break;
// ---------- VASTAUSPOHJAT ----------
case 'reply_templates':
requireAuth();
$companyId = requireCompany();
echo json_encode(dbLoadTemplates($companyId));
break;
case 'reply_template_save':
requireAuth();
$companyId = requireCompany();
if ($method !== 'POST') break;
$input = json_decode(file_get_contents('php://input'), true);
if (empty($input['nimi']) || empty($input['body'])) {
http_response_code(400);
echo json_encode(['error' => 'Nimi ja sisältö vaaditaan']);
break;
}
$tpl = [
'id' => $input['id'] ?? generateId(),
'nimi' => trim($input['nimi']),
'body' => trim($input['body']),
'sort_order' => intval($input['sort_order'] ?? 0),
];
dbSaveTemplate($companyId, $tpl);
echo json_encode(['success' => true, 'template' => $tpl]);
break;
case 'reply_template_delete':
requireAuth();
$companyId = requireCompany();
if ($method !== 'POST') break;
$input = json_decode(file_get_contents('php://input'), true);
dbDeleteTemplate($input['id'] ?? '');
echo json_encode(['success' => true]);
break;
// ---------- PRIORITY EMAILS ----------
case 'priority_emails':
requireAuth();
$companyId = requireCompany();
echo json_encode(dbLoadPriorityEmails($companyId));
break;
case 'priority_emails_save':
requireAuth();
$companyId = requireCompany();
if ($method !== 'POST') break;
$input = json_decode(file_get_contents('php://input'), true);
$emails = $input['emails'] ?? [];
// Poista vanhat ja lisää uudet
_dbExecute("DELETE FROM customer_priority_emails WHERE company_id = ?", [$companyId]);
foreach ($emails as $email) {
$email = strtolower(trim($email));
if ($email) {
_dbExecute("INSERT IGNORE INTO customer_priority_emails (company_id, email) VALUES (?, ?)", [$companyId, $email]);
}
}
echo json_encode(['success' => true]);
break;
// ---------- COMPANY MANAGEMENT ----------
case 'companies':
requireAuth();