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:
2026-03-12 19:25:51 +02:00
parent 1aea4bde20
commit fa8aaed11e
5 changed files with 645 additions and 12 deletions

328
api.php
View File

@@ -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']);