Lisää Zammad-integraatio ja modulaarinen integraatiot-hallinta
- Uusi integrations-taulu tietokantaan (moduulimalli: type, enabled, config) - ZammadClient-luokka: tiketit, artikkelit, vastaukset, ryhmät - API-endpointit: integration_save, integration_test, zammad_sync, zammad_reply, zammad_groups - Synkronointi: Zammad-tiketit → intran tiketit, artikkelit → viestit - Vastaukset: Zammad-tiketteihin vastaus kulkee Zammad API:n kautta (→ O365) - UI: Integraatiot-osio API-välilehdellä, toggle-kytkimet, Zammad-konfiguraatio - tickets.zammad_ticket_id ja ticket_messages.zammad_article_id linkitys Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
328
api.php
328
api.php
@@ -233,6 +233,99 @@ function sendMail(string $to, string $subject, string $htmlBody): bool {
|
||||
return mail($to, $subject, $htmlBody, $headers, '-f ' . MAIL_FROM);
|
||||
}
|
||||
|
||||
// ==================== ZAMMAD CLIENT ====================
|
||||
|
||||
class ZammadClient {
|
||||
private string $url;
|
||||
private string $token;
|
||||
|
||||
public function __construct(string $url, string $token) {
|
||||
$this->url = rtrim($url, '/');
|
||||
$this->token = $token;
|
||||
}
|
||||
|
||||
private function request(string $method, string $endpoint, ?array $data = null): array {
|
||||
$url = $this->url . '/api/v1/' . ltrim($endpoint, '/');
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Authorization: Token token=' . $this->token,
|
||||
'Content-Type: application/json',
|
||||
],
|
||||
]);
|
||||
if ($method === 'POST') {
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
if ($data) curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
|
||||
} elseif ($method === 'PUT') {
|
||||
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PUT');
|
||||
if ($data) curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
|
||||
}
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($httpCode >= 400) {
|
||||
$err = json_decode($response, true);
|
||||
throw new \RuntimeException('Zammad API error (' . $httpCode . '): ' . ($err['error'] ?? $response));
|
||||
}
|
||||
return json_decode($response, true) ?: [];
|
||||
}
|
||||
|
||||
/** Hae tikettejä (search API palauttaa kaikki joihin on oikeus) */
|
||||
public function getTickets(array $groupIds = [], int $page = 1, int $perPage = 100): array {
|
||||
if (!empty($groupIds)) {
|
||||
$query = implode(' OR ', array_map(fn($id) => 'group_id:' . $id, $groupIds));
|
||||
} else {
|
||||
$query = '*';
|
||||
}
|
||||
return $this->request('GET', 'tickets/search?query=' . urlencode($query) . '&per_page=' . $perPage . '&page=' . $page . '&expand=true');
|
||||
}
|
||||
|
||||
/** Hae yksittäinen tiketti */
|
||||
public function getTicket(int $id): array {
|
||||
return $this->request('GET', 'tickets/' . $id . '?expand=true');
|
||||
}
|
||||
|
||||
/** Hae tiketin artikkelit */
|
||||
public function getArticles(int $ticketId): array {
|
||||
return $this->request('GET', 'ticket_articles/by_ticket/' . $ticketId . '?expand=true');
|
||||
}
|
||||
|
||||
/** Lähetä vastaus tikettiin */
|
||||
public function createArticle(int $ticketId, string $body, string $to = '', string $subject = '', string $type = 'email'): array {
|
||||
$data = [
|
||||
'ticket_id' => $ticketId,
|
||||
'body' => $body,
|
||||
'content_type' => 'text/html',
|
||||
'type' => $type,
|
||||
'internal' => false,
|
||||
'sender' => 'Agent',
|
||||
];
|
||||
if ($to) $data['to'] = $to;
|
||||
if ($subject) $data['subject'] = $subject;
|
||||
return $this->request('POST', 'ticket_articles', $data);
|
||||
}
|
||||
|
||||
/** Päivitä tiketin tila */
|
||||
public function updateTicket(int $ticketId, array $fields): array {
|
||||
return $this->request('PUT', 'tickets/' . $ticketId, $fields);
|
||||
}
|
||||
|
||||
/** Hae ryhmät */
|
||||
public function getGroups(): array {
|
||||
return $this->request('GET', 'groups?expand=true');
|
||||
}
|
||||
|
||||
/** Testaa yhteys */
|
||||
public function testConnection(): array {
|
||||
$user = $this->request('GET', 'users/me');
|
||||
$groups = $this->getGroups();
|
||||
return ['user' => $user['login'] ?? '?', 'groups' => count($groups), 'ok' => true];
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== IMAP CLIENT (socket-pohjainen, ei vaadi php-imap) ====================
|
||||
|
||||
class ImapClient {
|
||||
@@ -4875,6 +4968,241 @@ switch ($action) {
|
||||
}
|
||||
break;
|
||||
|
||||
// ==================== INTEGRAATIOT ====================
|
||||
|
||||
case 'integrations':
|
||||
requireCompany();
|
||||
$integrations = dbLoadIntegrations($companyId);
|
||||
// Piilota tokenit/salasanat
|
||||
foreach ($integrations as &$integ) {
|
||||
if ($integ['config']) {
|
||||
$cfg = is_string($integ['config']) ? json_decode($integ['config'], true) : $integ['config'];
|
||||
if (isset($cfg['token'])) $cfg['token'] = str_repeat('*', 8);
|
||||
$integ['config'] = $cfg;
|
||||
}
|
||||
}
|
||||
echo json_encode($integrations);
|
||||
break;
|
||||
|
||||
case 'integration_save':
|
||||
requireCompany();
|
||||
$type = $input['type'] ?? '';
|
||||
$enabled = (bool)($input['enabled'] ?? false);
|
||||
$config = $input['config'] ?? [];
|
||||
if (!$type) { http_response_code(400); echo json_encode(['error' => 'Tyyppi puuttuu']); break; }
|
||||
|
||||
// Jos token on maskattua (********), säilytä vanha
|
||||
if (isset($config['token']) && preg_match('/^\*+$/', $config['token'])) {
|
||||
$old = dbGetIntegration($companyId, $type);
|
||||
if ($old && isset($old['config']['token'])) {
|
||||
$config['token'] = $old['config']['token'];
|
||||
}
|
||||
}
|
||||
|
||||
dbSaveIntegration($companyId, $type, $enabled, $config);
|
||||
echo json_encode(['ok' => true]);
|
||||
break;
|
||||
|
||||
case 'integration_test':
|
||||
requireCompany();
|
||||
$type = $input['type'] ?? $_GET['type'] ?? '';
|
||||
$integ = dbGetIntegration($companyId, $type);
|
||||
if (!$integ) { http_response_code(404); echo json_encode(['error' => 'Integraatiota ei löydy']); break; }
|
||||
|
||||
try {
|
||||
if ($type === 'zammad') {
|
||||
$cfg = $integ['config'];
|
||||
$z = new ZammadClient($cfg['url'], $cfg['token']);
|
||||
$result = $z->testConnection();
|
||||
echo json_encode($result);
|
||||
} else {
|
||||
echo json_encode(['error' => 'Tuntematon tyyppi']);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['error' => $e->getMessage()]);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'zammad_groups':
|
||||
requireCompany();
|
||||
$integ = dbGetIntegration($companyId, 'zammad');
|
||||
if (!$integ || !$integ['enabled']) { http_response_code(400); echo json_encode(['error' => 'Zammad ei käytössä']); break; }
|
||||
try {
|
||||
$z = new ZammadClient($integ['config']['url'], $integ['config']['token']);
|
||||
echo json_encode($z->getGroups());
|
||||
} catch (\Throwable $e) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['error' => $e->getMessage()]);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'zammad_sync':
|
||||
requireCompany();
|
||||
$integ = dbGetIntegration($companyId, 'zammad');
|
||||
if (!$integ || !$integ['enabled']) { http_response_code(400); echo json_encode(['error' => 'Zammad ei käytössä']); break; }
|
||||
|
||||
try {
|
||||
$cfg = $integ['config'];
|
||||
$z = new ZammadClient($cfg['url'], $cfg['token']);
|
||||
$groupIds = $cfg['group_ids'] ?? [];
|
||||
$syncedGroupNames = $cfg['group_names'] ?? [];
|
||||
|
||||
// Hae tikettejä Zammadista
|
||||
$allTickets = [];
|
||||
$page = 1;
|
||||
do {
|
||||
$batch = $z->getTickets($groupIds, $page, 100);
|
||||
if (empty($batch)) break;
|
||||
$allTickets = array_merge($allTickets, $batch);
|
||||
$page++;
|
||||
} while (count($batch) >= 100 && $page <= 10);
|
||||
|
||||
$created = 0;
|
||||
$updated = 0;
|
||||
$messagesAdded = 0;
|
||||
|
||||
// Zammad state → intran status
|
||||
$stateMap = [
|
||||
'new' => 'uusi', 'open' => 'avoin', 'pending reminder' => 'odottaa',
|
||||
'pending close' => 'odottaa', 'closed' => 'suljettu',
|
||||
'merged' => 'suljettu', 'removed' => 'suljettu',
|
||||
];
|
||||
// Zammad priority → intran priority
|
||||
$priorityMap = [
|
||||
'1 low' => 'matala', '2 normal' => 'normaali', '3 high' => 'korkea',
|
||||
];
|
||||
|
||||
foreach ($allTickets as $zt) {
|
||||
$zammadId = (int)$zt['id'];
|
||||
$existing = dbGetTicketByZammadId($companyId, $zammadId);
|
||||
|
||||
// Mäppää Zammad-ryhmä → intran tikettityyppi
|
||||
$group = $zt['group'] ?? '';
|
||||
$type = 'muu';
|
||||
if (stripos($group, 'support') !== false || stripos($group, 'tech') !== false) $type = 'tekniikka';
|
||||
elseif (stripos($group, 'sales') !== false) $type = 'laskutus';
|
||||
elseif (stripos($group, 'abuse') !== false) $type = 'abuse';
|
||||
elseif (stripos($group, 'domain') !== false) $type = 'muu';
|
||||
elseif (stripos($group, 'noc') !== false) $type = 'vika';
|
||||
|
||||
$status = $stateMap[strtolower($zt['state'] ?? '')] ?? 'uusi';
|
||||
$priority = $priorityMap[strtolower($zt['priority'] ?? '')] ?? 'normaali';
|
||||
|
||||
if (!$existing) {
|
||||
// Uusi tiketti
|
||||
$ticketId = substr(uniqid(), -8) . bin2hex(random_bytes(2));
|
||||
$now = date('Y-m-d H:i:s');
|
||||
_dbExecute(
|
||||
"INSERT INTO tickets (id, company_id, subject, from_email, from_name, status, type, priority, zammad_ticket_id, ticket_number, created, updated)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
[$ticketId, $companyId, $zt['title'] ?? '', $zt['customer'] ?? '', $zt['customer'] ?? '',
|
||||
$status, $type, $priority, $zammadId, (int)($zt['number'] ?? 0),
|
||||
$zt['created_at'] ? date('Y-m-d H:i:s', strtotime($zt['created_at'])) : $now,
|
||||
$zt['updated_at'] ? date('Y-m-d H:i:s', strtotime($zt['updated_at'])) : $now]
|
||||
);
|
||||
$created++;
|
||||
} else {
|
||||
$ticketId = $existing['id'];
|
||||
// Päivitä status/priority
|
||||
_dbExecute(
|
||||
"UPDATE tickets SET status = ?, type = ?, priority = ?, subject = ?, updated = ? WHERE id = ?",
|
||||
[$status, $type, $priority, $zt['title'] ?? '', date('Y-m-d H:i:s', strtotime($zt['updated_at'] ?? 'now')), $ticketId]
|
||||
);
|
||||
$updated++;
|
||||
}
|
||||
|
||||
// Synkkaa artikkelit → viestit
|
||||
try {
|
||||
$articles = $z->getArticles($zammadId);
|
||||
foreach ($articles as $art) {
|
||||
if (($art['internal'] ?? false)) continue; // Ohita sisäiset muistiinpanot
|
||||
$artId = (int)$art['id'];
|
||||
$existingMsg = dbGetMessageByZammadArticleId($ticketId, $artId);
|
||||
if ($existingMsg) continue;
|
||||
|
||||
$msgId = substr(uniqid(), -8) . bin2hex(random_bytes(2));
|
||||
$msgType = ($art['sender'] ?? '') === 'Customer' ? 'incoming' : 'outgoing';
|
||||
$body = $art['body'] ?? '';
|
||||
// Strippaa HTML tagit jos content_type on text/html
|
||||
if (($art['content_type'] ?? '') === 'text/html') {
|
||||
$body = strip_tags($body, '<br><p><div><a><b><i><strong><em><ul><ol><li>');
|
||||
}
|
||||
|
||||
_dbExecute(
|
||||
"INSERT INTO ticket_messages (id, ticket_id, type, from_email, from_name, body, timestamp, message_id, zammad_article_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
[$msgId, $ticketId, $msgType,
|
||||
$art['from'] ?? '', $art['from'] ?? '',
|
||||
$body,
|
||||
$art['created_at'] ? date('Y-m-d H:i:s', strtotime($art['created_at'])) : date('Y-m-d H:i:s'),
|
||||
$art['message_id'] ?? '',
|
||||
$artId]
|
||||
);
|
||||
$messagesAdded++;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// Artikkelihaku epäonnistui — jatka silti
|
||||
error_log("Zammad articles error for ticket $zammadId: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
'ok' => true,
|
||||
'tickets_found' => count($allTickets),
|
||||
'created' => $created,
|
||||
'updated' => $updated,
|
||||
'messages_added' => $messagesAdded,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['error' => $e->getMessage()]);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'zammad_reply':
|
||||
requireCompany();
|
||||
$ticketId = $input['ticket_id'] ?? '';
|
||||
$body = $input['body'] ?? '';
|
||||
if (!$ticketId || !$body) { http_response_code(400); echo json_encode(['error' => 'Tiketti ja viesti vaaditaan']); break; }
|
||||
|
||||
$ticket = _dbFetchRow("SELECT * FROM tickets WHERE id = ? AND company_id = ?", [$ticketId, $companyId]);
|
||||
if (!$ticket || !$ticket['zammad_ticket_id']) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => 'Tiketti ei ole Zammad-tiketti']);
|
||||
break;
|
||||
}
|
||||
|
||||
$integ = dbGetIntegration($companyId, 'zammad');
|
||||
if (!$integ || !$integ['enabled']) { http_response_code(400); echo json_encode(['error' => 'Zammad ei käytössä']); break; }
|
||||
|
||||
try {
|
||||
$z = new ZammadClient($integ['config']['url'], $integ['config']['token']);
|
||||
$result = $z->createArticle(
|
||||
(int)$ticket['zammad_ticket_id'],
|
||||
$body,
|
||||
$ticket['from_email'] ?? '',
|
||||
$ticket['subject'] ?? ''
|
||||
);
|
||||
|
||||
// Tallenna myös paikalliseen tietokantaan
|
||||
$msgId = substr(uniqid(), -8) . bin2hex(random_bytes(2));
|
||||
_dbExecute(
|
||||
"INSERT INTO ticket_messages (id, ticket_id, type, from_email, from_name, body, timestamp, zammad_article_id)
|
||||
VALUES (?, ?, 'outgoing', ?, ?, ?, ?, ?)",
|
||||
[$msgId, $ticketId,
|
||||
$result['from'] ?? '', $result['from'] ?? '',
|
||||
$body, date('Y-m-d H:i:s'),
|
||||
(int)($result['id'] ?? 0)]
|
||||
);
|
||||
|
||||
echo json_encode(['ok' => true, 'message_id' => $msgId]);
|
||||
} catch (\Throwable $e) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['error' => $e->getMessage()]);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
http_response_code(404);
|
||||
echo json_encode(['error' => 'Tuntematon toiminto']);
|
||||
|
||||
Reference in New Issue
Block a user