Local statuses (odottaa vastausta, kasittelyssa, odottaa) set in the intra UI were being overwritten by Zammad's state on every sync. Now these statuses are preserved unless Zammad explicitly closes the ticket. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
6300 lines
266 KiB
PHP
6300 lines
266 KiB
PHP
<?php
|
|
// Turvalliset session-asetukset
|
|
ini_set('session.cookie_httponly', 1);
|
|
ini_set('session.cookie_secure', 1);
|
|
ini_set('session.use_strict_mode', 1);
|
|
ini_set('session.cookie_samesite', 'Lax');
|
|
session_start();
|
|
|
|
require_once __DIR__ . '/db.php';
|
|
initDatabase();
|
|
|
|
header('Content-Type: application/json');
|
|
header('X-Content-Type-Options: nosniff');
|
|
|
|
define('DATA_DIR', __DIR__ . '/data');
|
|
// Dynaaminen SITE_URL domainin mukaan
|
|
define('SITE_URL', 'https://' . ($_SERVER['HTTP_HOST'] ?? 'intra.noxus.fi'));
|
|
|
|
// Sähköpostiasetukset (fallback)
|
|
define('MAIL_FROM', 'noreply@noxus.fi');
|
|
define('MAIL_FROM_NAME', 'Noxus HUB');
|
|
|
|
// Varmista data-kansio (tiedostoja varten)
|
|
if (!file_exists(DATA_DIR)) mkdir(DATA_DIR, 0755, true);
|
|
|
|
$method = $_SERVER['REQUEST_METHOD'];
|
|
$action = $_GET['action'] ?? '';
|
|
|
|
// ==================== HELPERS ====================
|
|
|
|
function requireAuth() {
|
|
if (!isset($_SESSION['user_id'])) {
|
|
http_response_code(401);
|
|
echo json_encode(['error' => 'Kirjaudu sisään']);
|
|
exit;
|
|
}
|
|
}
|
|
|
|
function requireAdmin() {
|
|
requireAuth();
|
|
if (!isCompanyAdmin()) {
|
|
http_response_code(403);
|
|
echo json_encode(['error' => 'Vain ylläpitäjä voi tehdä tämän']);
|
|
exit;
|
|
}
|
|
}
|
|
|
|
function requireSuperAdmin() {
|
|
requireAuth();
|
|
if (($_SESSION['role'] ?? '') !== 'superadmin') {
|
|
http_response_code(403);
|
|
echo json_encode(['error' => 'Vain pääkäyttäjä voi tehdä tämän']);
|
|
exit;
|
|
}
|
|
}
|
|
|
|
function isSuperAdmin(): bool {
|
|
return ($_SESSION['role'] ?? '') === 'superadmin';
|
|
}
|
|
|
|
function isCompanyAdmin(): bool {
|
|
if (($_SESSION['role'] ?? '') === 'superadmin') return true;
|
|
return ($_SESSION['company_role'] ?? '') === 'admin';
|
|
}
|
|
|
|
function currentUser(): string {
|
|
return $_SESSION['username'] ?? 'tuntematon';
|
|
}
|
|
|
|
function generateId(): string {
|
|
return bin2hex(random_bytes(8));
|
|
}
|
|
|
|
function generateToken(): string {
|
|
return bin2hex(random_bytes(32));
|
|
}
|
|
|
|
// ==================== MULTI-COMPANY ====================
|
|
|
|
function getCompanyDir(?string $companyId = null): string {
|
|
$id = $companyId ?? ($_SESSION['company_id'] ?? '');
|
|
if (empty($id) || !preg_match('/^[a-z0-9-]+$/', $id)) {
|
|
http_response_code(400);
|
|
echo json_encode(['error' => 'Yritystä ei ole valittu']);
|
|
exit;
|
|
}
|
|
$dir = DATA_DIR . '/companies/' . $id;
|
|
if (!file_exists($dir)) mkdir($dir, 0755, true);
|
|
return $dir;
|
|
}
|
|
|
|
function requireCompany(): string {
|
|
$companyId = $_SESSION['company_id'] ?? '';
|
|
if (empty($companyId)) {
|
|
http_response_code(400);
|
|
echo json_encode(['error' => 'Valitse ensin yritys']);
|
|
exit;
|
|
}
|
|
$userCompanies = $_SESSION['companies'] ?? [];
|
|
$isSuperadmin = ($_SESSION['role'] ?? '') === 'superadmin';
|
|
if (!$isSuperadmin && !in_array($companyId, $userCompanies)) {
|
|
http_response_code(403);
|
|
echo json_encode(['error' => 'Ei oikeutta tähän yritykseen']);
|
|
exit;
|
|
}
|
|
// IP-rajoitus: tarkista vasta kun yrityksen dataa käytetään (superadmin ohittaa)
|
|
if (($_SESSION['role'] ?? '') !== 'superadmin') {
|
|
$allCompanies = dbLoadCompanies();
|
|
foreach ($allCompanies as $comp) {
|
|
if ($comp['id'] === $companyId) {
|
|
if (!isIpAllowed(getClientIp(), $comp['allowed_ips'] ?? '')) {
|
|
http_response_code(403);
|
|
echo json_encode(['error' => 'IP-osoitteesi ei ole sallittu tälle yritykselle.']);
|
|
exit;
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
return $companyId;
|
|
}
|
|
|
|
// Kuten requireCompany(), mutta sallii company_id:n overriden GET-parametrista
|
|
// Käytetään tiketti-endpointeissa jotta toisen yrityksen tikettejä voi avata
|
|
// EI muuta sessiota pysyvästi — palauttaa vain company_id:n
|
|
function requireCompanyOrParam(): string {
|
|
$paramCompany = $_GET['company_id'] ?? '';
|
|
if (!empty($paramCompany)) {
|
|
$userCompanies = $_SESSION['companies'] ?? [];
|
|
$isSuperadmin = ($_SESSION['role'] ?? '') === 'superadmin';
|
|
if (!$isSuperadmin && !in_array($paramCompany, $userCompanies)) {
|
|
http_response_code(403);
|
|
echo json_encode(['error' => 'Ei oikeutta tähän yritykseen']);
|
|
exit;
|
|
}
|
|
// Palauta parametrin company_id ILMAN session muutosta
|
|
return $paramCompany;
|
|
}
|
|
return requireCompany();
|
|
}
|
|
|
|
function companyFile(string $filename): string {
|
|
return getCompanyDir() . '/' . $filename;
|
|
}
|
|
|
|
function getClientIp(): string {
|
|
$xff = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? '';
|
|
if ($xff) {
|
|
// X-Forwarded-For voi sisältää useita IP:itä: "client, proxy1, proxy2" — otetaan ensimmäinen
|
|
$parts = explode(',', $xff);
|
|
$ip = trim($parts[0]);
|
|
if (filter_var($ip, FILTER_VALIDATE_IP)) return $ip;
|
|
}
|
|
return $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
|
|
}
|
|
|
|
/**
|
|
* Tarkista onko IP sallittujen listalla.
|
|
* Tyhjä lista = ei rajoitusta (kaikki sallittu).
|
|
* Tukee IPv4 ja IPv6, yksittäisiä osoitteita ja CIDR-alueita.
|
|
*/
|
|
function isIpAllowed(string $ip, string $allowedIps): bool {
|
|
$allowedIps = trim($allowedIps);
|
|
if ($allowedIps === '' || strtolower($allowedIps) === 'kaikki') return true;
|
|
// Tukee kommentteja: "192.168.1.1 # Yritys VPN" — risuaidan jälkeinen osa ohitetaan
|
|
$lines = preg_split('/\r?\n/', $allowedIps);
|
|
$entries = [];
|
|
foreach ($lines as $line) {
|
|
$line = preg_replace('/#.*$/', '', $line); // Poista kommentti
|
|
$parts = preg_split('/[\s,]+/', trim($line), -1, PREG_SPLIT_NO_EMPTY);
|
|
foreach ($parts as $p) $entries[] = $p;
|
|
}
|
|
|
|
// Normalisoi IP: IPv4-mapped IPv6 (::ffff:1.2.3.4) → myös IPv4 muotoon
|
|
$ipBin = @inet_pton($ip);
|
|
if ($ipBin === false) return false;
|
|
|
|
// Jos IP on IPv4-mapped IPv6 (::ffff:x.x.x.x), kokeile myös puhtaana IPv4:nä
|
|
$ipv4Equivalent = null;
|
|
if (strlen($ipBin) === 16 && substr($ipBin, 0, 12) === "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff") {
|
|
$ipv4Equivalent = inet_ntop(substr($ipBin, 12));
|
|
}
|
|
// Jos IP on puhdas IPv4, kokeile myös mapped-muodossa
|
|
$ipv6MappedBin = null;
|
|
if (strlen($ipBin) === 4) {
|
|
$ipv6MappedBin = "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff" . $ipBin;
|
|
}
|
|
|
|
foreach ($entries as $entry) {
|
|
$entry = trim($entry);
|
|
if ($entry === '') continue;
|
|
if (strpos($entry, '/') !== false) {
|
|
// CIDR-alue (IPv4 tai IPv6)
|
|
[$subnet, $bits] = explode('/', $entry, 2);
|
|
$bits = (int)$bits;
|
|
$subnetBin = @inet_pton($subnet);
|
|
if ($subnetBin === false) continue;
|
|
$maxBits = strlen($subnetBin) * 8;
|
|
if ($bits < 0 || $bits > $maxBits) continue;
|
|
// Rakenna bittimask
|
|
$mask = str_repeat("\xff", intdiv($bits, 8));
|
|
if ($bits % 8) $mask .= chr(0xff << (8 - ($bits % 8)));
|
|
$mask = str_pad($mask, strlen($subnetBin), "\x00");
|
|
|
|
// Tarkista suoraan
|
|
if (strlen($ipBin) === strlen($subnetBin) && ($ipBin & $mask) === ($subnetBin & $mask)) return true;
|
|
// Tarkista IPv4-equivalent
|
|
if ($ipv4Equivalent) {
|
|
$ipv4Bin = @inet_pton($ipv4Equivalent);
|
|
if ($ipv4Bin && strlen($ipv4Bin) === strlen($subnetBin) && ($ipv4Bin & $mask) === ($subnetBin & $mask)) return true;
|
|
}
|
|
// Tarkista IPv6-mapped
|
|
if ($ipv6MappedBin && strlen($ipv6MappedBin) === strlen($subnetBin) && ($ipv6MappedBin & $mask) === ($subnetBin & $mask)) return true;
|
|
} else {
|
|
// Yksittäinen IP (IPv4 tai IPv6)
|
|
$entryBin = @inet_pton($entry);
|
|
if ($entryBin === false) continue;
|
|
if ($ipBin === $entryBin) return true;
|
|
// Tarkista IPv4-equivalent
|
|
if ($ipv4Equivalent) {
|
|
$ipv4Bin = @inet_pton($ipv4Equivalent);
|
|
if ($ipv4Bin && $ipv4Bin === $entryBin) return true;
|
|
}
|
|
// Tarkista IPv6-mapped
|
|
if ($ipv6MappedBin && $ipv6MappedBin === $entryBin) return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function normalizeAddress(string $addr): string {
|
|
$addr = strtolower(trim($addr));
|
|
$addr = preg_replace('/\s+/', ' ', $addr);
|
|
return $addr;
|
|
}
|
|
|
|
// ==================== EMAIL ====================
|
|
|
|
function sendMail(string $to, string $subject, string $htmlBody): bool {
|
|
$headers = "MIME-Version: 1.0\r\n";
|
|
$headers .= "Content-Type: text/html; charset=UTF-8\r\n";
|
|
$headers .= "From: " . MAIL_FROM_NAME . " <" . MAIL_FROM . ">\r\n";
|
|
$headers .= "Reply-To: " . MAIL_FROM . "\r\n";
|
|
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) {
|
|
$url = rtrim($url, '/');
|
|
// Lisää https:// jos protokolla puuttuu
|
|
if (!preg_match('#^https?://#i', $url)) {
|
|
$url = 'https://' . $url;
|
|
}
|
|
$this->url = $url;
|
|
$this->token = $token;
|
|
}
|
|
|
|
private function request(string $method, string $endpoint, ?array $data = null, int $timeout = 15): array {
|
|
$url = $this->url . '/api/v1/' . ltrim($endpoint, '/');
|
|
$ch = curl_init($url);
|
|
curl_setopt_array($ch, [
|
|
CURLOPT_RETURNTRANSFER => true,
|
|
CURLOPT_TIMEOUT => $timeout,
|
|
CURLOPT_CONNECTTIMEOUT => 5,
|
|
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, ?string $updatedSince = null, bool $excludeClosed = false): array {
|
|
if (!empty($groupIds)) {
|
|
$parts = array_map(fn($id) => 'group_id:' . $id, $groupIds);
|
|
$query = '(' . implode(' OR ', $parts) . ')';
|
|
} else {
|
|
$query = '*';
|
|
}
|
|
if ($updatedSince) {
|
|
$query .= ' AND updated_at:>=' . $updatedSince;
|
|
}
|
|
// Ohita suljetut/merged/removed tiketit nopeuttaakseen synkkausta
|
|
if ($excludeClosed) {
|
|
$query .= ' AND NOT (state:closed OR state:merged OR state:removed)';
|
|
}
|
|
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', string $cc = '', string $bcc = '', array $attachments = []): array {
|
|
// Muunna plain-text HTML:ksi jos body ei sisällä HTML-tageja
|
|
$htmlBody = $body;
|
|
if (strip_tags($body) === $body) {
|
|
$htmlBody = nl2br(htmlspecialchars($body, ENT_QUOTES, 'UTF-8'));
|
|
}
|
|
$data = [
|
|
'ticket_id' => $ticketId,
|
|
'body' => $htmlBody,
|
|
'content_type' => 'text/html',
|
|
'type' => $type,
|
|
'subtype' => 'reply',
|
|
'internal' => false,
|
|
'sender' => 'Agent',
|
|
];
|
|
if ($to) $data['to'] = $to;
|
|
if ($cc) $data['cc'] = $cc;
|
|
if ($subject) $data['subject'] = 'Re: ' . preg_replace('/^Re:\s*/i', '', $subject);
|
|
// Liitetiedostot Zammad-muodossa
|
|
if (!empty($attachments)) {
|
|
$data['attachments'] = [];
|
|
foreach ($attachments as $att) {
|
|
$data['attachments'][] = [
|
|
'filename' => $att['name'] ?? 'attachment',
|
|
'data' => $att['data'] ?? '', // base64
|
|
'mime-type' => $att['type'] ?? 'application/octet-stream',
|
|
];
|
|
}
|
|
}
|
|
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 {
|
|
private $socket = null;
|
|
private int $tagCounter = 0;
|
|
public string $lastError = '';
|
|
|
|
public function connect(array $config): bool {
|
|
$host = $config['imap_host'] ?? '';
|
|
$port = intval($config['imap_port'] ?? 993);
|
|
$user = $config['imap_user'] ?? '';
|
|
$pass = $config['imap_password'] ?? '';
|
|
$encryption = $config['imap_encryption'] ?? 'ssl';
|
|
|
|
if (empty($host) || empty($user) || empty($pass)) {
|
|
$this->lastError = 'IMAP-asetukset puuttuvat';
|
|
return false;
|
|
}
|
|
|
|
$prefix = ($encryption === 'ssl') ? 'ssl://' : 'tcp://';
|
|
$context = stream_context_create([
|
|
'ssl' => ['verify_peer' => false, 'verify_peer_name' => false]
|
|
]);
|
|
|
|
$this->socket = @stream_socket_client(
|
|
$prefix . $host . ':' . $port,
|
|
$errno, $errstr, 15,
|
|
STREAM_CLIENT_CONNECT, $context
|
|
);
|
|
|
|
if (!$this->socket) {
|
|
$this->lastError = "Yhteys epäonnistui: {$errstr} ({$errno})";
|
|
return false;
|
|
}
|
|
|
|
stream_set_timeout($this->socket, 30);
|
|
|
|
// Read greeting
|
|
$greeting = $this->readLine();
|
|
if (!$greeting || strpos($greeting, '* OK') === false) {
|
|
$this->lastError = 'Palvelin ei vastannut oikein: ' . $greeting;
|
|
$this->disconnect();
|
|
return false;
|
|
}
|
|
|
|
// STARTTLS if needed
|
|
if ($encryption === 'tls') {
|
|
$resp = $this->command('STARTTLS');
|
|
if (!$this->isOk($resp)) {
|
|
$this->lastError = 'STARTTLS epäonnistui';
|
|
$this->disconnect();
|
|
return false;
|
|
}
|
|
if (!stream_socket_enable_crypto($this->socket, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) {
|
|
$this->lastError = 'TLS-neuvottelu epäonnistui';
|
|
$this->disconnect();
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Login
|
|
$resp = $this->command('LOGIN "' . $this->escape($user) . '" "' . $this->escape($pass) . '"');
|
|
if (!$this->isOk($resp)) {
|
|
$this->lastError = 'Kirjautuminen epäonnistui: väärä tunnus tai salasana';
|
|
$this->disconnect();
|
|
return false;
|
|
}
|
|
|
|
// Select INBOX
|
|
$resp = $this->command('SELECT INBOX');
|
|
if (!$this->isOk($resp)) {
|
|
$this->lastError = 'INBOX:n avaus epäonnistui';
|
|
$this->disconnect();
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
public function fetchMessages(int $limit = 50): array {
|
|
if (!$this->socket) return [];
|
|
|
|
// Get message count from STATUS
|
|
$resp = $this->command('STATUS INBOX (MESSAGES)');
|
|
$totalMessages = 0;
|
|
foreach ($resp as $line) {
|
|
if (preg_match('/MESSAGES\s+(\d+)/i', $line, $m)) {
|
|
$totalMessages = intval($m[1]);
|
|
}
|
|
}
|
|
if ($totalMessages === 0) return [];
|
|
|
|
$start = max(1, $totalMessages - $limit + 1);
|
|
$range = $start . ':' . $totalMessages;
|
|
|
|
// Fetch headers for range
|
|
$resp = $this->command("FETCH {$range} (BODY.PEEK[HEADER.FIELDS (FROM SUBJECT DATE MESSAGE-ID IN-REPLY-TO REFERENCES)] BODY.PEEK[TEXT] FLAGS)");
|
|
|
|
$messages = [];
|
|
$current = null;
|
|
$headerBuf = '';
|
|
$bodyBuf = '';
|
|
$readingHeader = false;
|
|
$readingBody = false;
|
|
$headerBytesLeft = 0;
|
|
$bodyBytesLeft = 0;
|
|
|
|
// Simpler approach: fetch one-by-one for reliability
|
|
$messages = [];
|
|
for ($i = $totalMessages; $i >= $start; $i--) {
|
|
$msg = $this->fetchSingleMessage($i);
|
|
if ($msg) $messages[] = $msg;
|
|
}
|
|
|
|
return $messages;
|
|
}
|
|
|
|
private function fetchSingleMessage(int $num): ?array {
|
|
// Fetch headers
|
|
$resp = $this->command("FETCH {$num} BODY.PEEK[HEADER]");
|
|
$headerRaw = $this->extractLiteral($resp);
|
|
|
|
if (!$headerRaw) return null;
|
|
|
|
$headers = $this->parseHeaders($headerRaw);
|
|
$subject = $this->decodeMimeHeader($headers['subject'] ?? '');
|
|
$fromRaw = $headers['from'] ?? '';
|
|
$fromParsed = $this->parseFrom($fromRaw);
|
|
$messageId = trim($headers['message-id'] ?? '');
|
|
$inReplyTo = trim($headers['in-reply-to'] ?? '');
|
|
$references = trim($headers['references'] ?? '');
|
|
$dateStr = $headers['date'] ?? '';
|
|
$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 To
|
|
$toRaw = $this->decodeMimeHeader($headers['to'] ?? '');
|
|
$toEmails = $this->parseCcAddresses($toRaw);
|
|
|
|
// Parse CC
|
|
$ccRaw = $this->decodeMimeHeader($headers['cc'] ?? '');
|
|
$ccEmails = $this->parseCcAddresses($ccRaw);
|
|
|
|
// Fetch body (text part)
|
|
$body = $this->fetchBody($num);
|
|
|
|
return [
|
|
'subject' => $subject,
|
|
'from_email' => $fromParsed['email'],
|
|
'from_name' => $this->decodeMimeHeader($fromParsed['name']),
|
|
'to' => $toEmails,
|
|
'message_id' => $messageId,
|
|
'in_reply_to' => $inReplyTo,
|
|
'references' => $references,
|
|
'date' => $date,
|
|
'body' => $body,
|
|
'cc' => $ccEmails,
|
|
];
|
|
}
|
|
|
|
private function fetchBody(int $num): string {
|
|
// Hae BODYSTRUCTURE rakenteen selvittämiseksi
|
|
$resp = $this->command("FETCH {$num} BODYSTRUCTURE");
|
|
$struct = implode(' ', $resp);
|
|
error_log("IMAP BODYSTRUCTURE msg#{$num}: " . substr($struct, 0, 500));
|
|
|
|
// Etsi text/plain osa ja sen koodaus BODYSTRUCTURE:sta
|
|
// Joustava regex: param=(list|NIL), body-id=(NIL|"str"), body-desc=(NIL|"str"), encoding="str"
|
|
$pParam = '(?:\([^)]*\)|NIL)';
|
|
$pNStr = '(?:NIL|"[^"]*")';
|
|
$plainRx = '/"TEXT"\s+"PLAIN"\s+' . $pParam . '\s+' . $pNStr . '\s+' . $pNStr . '\s+"([^"]+)"/i';
|
|
$htmlRx = '/"TEXT"\s+"HTML"\s+' . $pParam . '\s+' . $pNStr . '\s+' . $pNStr . '\s+"([^"]+)"/i';
|
|
|
|
$plainEncoding = '';
|
|
$htmlEncoding = '';
|
|
if (preg_match($plainRx, $struct, $em)) $plainEncoding = strtoupper($em[1]);
|
|
if (preg_match($htmlRx, $struct, $em)) $htmlEncoding = strtoupper($em[1]);
|
|
|
|
// Charset text/plain -osasta
|
|
$charset = 'utf-8';
|
|
if (preg_match('/"TEXT"\s+"PLAIN"\s+\([^)]*"CHARSET"\s+"([^"]+)"/i', $struct, $cm)) {
|
|
$charset = strtolower($cm[1]);
|
|
} elseif (preg_match('/charset[="\s]+([^\s;"\\)]+)/i', $struct, $cm)) {
|
|
$charset = strtolower(trim($cm[1], '"'));
|
|
}
|
|
|
|
// Päättele oikea section-numero BODYSTRUCTURE:n rakenteesta
|
|
// Yksiosainen: BODYSTRUCTURE ("TEXT" "PLAIN" ...) → BODY[TEXT]
|
|
// Multipart: BODYSTRUCTURE (("TEXT" "PLAIN" ...) ...) → BODY[1]
|
|
// Sisäkkäinen: BODYSTRUCTURE ((("TEXT" "PLAIN" ...) ...) ...) → BODY[1.1]
|
|
$sections = [];
|
|
$plainPos = stripos($struct, '"TEXT" "PLAIN"');
|
|
if ($plainPos !== false) {
|
|
$bsPos = stripos($struct, 'BODYSTRUCTURE');
|
|
$after = ($bsPos !== false) ? substr($struct, $bsPos + 13) : $struct;
|
|
$plainInAfter = stripos($after, '"TEXT" "PLAIN"');
|
|
if ($plainInAfter !== false) {
|
|
$beforePlain = substr($after, 0, $plainInAfter);
|
|
$depth = substr_count($beforePlain, '(') - substr_count($beforePlain, ')');
|
|
if ($depth <= 1) {
|
|
$sections[] = 'TEXT'; // yksiosainen viesti
|
|
} elseif ($depth === 2) {
|
|
$sections[] = '1'; // suora lapsi multipartissa
|
|
} elseif ($depth >= 3) {
|
|
$sections[] = '1.1'; // sisäkkäinen multipart
|
|
}
|
|
}
|
|
}
|
|
// Lisää fallbackit
|
|
foreach (['1', '1.1', 'TEXT'] as $fb) {
|
|
if (!in_array($fb, $sections)) $sections[] = $fb;
|
|
}
|
|
|
|
error_log("IMAP sections to try for msg#{$num}: " . implode(', ', $sections) . " | plainEnc={$plainEncoding} htmlEnc={$htmlEncoding} charset={$charset}");
|
|
|
|
// Kokeile osioita järjestyksessä
|
|
$body = '';
|
|
$usedSection = '';
|
|
foreach ($sections as $sec) {
|
|
$resp = $this->command("FETCH {$num} BODY.PEEK[{$sec}]");
|
|
$data = $this->extractLiteral($resp);
|
|
if ($data && strlen(trim($data)) > 0) {
|
|
$body = $data;
|
|
$usedSection = $sec;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!$body) return '';
|
|
|
|
// Päättele käytettävä koodaus
|
|
$encoding = $plainEncoding;
|
|
// Jos BODYSTRUCTURE ei löytänyt text/plain koodausta, kokeile raakaa hakua
|
|
if (!$encoding) {
|
|
if (preg_match('/"(BASE64|QUOTED-PRINTABLE|7BIT|8BIT)"/i', $struct, $em)) {
|
|
$encoding = strtoupper($em[1]);
|
|
}
|
|
}
|
|
|
|
error_log("IMAP body msg#{$num}: section={$usedSection} encoding={$encoding} bodyLen=" . strlen($body) . " first100=" . substr($body, 0, 100));
|
|
|
|
// Dekoodaa sisältö
|
|
if ($encoding === 'BASE64') {
|
|
$decoded = @base64_decode($body);
|
|
if ($decoded !== false) $body = $decoded;
|
|
} elseif ($encoding === 'QUOTED-PRINTABLE') {
|
|
$body = quoted_printable_decode($body);
|
|
}
|
|
|
|
// Jos koodausta ei tunnistettu, yritä automaattinen tunnistus
|
|
if (!$encoding || $encoding === '7BIT' || $encoding === '8BIT') {
|
|
$trimmed = trim($body);
|
|
// Tarkista näyttääkö base64:ltä
|
|
if (preg_match('/^[A-Za-z0-9+\/=\s]+$/', $trimmed) && strlen($trimmed) > 50) {
|
|
$decoded = @base64_decode($trimmed, true);
|
|
if ($decoded !== false && strlen($decoded) > 0 && preg_match('/[\x20-\x7E\xC0-\xFF]/', $decoded)) {
|
|
$body = $decoded;
|
|
}
|
|
}
|
|
// Tarkista näyttääkö quoted-printable:lta (sisältää =XX koodeja)
|
|
elseif (preg_match('/=[0-9A-Fa-f]{2}/', $body) && substr_count($body, '=') > 3) {
|
|
$decoded = quoted_printable_decode($body);
|
|
if (strlen($decoded) < strlen($body)) {
|
|
$body = $decoded;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Jos body sisältää multipart-rajoja (haettiin väärä osio), yritä parsia plain text
|
|
if (preg_match('/^--[^\r\n]+\r?\n/m', $body) && preg_match('/Content-Type:/i', $body)) {
|
|
error_log("IMAP msg#{$num}: body contains MIME boundaries, trying to extract text/plain");
|
|
$extracted = $this->extractPlainFromMultipart($body);
|
|
if ($extracted) $body = $extracted;
|
|
}
|
|
|
|
// Riisu HTML jos sisältö on HTML:ää
|
|
if (preg_match('/<html|<body|<div|<p\b/i', $body)) {
|
|
// Yritä ensin hakea text/html -osan koodaus
|
|
if (!$plainEncoding && $htmlEncoding === 'BASE64') {
|
|
// Body saattaa olla vielä base64-koodattua HTML:ää
|
|
$decoded = @base64_decode($body);
|
|
if ($decoded !== false && preg_match('/<html|<body|<div|<p\b/i', $decoded)) {
|
|
$body = $decoded;
|
|
}
|
|
}
|
|
$body = strip_tags($body);
|
|
$body = preg_replace('/\n{3,}/', "\n\n", $body);
|
|
$body = preg_replace('/[ \t]+\n/', "\n", $body);
|
|
}
|
|
|
|
// Charset-muunnos
|
|
if ($charset && $charset !== 'utf-8') {
|
|
$converted = @iconv($charset, 'UTF-8//IGNORE', $body);
|
|
if ($converted !== false) $body = $converted;
|
|
}
|
|
|
|
return trim($body);
|
|
}
|
|
|
|
/**
|
|
* Jos fetchBody palautti raakaa multipart-dataa, yritetään parsia text/plain -osa siitä.
|
|
*/
|
|
private function extractPlainFromMultipart(string $raw): string {
|
|
// Etsi boundary
|
|
if (!preg_match('/^--([^\r\n]+)/m', $raw, $bm)) return '';
|
|
$boundary = $bm[1];
|
|
$parts = explode('--' . $boundary, $raw);
|
|
|
|
foreach ($parts as $part) {
|
|
$part = trim($part);
|
|
if (!$part || $part === '--') continue;
|
|
// Etsi Content-Type header
|
|
if (preg_match('/Content-Type:\s*text\/plain/i', $part)) {
|
|
// Erota headerit ja body
|
|
$split = preg_split('/\r?\n\r?\n/', $part, 2);
|
|
if (count($split) < 2) continue;
|
|
$headers = $split[0];
|
|
$body = $split[1];
|
|
// Tarkista Transfer-Encoding
|
|
if (preg_match('/Content-Transfer-Encoding:\s*base64/i', $headers)) {
|
|
$body = base64_decode($body);
|
|
} elseif (preg_match('/Content-Transfer-Encoding:\s*quoted-printable/i', $headers)) {
|
|
$body = quoted_printable_decode($body);
|
|
}
|
|
// Charset
|
|
if (preg_match('/charset[="\s]+([^\s;"\\)]+)/i', $headers, $cm)) {
|
|
$cs = strtolower(trim($cm[1], '"'));
|
|
if ($cs && $cs !== 'utf-8') {
|
|
$converted = @iconv($cs, 'UTF-8//IGNORE', $body);
|
|
if ($converted !== false) $body = $converted;
|
|
}
|
|
}
|
|
return trim($body);
|
|
}
|
|
}
|
|
|
|
// Jos text/plain ei löydy, yritä text/html ja riisu tagit
|
|
foreach ($parts as $part) {
|
|
$part = trim($part);
|
|
if (!$part || $part === '--') continue;
|
|
if (preg_match('/Content-Type:\s*text\/html/i', $part)) {
|
|
$split = preg_split('/\r?\n\r?\n/', $part, 2);
|
|
if (count($split) < 2) continue;
|
|
$headers = $split[0];
|
|
$body = $split[1];
|
|
if (preg_match('/Content-Transfer-Encoding:\s*base64/i', $headers)) {
|
|
$body = base64_decode($body);
|
|
} elseif (preg_match('/Content-Transfer-Encoding:\s*quoted-printable/i', $headers)) {
|
|
$body = quoted_printable_decode($body);
|
|
}
|
|
if (preg_match('/charset[="\s]+([^\s;"\\)]+)/i', $headers, $cm)) {
|
|
$cs = strtolower(trim($cm[1], '"'));
|
|
if ($cs && $cs !== 'utf-8') {
|
|
$converted = @iconv($cs, 'UTF-8//IGNORE', $body);
|
|
if ($converted !== false) $body = $converted;
|
|
}
|
|
}
|
|
$body = strip_tags($body);
|
|
$body = preg_replace('/\n{3,}/', "\n\n", $body);
|
|
return trim($body);
|
|
}
|
|
}
|
|
|
|
return '';
|
|
}
|
|
|
|
private function parseHeaders(string $raw): array {
|
|
$headers = [];
|
|
$lines = explode("\n", str_replace("\r\n", "\n", $raw));
|
|
$lastKey = '';
|
|
foreach ($lines as $line) {
|
|
if ($line === '' || $line === "\r") continue;
|
|
// Continuation line (starts with space/tab)
|
|
if (preg_match('/^[\s\t]+(.+)/', $line, $m)) {
|
|
if ($lastKey && isset($headers[$lastKey])) {
|
|
$headers[$lastKey] .= ' ' . trim($m[1]);
|
|
}
|
|
continue;
|
|
}
|
|
if (preg_match('/^([A-Za-z\-]+):\s*(.*)$/', $line, $m)) {
|
|
$key = strtolower($m[1]);
|
|
$headers[$key] = trim($m[2]);
|
|
$lastKey = $key;
|
|
}
|
|
}
|
|
return $headers;
|
|
}
|
|
|
|
private function parseFrom(string $from): array {
|
|
$from = trim($from);
|
|
if (preg_match('/^"?([^"<]*)"?\s*<([^>]+)>/', $from, $m)) {
|
|
return ['name' => trim($m[1], ' "'), 'email' => trim($m[2])];
|
|
}
|
|
if (preg_match('/^([^\s@]+@[^\s@]+)/', $from, $m)) {
|
|
return ['name' => '', 'email' => $m[1]];
|
|
}
|
|
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 = '';
|
|
$parts = preg_split('/(=\?[^\?]+\?[BbQq]\?[^\?]*\?=)/', $str, -1, PREG_SPLIT_DELIM_CAPTURE);
|
|
foreach ($parts as $part) {
|
|
if (preg_match('/^=\?([^\?]+)\?([BbQq])\?([^\?]*)\?=$/', $part, $m)) {
|
|
$charset = $m[1];
|
|
$encoding = strtoupper($m[2]);
|
|
$text = $m[3];
|
|
if ($encoding === 'B') {
|
|
$text = base64_decode($text);
|
|
} elseif ($encoding === 'Q') {
|
|
$text = quoted_printable_decode(str_replace('_', ' ', $text));
|
|
}
|
|
if (strtolower($charset) !== 'utf-8') {
|
|
$converted = @iconv($charset, 'UTF-8//IGNORE', $text);
|
|
if ($converted !== false) $text = $converted;
|
|
}
|
|
$decoded .= $text;
|
|
} else {
|
|
// Remove whitespace between encoded words
|
|
if (trim($part) === '') continue;
|
|
$decoded .= $part;
|
|
}
|
|
}
|
|
return trim($decoded);
|
|
}
|
|
|
|
private function command(string $cmd): array {
|
|
$tag = 'A' . (++$this->tagCounter);
|
|
$this->writeLine("{$tag} {$cmd}");
|
|
|
|
$response = [];
|
|
while (true) {
|
|
$line = $this->readLine();
|
|
if ($line === false || $line === null) break;
|
|
$response[] = $line;
|
|
|
|
// Check for literal {N} — read N bytes
|
|
if (preg_match('/\{(\d+)\}$/', $line, $m)) {
|
|
$bytes = intval($m[1]);
|
|
$data = $this->readBytes($bytes);
|
|
$response[] = $data;
|
|
// Read the closing line after literal
|
|
$closingLine = $this->readLine();
|
|
if ($closingLine !== false && $closingLine !== null) {
|
|
$response[] = $closingLine;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// Tagged response = done
|
|
if (strpos($line, $tag . ' ') === 0) break;
|
|
}
|
|
return $response;
|
|
}
|
|
|
|
private function extractLiteral(array $resp): string {
|
|
$result = '';
|
|
for ($i = 0; $i < count($resp); $i++) {
|
|
if (preg_match('/\{(\d+)\}$/', $resp[$i], $m)) {
|
|
// Next element should be the literal data
|
|
if (isset($resp[$i + 1])) {
|
|
$result .= $resp[$i + 1];
|
|
}
|
|
}
|
|
}
|
|
return $result;
|
|
}
|
|
|
|
private function isOk(array $resp): bool {
|
|
foreach ($resp as $line) {
|
|
if (preg_match('/^A\d+\s+OK/i', $line)) return true;
|
|
if (preg_match('/^A\d+\s+(NO|BAD)/i', $line)) return false;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
private function escape(string $str): string {
|
|
return str_replace(['\\', '"'], ['\\\\', '\\"'], $str);
|
|
}
|
|
|
|
private function writeLine(string $line): void {
|
|
if (!$this->socket) return;
|
|
fwrite($this->socket, $line . "\r\n");
|
|
}
|
|
|
|
private function readLine(): ?string {
|
|
if (!$this->socket) return null;
|
|
$line = fgets($this->socket, 8192);
|
|
if ($line === false) return null;
|
|
return rtrim($line, "\r\n");
|
|
}
|
|
|
|
private function readBytes(int $n): string {
|
|
if (!$this->socket) return '';
|
|
$data = '';
|
|
$remaining = $n;
|
|
while ($remaining > 0) {
|
|
$chunk = fread($this->socket, min($remaining, 8192));
|
|
if ($chunk === false || $chunk === '') break;
|
|
$data .= $chunk;
|
|
$remaining -= strlen($chunk);
|
|
}
|
|
return $data;
|
|
}
|
|
|
|
public function disconnect(): void {
|
|
if ($this->socket) {
|
|
try {
|
|
$this->command('LOGOUT');
|
|
} catch (\Throwable $e) {}
|
|
@fclose($this->socket);
|
|
$this->socket = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
// ==================== IREDMAIL CLIENT ====================
|
|
|
|
class IRedMailClient {
|
|
private \PDO $pdo;
|
|
|
|
public function __construct(string $host, string $dbName, string $dbUser, string $dbPassword, int $port = 3306) {
|
|
$dsn = "mysql:host={$host};port={$port};dbname={$dbName};charset=utf8mb4";
|
|
$this->pdo = new \PDO($dsn, $dbUser, $dbPassword, [
|
|
\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION,
|
|
\PDO::ATTR_DEFAULT_FETCH_MODE => \PDO::FETCH_ASSOC,
|
|
\PDO::ATTR_TIMEOUT => 5,
|
|
]);
|
|
}
|
|
|
|
public function getDomains(): array {
|
|
$stmt = $this->pdo->query("SELECT d.domain, d.active, d.created,
|
|
(SELECT COUNT(*) FROM mailbox m WHERE m.domain = d.domain) AS mailboxes,
|
|
(SELECT COUNT(*) FROM alias a WHERE a.domain = d.domain AND a.address != a.goto AND a.address NOT IN (SELECT username FROM mailbox WHERE domain = d.domain)) AS aliases,
|
|
d.settings
|
|
FROM domain d WHERE d.domain != 'localhost' ORDER BY d.domain");
|
|
return $stmt->fetchAll();
|
|
}
|
|
|
|
public function createDomain(string $domain, array $opts = []): void {
|
|
$stmt = $this->pdo->prepare("INSERT INTO domain (domain, transport, settings, created, active) VALUES (?, 'dovecot', ?, NOW(), 1)");
|
|
$settings = '';
|
|
if (!empty($opts['quota'])) $settings = 'default_user_quota:' . intval($opts['quota']) . ';';
|
|
$stmt->execute([$domain, $settings]);
|
|
}
|
|
|
|
public function deleteDomain(string $domain): void {
|
|
$this->pdo->beginTransaction();
|
|
try {
|
|
$this->pdo->prepare("DELETE FROM alias WHERE domain = ?")->execute([$domain]);
|
|
$this->pdo->prepare("DELETE FROM forwardings WHERE domain = ?")->execute([$domain]);
|
|
$this->pdo->prepare("DELETE FROM mailbox WHERE domain = ?")->execute([$domain]);
|
|
$this->pdo->prepare("DELETE FROM domain WHERE domain = ?")->execute([$domain]);
|
|
$this->pdo->commit();
|
|
} catch (\Throwable $e) {
|
|
$this->pdo->rollBack();
|
|
throw $e;
|
|
}
|
|
}
|
|
|
|
public function getUsers(string $domain): array {
|
|
$stmt = $this->pdo->prepare("SELECT username AS email, name AS cn, quota AS mailQuota, active,
|
|
CASE WHEN active = 1 THEN 'active' ELSE 'disabled' END AS accountStatus
|
|
FROM mailbox WHERE domain = ? ORDER BY username");
|
|
$stmt->execute([$domain]);
|
|
return $stmt->fetchAll();
|
|
}
|
|
|
|
public function createUser(string $email, string $password, array $opts = []): void {
|
|
$parts = explode('@', $email, 2);
|
|
if (count($parts) !== 2) throw new \RuntimeException('Virheellinen sähköpostiosoite');
|
|
[$local, $domain] = $parts;
|
|
|
|
// Tarkista onko domain olemassa
|
|
$stmt = $this->pdo->prepare("SELECT domain FROM domain WHERE domain = ?");
|
|
$stmt->execute([$domain]);
|
|
if (!$stmt->fetch()) throw new \RuntimeException("Domain {$domain} ei ole olemassa");
|
|
|
|
// Tarkista duplikaatti
|
|
$stmt = $this->pdo->prepare("SELECT username FROM mailbox WHERE username = ?");
|
|
$stmt->execute([$email]);
|
|
if ($stmt->fetch()) throw new \RuntimeException("Tili {$email} on jo olemassa");
|
|
|
|
$hash = $this->hashPassword($password);
|
|
$quota = intval($opts['mailQuota'] ?? 1024) * 1048576; // MB → bytes
|
|
$name = $opts['cn'] ?? '';
|
|
$maildir = $domain . '/' . $local . '/';
|
|
|
|
$this->pdo->beginTransaction();
|
|
try {
|
|
$stmt = $this->pdo->prepare("INSERT INTO mailbox (username, password, name, storagebasedirectory, storagenode, maildir, quota, domain, active, enablesmtp, enablepop3, enableimap, enabledeliver, enablelda, created) VALUES (?, ?, ?, '/var/vmail', 'vmail1', ?, ?, ?, 1, 1, 1, 1, 1, 1, NOW())");
|
|
$stmt->execute([$email, $hash, $name, $maildir, $quota, $domain]);
|
|
|
|
// Lisää myös alias-rivi (iRedMail vaatii)
|
|
$stmt = $this->pdo->prepare("INSERT INTO alias (address, goto, domain, active) VALUES (?, ?, ?, 1) ON DUPLICATE KEY UPDATE goto = VALUES(goto)");
|
|
$stmt->execute([$email, $email, $domain]);
|
|
|
|
// Lisää forwardings-rivi
|
|
$stmt = $this->pdo->prepare("INSERT INTO forwardings (address, forwarding, domain, dest_domain, is_forwarding, active) VALUES (?, ?, ?, ?, 0, 1) ON DUPLICATE KEY UPDATE forwarding = VALUES(forwarding)");
|
|
$stmt->execute([$email, $email, $domain, $domain]);
|
|
|
|
$this->pdo->commit();
|
|
} catch (\Throwable $e) {
|
|
$this->pdo->rollBack();
|
|
throw $e;
|
|
}
|
|
}
|
|
|
|
public function updateUser(string $email, array $opts): void {
|
|
$sets = [];
|
|
$params = [];
|
|
if (!empty($opts['password'])) {
|
|
$sets[] = 'password = ?';
|
|
$params[] = $this->hashPassword($opts['password']);
|
|
$sets[] = 'passwordlastchange = NOW()';
|
|
}
|
|
if (isset($opts['cn'])) {
|
|
$sets[] = 'name = ?';
|
|
$params[] = $opts['cn'];
|
|
}
|
|
if (isset($opts['mailQuota'])) {
|
|
$sets[] = 'quota = ?';
|
|
$params[] = intval($opts['mailQuota']) * 1048576;
|
|
}
|
|
if (isset($opts['accountStatus'])) {
|
|
$sets[] = 'active = ?';
|
|
$params[] = $opts['accountStatus'] === 'disabled' ? 0 : 1;
|
|
}
|
|
if (empty($sets)) return;
|
|
$sets[] = 'modified = NOW()';
|
|
$params[] = $email;
|
|
$sql = "UPDATE mailbox SET " . implode(', ', $sets) . " WHERE username = ?";
|
|
$this->pdo->prepare($sql)->execute($params);
|
|
}
|
|
|
|
public function deleteUser(string $email): void {
|
|
$domain = substr($email, strpos($email, '@') + 1);
|
|
$this->pdo->beginTransaction();
|
|
try {
|
|
$this->pdo->prepare("DELETE FROM mailbox WHERE username = ?")->execute([$email]);
|
|
$this->pdo->prepare("DELETE FROM alias WHERE address = ?")->execute([$email]);
|
|
$this->pdo->prepare("DELETE FROM forwardings WHERE address = ? OR forwarding = ?")->execute([$email, $email]);
|
|
$this->pdo->commit();
|
|
} catch (\Throwable $e) {
|
|
$this->pdo->rollBack();
|
|
throw $e;
|
|
}
|
|
}
|
|
|
|
public function getAliases(string $domain): array {
|
|
// Aliakset = alias-rivit jotka eivät ole mailbox-käyttäjien omia
|
|
$stmt = $this->pdo->prepare("SELECT a.address, a.goto, a.domain, a.active
|
|
FROM alias a WHERE a.domain = ?
|
|
AND a.address NOT IN (SELECT username FROM mailbox WHERE domain = ?)
|
|
ORDER BY a.address");
|
|
$stmt->execute([$domain, $domain]);
|
|
$results = $stmt->fetchAll();
|
|
return array_map(function($a) {
|
|
return [
|
|
'address' => $a['address'],
|
|
'goto' => $a['goto'],
|
|
'members' => $a['goto'],
|
|
'active' => $a['active'],
|
|
];
|
|
}, $results);
|
|
}
|
|
|
|
public function createAlias(string $alias, string $goto): void {
|
|
$domain = substr($alias, strpos($alias, '@') + 1);
|
|
$stmt = $this->pdo->prepare("INSERT INTO alias (address, goto, domain, active, created) VALUES (?, ?, ?, 1, NOW()) ON DUPLICATE KEY UPDATE goto = VALUES(goto), modified = NOW()");
|
|
$stmt->execute([$alias, $goto, $domain]);
|
|
}
|
|
|
|
public function deleteAlias(string $alias): void {
|
|
$this->pdo->prepare("DELETE FROM alias WHERE address = ?")->execute([$alias]);
|
|
}
|
|
|
|
public function testConnection(): array {
|
|
$domains = $this->getDomains();
|
|
return ['ok' => true, 'domains' => count($domains)];
|
|
}
|
|
|
|
private function hashPassword(string $password): string {
|
|
// SSHA512 — iRedMail default
|
|
$salt = random_bytes(8);
|
|
$hash = hash('sha512', $password . $salt, true);
|
|
return '{SSHA512}' . base64_encode($hash . $salt);
|
|
}
|
|
}
|
|
|
|
function getIRedMailClient(string $companyId): IRedMailClient {
|
|
$integ = dbGetIntegration($companyId, 'iredmail');
|
|
if (!$integ || !$integ['enabled']) throw new \RuntimeException('iRedMail-integraatio ei ole käytössä');
|
|
$cfg = $integ['config'] ?? [];
|
|
$host = $cfg['db_host'] ?? '';
|
|
$dbName = $cfg['db_name'] ?? 'vmail';
|
|
$dbUser = $cfg['db_user'] ?? '';
|
|
$dbPassword = $cfg['db_password'] ?? '';
|
|
$port = intval($cfg['db_port'] ?? 3306);
|
|
if (!$host || !$dbUser || !$dbPassword) {
|
|
throw new \RuntimeException('iRedMail-tietokanta-asetukset puuttuvat. Aseta host, käyttäjä ja salasana.');
|
|
}
|
|
return new IRedMailClient($host, $dbName, $dbUser, $dbPassword, $port);
|
|
}
|
|
|
|
// ==================== TICKETS HELPER ====================
|
|
|
|
function sendTelegramAlert(string $companyId, array $ticket): void {
|
|
// Hae yrityskohtainen Telegram-konfiguraatio
|
|
$integ = dbGetIntegration($companyId, 'telegram');
|
|
if (!$integ || !$integ['enabled']) return;
|
|
$chatId = $integ['config']['chat_id'] ?? '';
|
|
if (!$chatId) return;
|
|
|
|
// Bot token: yrityskohtainen tai globaali fallback
|
|
$botToken = $integ['config']['bot_token'] ?? '';
|
|
if (!$botToken) {
|
|
$config = dbLoadConfig();
|
|
$botToken = $config['telegram_bot_token'] ?? '';
|
|
}
|
|
if (!$botToken) return;
|
|
|
|
// Hae yrityksen nimi
|
|
$companies = dbLoadCompanies();
|
|
$companyName = $companyId;
|
|
foreach ($companies as $c) { if ($c['id'] === $companyId) { $companyName = $c['nimi']; break; } }
|
|
|
|
$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 .= "🏢 " . $companyName . "\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);
|
|
}
|
|
|
|
/**
|
|
* Generoi oletusallekirjoitukset käyttäjälle niille mailboxeille joille ei ole omaa.
|
|
* Palauttaa yhdistetyn allekirjoitukset-arrayn (omat + generoitut oletukset).
|
|
*/
|
|
function buildSignaturesWithDefaults(array $user, array $userCompanyIds): array {
|
|
$sigs = $user['signatures'] ?? [];
|
|
$allCompanies = dbLoadCompanies();
|
|
foreach ($allCompanies as $comp) {
|
|
if (!in_array($comp['id'], $userCompanyIds)) continue;
|
|
$etunimi = trim(explode(' ', $user['nimi'] ?? '')[0]);
|
|
$yritys = $comp['nimi'] ?? '';
|
|
$phone = $comp['phone'] ?? '';
|
|
// SMTP-postilaatikoiden allekirjoitukset
|
|
$mailboxes = dbLoadMailboxes($comp['id']);
|
|
foreach ($mailboxes as $mb) {
|
|
if (!empty($sigs[$mb['id']])) continue;
|
|
$email = $mb['smtp_from_email'] ?? $mb['imap_user'] ?? '';
|
|
$parts = array_filter([$etunimi, $yritys, $email]);
|
|
if ($phone) $parts[] = $phone;
|
|
if (!empty($parts)) {
|
|
$sigs[$mb['id']] = implode("\n", $parts);
|
|
}
|
|
}
|
|
// Zammad-sähköpostien allekirjoitukset
|
|
$zEmails = _dbFetchAll(
|
|
"SELECT DISTINCT zammad_to_email FROM tickets WHERE company_id = ? AND source = 'zammad' AND zammad_to_email IS NOT NULL AND zammad_to_email != ''",
|
|
[$comp['id']]
|
|
);
|
|
foreach ($zEmails as $r) {
|
|
$key = 'zammad:' . $r['zammad_to_email'];
|
|
if (!empty($sigs[$key])) continue;
|
|
$parts = array_filter([$etunimi, $yritys, $r['zammad_to_email']]);
|
|
if ($phone) $parts[] = $phone;
|
|
if (!empty($parts)) {
|
|
$sigs[$key] = implode("\n", $parts);
|
|
}
|
|
}
|
|
}
|
|
return $sigs;
|
|
}
|
|
|
|
function sendTicketMail(string $to, string $subject, string $body, string $inReplyTo = '', string $references = '', ?array $mailbox = null, string $cc = '', string $bcc = '', array $attachments = []): bool {
|
|
$fromEmail = $mailbox['smtp_from_email'] ?? $mailbox['imap_user'] ?? MAIL_FROM;
|
|
$fromName = $mailbox['smtp_from_name'] ?? $mailbox['nimi'] ?? 'Asiakaspalvelu';
|
|
|
|
// Jos mailboxilla on SMTP-asetukset, käytä SMTP:tä
|
|
$smtpHost = $mailbox['smtp_host'] ?? '';
|
|
error_log("MAIL DEBUG: to={$to} smtpHost={$smtpHost} from={$fromEmail} mailbox_keys=" . implode(',', array_keys($mailbox ?? [])));
|
|
if ($smtpHost !== '') {
|
|
return sendViaSMTP($to, $subject, $body, $fromEmail, $fromName, $inReplyTo, $references, $mailbox, $cc, $bcc, $attachments);
|
|
}
|
|
|
|
// Fallback: PHP mail()
|
|
$headers = "MIME-Version: 1.0\r\n";
|
|
$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 ($bcc) {
|
|
$headers .= "Bcc: {$bcc}\r\n";
|
|
}
|
|
if ($inReplyTo) {
|
|
$headers .= "In-Reply-To: {$inReplyTo}\r\n";
|
|
$headers .= "References: " . ($references ? $references . ' ' : '') . $inReplyTo . "\r\n";
|
|
}
|
|
return mail($to, $subject, $body, $headers, '-f ' . $fromEmail);
|
|
}
|
|
|
|
/** @var string|null Viimeisin SMTP-virhe (palautetaan frontendille) */
|
|
$GLOBALS['smtp_last_error'] = null;
|
|
|
|
function smtpReadResponse($fp): string {
|
|
$full = '';
|
|
while ($line = @fgets($fp, 512)) {
|
|
$full .= $line;
|
|
// Viimeinen rivi: koodi + välilyönti (ei -)
|
|
if (isset($line[3]) && $line[3] !== '-') break;
|
|
// Timeout / EOF
|
|
if ($line === false) break;
|
|
}
|
|
return $full;
|
|
}
|
|
|
|
function smtpCommand($fp, string $cmd): string {
|
|
fwrite($fp, $cmd . "\r\n");
|
|
return smtpReadResponse($fp);
|
|
}
|
|
|
|
function smtpCode(string $resp): string {
|
|
return substr(trim($resp), 0, 3);
|
|
}
|
|
|
|
function sendViaSMTP(string $to, string $subject, string $body, string $fromEmail, string $fromName, string $inReplyTo, string $references, array $mailbox, string $cc, string $bcc = '', array $attachments = []): bool {
|
|
$host = $mailbox['smtp_host'];
|
|
$port = (int)($mailbox['smtp_port'] ?? 587);
|
|
// Fallback-ketju käyttäjälle: smtp_user → imap_user → smtp_from_email
|
|
$user = $mailbox['smtp_user'] ?? '';
|
|
if ($user === '') $user = $mailbox['imap_user'] ?? '';
|
|
if ($user === '') $user = $fromEmail;
|
|
// Fallback salasanalle: smtp_password → imap_password
|
|
$pass = $mailbox['smtp_password'] ?? '';
|
|
if ($pass === '') $pass = $mailbox['imap_password'] ?? '';
|
|
$encryption = $mailbox['smtp_encryption'] ?? 'tls';
|
|
|
|
$log = []; // Debug-loki
|
|
|
|
$fail = function(string $step, string $detail) use (&$fp, &$log) {
|
|
$log[] = "FAIL @ {$step}: {$detail}";
|
|
$msg = "SMTP {$step}: {$detail} | log: " . implode(' → ', $log);
|
|
error_log($msg);
|
|
$GLOBALS['smtp_last_error'] = "SMTP {$step}: {$detail}";
|
|
if (isset($fp) && is_resource($fp)) fclose($fp);
|
|
return false;
|
|
};
|
|
|
|
// 1. Yhteys
|
|
$timeout = 15;
|
|
$errno = 0; $errstr = '';
|
|
$connStr = ($encryption === 'ssl' ? "ssl" : "tcp") . "://{$host}:{$port}";
|
|
$log[] = "connect {$connStr}";
|
|
|
|
$ctx = stream_context_create(['ssl' => [
|
|
'verify_peer' => false,
|
|
'verify_peer_name' => false,
|
|
'allow_self_signed' => true,
|
|
]]);
|
|
$fp = @stream_socket_client($connStr, $errno, $errstr, $timeout, STREAM_CLIENT_CONNECT, $ctx);
|
|
if (!$fp) return $fail('connect', "{$errstr} ({$errno})");
|
|
stream_set_timeout($fp, $timeout);
|
|
|
|
// 2. Banner
|
|
$resp = smtpReadResponse($fp);
|
|
$log[] = "banner:" . smtpCode($resp);
|
|
if (smtpCode($resp) !== '220') return $fail('banner', trim($resp));
|
|
|
|
// 3. EHLO
|
|
$ehlo = smtpCommand($fp, "EHLO " . gethostname());
|
|
$log[] = "ehlo:" . smtpCode($ehlo);
|
|
if (smtpCode($ehlo) !== '250') return $fail('EHLO', trim($ehlo));
|
|
|
|
// 4. STARTTLS
|
|
if ($encryption === 'tls') {
|
|
$resp = smtpCommand($fp, "STARTTLS");
|
|
$log[] = "starttls:" . smtpCode($resp);
|
|
if (smtpCode($resp) !== '220') return $fail('STARTTLS', trim($resp));
|
|
$crypto = @stream_socket_enable_crypto($fp, true,
|
|
STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT | STREAM_CRYPTO_METHOD_TLSv1_3_CLIENT);
|
|
if (!$crypto) return $fail('TLS', 'negotiation failed');
|
|
$log[] = "tls:ok";
|
|
// EHLO opnieuw na TLS
|
|
$ehlo = smtpCommand($fp, "EHLO " . gethostname());
|
|
$log[] = "ehlo2:" . smtpCode($ehlo);
|
|
}
|
|
|
|
// 5. AUTH — probeer eerst PLAIN, dan LOGIN
|
|
if ($user !== '') {
|
|
$authOk = false;
|
|
|
|
// AUTH PLAIN
|
|
$cred = base64_encode("\0{$user}\0{$pass}");
|
|
$resp = smtpCommand($fp, "AUTH PLAIN {$cred}");
|
|
$log[] = "auth_plain:" . smtpCode($resp);
|
|
if (smtpCode($resp) === '235') {
|
|
$authOk = true;
|
|
} else {
|
|
// AUTH LOGIN fallback
|
|
$resp = smtpCommand($fp, "AUTH LOGIN");
|
|
$log[] = "auth_login:" . smtpCode($resp);
|
|
if (smtpCode($resp) === '334') {
|
|
$resp = smtpCommand($fp, base64_encode($user));
|
|
$log[] = "auth_user:" . smtpCode($resp);
|
|
if (smtpCode($resp) === '334') {
|
|
$resp = smtpCommand($fp, base64_encode($pass));
|
|
$log[] = "auth_pass:" . smtpCode($resp);
|
|
if (smtpCode($resp) === '235') {
|
|
$authOk = true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!$authOk) {
|
|
$passHint = strlen($pass) > 0 ? strlen($pass) . ' chars' : 'EMPTY';
|
|
return $fail('AUTH', trim($resp) . " (user={$user}, pass={$passHint})");
|
|
}
|
|
$log[] = "auth:ok";
|
|
}
|
|
|
|
// 6. MAIL FROM
|
|
$resp = smtpCommand($fp, "MAIL FROM:<{$fromEmail}>");
|
|
$log[] = "from:" . smtpCode($resp);
|
|
if (smtpCode($resp) !== '250') return $fail('MAIL FROM', trim($resp));
|
|
|
|
// 7. RCPT TO
|
|
$allRecipients = array_filter(array_map('trim', explode(',', $to)));
|
|
if ($cc) $allRecipients = array_merge($allRecipients, array_filter(array_map('trim', explode(',', $cc))));
|
|
if ($bcc) $allRecipients = array_merge($allRecipients, array_filter(array_map('trim', explode(',', $bcc))));
|
|
foreach ($allRecipients as $rcpt) {
|
|
$resp = smtpCommand($fp, "RCPT TO:<{$rcpt}>");
|
|
$log[] = "rcpt:" . smtpCode($resp);
|
|
if (!in_array(smtpCode($resp), ['250', '251'])) return $fail('RCPT TO', trim($resp) . " ({$rcpt})");
|
|
}
|
|
|
|
// 8. DATA
|
|
$resp = smtpCommand($fp, "DATA");
|
|
$log[] = "data:" . smtpCode($resp);
|
|
if (smtpCode($resp) !== '354') return $fail('DATA', trim($resp));
|
|
|
|
// 9. Viesti
|
|
$messageId = '<' . uniqid('msg_', true) . '@' . (explode('@', $fromEmail)[1] ?? 'localhost') . '>';
|
|
$msg = "From: {$fromName} <{$fromEmail}>\r\n";
|
|
$msg .= "To: {$to}\r\n";
|
|
if ($cc) $msg .= "Cc: {$cc}\r\n";
|
|
$msg .= "Subject: =?UTF-8?B?" . base64_encode($subject) . "?=\r\n";
|
|
$msg .= "Message-ID: {$messageId}\r\n";
|
|
if ($inReplyTo) {
|
|
$msg .= "In-Reply-To: {$inReplyTo}\r\n";
|
|
$msg .= "References: " . ($references ? $references . ' ' : '') . $inReplyTo . "\r\n";
|
|
}
|
|
$msg .= "MIME-Version: 1.0\r\n";
|
|
$msg .= "Date: " . date('r') . "\r\n";
|
|
|
|
if (!empty($attachments)) {
|
|
// Multipart MIME — teksti + liitteet
|
|
$boundary = '----=_Part_' . uniqid();
|
|
$msg .= "Content-Type: multipart/mixed; boundary=\"{$boundary}\"\r\n";
|
|
$msg .= "\r\n";
|
|
// Tekstiosa
|
|
$msg .= "--{$boundary}\r\n";
|
|
$msg .= "Content-Type: text/plain; charset=UTF-8\r\n";
|
|
$msg .= "Content-Transfer-Encoding: base64\r\n";
|
|
$msg .= "\r\n";
|
|
$msg .= chunk_split(base64_encode($body));
|
|
// Liitteet
|
|
foreach ($attachments as $att) {
|
|
$attName = $att['name'] ?? 'attachment';
|
|
$attType = $att['type'] ?? 'application/octet-stream';
|
|
$attData = $att['data'] ?? '';
|
|
$encodedName = '=?UTF-8?B?' . base64_encode($attName) . '?=';
|
|
$msg .= "--{$boundary}\r\n";
|
|
$msg .= "Content-Type: {$attType}; name=\"{$encodedName}\"\r\n";
|
|
$msg .= "Content-Disposition: attachment; filename=\"{$encodedName}\"\r\n";
|
|
$msg .= "Content-Transfer-Encoding: base64\r\n";
|
|
$msg .= "\r\n";
|
|
$msg .= chunk_split($attData);
|
|
}
|
|
$msg .= "--{$boundary}--\r\n";
|
|
} else {
|
|
// Pelkkä teksti ilman liitteitä
|
|
$msg .= "Content-Type: text/plain; charset=UTF-8\r\n";
|
|
$msg .= "Content-Transfer-Encoding: base64\r\n";
|
|
$msg .= "\r\n";
|
|
$msg .= chunk_split(base64_encode($body));
|
|
}
|
|
// Lopeta piste omalla rivillä
|
|
fwrite($fp, $msg . "\r\n.\r\n");
|
|
$resp = smtpReadResponse($fp);
|
|
$log[] = "send:" . smtpCode($resp);
|
|
if (smtpCode($resp) !== '250') return $fail('send', trim($resp));
|
|
|
|
// QUIT
|
|
fwrite($fp, "QUIT\r\n");
|
|
fclose($fp);
|
|
error_log("SMTP OK: " . implode(' → ', $log));
|
|
return true;
|
|
}
|
|
|
|
function parseLiittymat(array $input): array {
|
|
$liittymat = [];
|
|
foreach (($input['liittymat'] ?? []) as $l) {
|
|
$liittymat[] = [
|
|
'asennusosoite' => trim($l['asennusosoite'] ?? ''),
|
|
'postinumero' => trim($l['postinumero'] ?? ''),
|
|
'kaupunki' => trim($l['kaupunki'] ?? ''),
|
|
'liittymanopeus' => trim($l['liittymanopeus'] ?? ''),
|
|
'hinta' => floatval($l['hinta'] ?? 0),
|
|
'sopimuskausi' => trim($l['sopimuskausi'] ?? ''),
|
|
'alkupvm' => trim($l['alkupvm'] ?? ''),
|
|
'vlan' => trim($l['vlan'] ?? ''),
|
|
'laite' => trim($l['laite'] ?? ''),
|
|
'portti' => trim($l['portti'] ?? ''),
|
|
'ip' => trim($l['ip'] ?? ''),
|
|
];
|
|
}
|
|
if (empty($liittymat)) {
|
|
$liittymat[] = ['asennusosoite' => '', 'postinumero' => '', 'kaupunki' => '', 'liittymanopeus' => '', 'hinta' => 0, 'sopimuskausi' => '', 'alkupvm' => '', 'vlan' => '', 'laite' => '', 'portti' => '', 'ip' => ''];
|
|
}
|
|
return $liittymat;
|
|
}
|
|
|
|
// ==================== ROUTES ====================
|
|
|
|
switch ($action) {
|
|
|
|
// ---------- SAATAVUUS (julkinen API) ----------
|
|
case 'saatavuus':
|
|
$providedKey = $_GET['key'] ?? ($_SERVER['HTTP_X_API_KEY'] ?? '');
|
|
if (empty($providedKey)) {
|
|
http_response_code(403);
|
|
echo json_encode(['error' => 'API-avain puuttuu']);
|
|
break;
|
|
}
|
|
|
|
// Etsi yritys jonka API-avain täsmää
|
|
$matchedCompany = dbGetCompanyByApiKey($providedKey);
|
|
|
|
if (!$matchedCompany) {
|
|
http_response_code(403);
|
|
echo json_encode(['error' => 'Virheellinen API-avain']);
|
|
break;
|
|
}
|
|
|
|
// CORS - yrityskohtaiset originit
|
|
$allowedOrigins = dbGetCompanyCorsOrigins($matchedCompany['id']);
|
|
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
|
|
if (in_array($origin, $allowedOrigins)) {
|
|
header("Access-Control-Allow-Origin: $origin");
|
|
header('Access-Control-Allow-Methods: GET, OPTIONS');
|
|
header('Access-Control-Allow-Headers: Content-Type, X-Api-Key');
|
|
}
|
|
if ($method === 'OPTIONS') { http_response_code(204); break; }
|
|
|
|
// Parametrit
|
|
$queryOsoite = normalizeAddress($_GET['osoite'] ?? '');
|
|
$queryPostinumero = trim($_GET['postinumero'] ?? '');
|
|
$queryKaupunki = strtolower(trim($_GET['kaupunki'] ?? ''));
|
|
|
|
if (empty($queryOsoite) || empty($queryPostinumero) || empty($queryKaupunki)) {
|
|
http_response_code(400);
|
|
echo json_encode(['error' => 'Anna osoite, postinumero ja kaupunki']);
|
|
break;
|
|
}
|
|
|
|
// Hae VAIN tämän yrityksen asiakkaista
|
|
$customers = dbLoadCustomers($matchedCompany['id']);
|
|
$found = false;
|
|
foreach ($customers as $c) {
|
|
foreach ($c['liittymat'] ?? [] as $l) {
|
|
$addr = normalizeAddress($l['asennusosoite'] ?? '');
|
|
$zip = trim($l['postinumero'] ?? '');
|
|
$city = strtolower(trim($l['kaupunki'] ?? ''));
|
|
if ($zip === $queryPostinumero && $city === $queryKaupunki) {
|
|
if (!empty($addr) && !empty($queryOsoite)) {
|
|
if (strpos($addr, $queryOsoite) !== false || strpos($queryOsoite, $addr) !== false) {
|
|
$found = true;
|
|
break 2;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Todennäköinen saatavuus: postinumero saatavuuslistalla
|
|
$probable = false;
|
|
if (!$found) {
|
|
$saatInteg = dbGetIntegration($matchedCompany['id'], 'saatavuus_api');
|
|
$probablePostcodes = ($saatInteg && $saatInteg['config']) ? ($saatInteg['config']['probable_postcodes'] ?? []) : [];
|
|
if (in_array($queryPostinumero, $probablePostcodes)) {
|
|
$probable = true;
|
|
}
|
|
}
|
|
|
|
// Tulos: true, "todennäköinen", tai false
|
|
$result = $found ? true : ($probable ? 'todennäköinen' : false);
|
|
|
|
// Tallenna kysely tietokantaan (ohita duplikaatit: sama osoite+postinumero+kaupunki+yritys)
|
|
try {
|
|
$rawOsoite = $_GET['osoite'] ?? '';
|
|
$rawPostinumero = $_GET['postinumero'] ?? '';
|
|
$rawKaupunki = $_GET['kaupunki'] ?? '';
|
|
$exists = _dbFetchScalar(
|
|
"SELECT COUNT(*) FROM availability_queries WHERE company_id = ? AND LOWER(osoite) = LOWER(?) AND postinumero = ? AND LOWER(kaupunki) = LOWER(?)",
|
|
[$matchedCompany['id'], $rawOsoite, $rawPostinumero, $rawKaupunki]
|
|
);
|
|
if (!$exists) {
|
|
$ip = getClientIp();
|
|
$hostname = @gethostbyaddr($ip) ?: '';
|
|
if ($hostname === $ip) $hostname = '';
|
|
// Hae IP-osoitteen organisaatio/ISP ip-api.com:sta
|
|
$org = '';
|
|
try {
|
|
$ipApiUrl = "http://ip-api.com/json/{$ip}?fields=org,isp,as";
|
|
$ctx = stream_context_create(['http' => ['timeout' => 3]]);
|
|
$ipJson = @file_get_contents($ipApiUrl, false, $ctx);
|
|
if ($ipJson) {
|
|
$ipData = json_decode($ipJson, true);
|
|
$org = $ipData['org'] ?? $ipData['isp'] ?? '';
|
|
}
|
|
} catch (\Throwable $e) { /* IP-haku ei saa kaataa API:a */ }
|
|
// saatavilla: 1=kyllä, 2=todennäköinen, 0=ei
|
|
$saatavillaDb = $found ? 1 : ($probable ? 2 : 0);
|
|
_dbExecute(
|
|
"INSERT INTO availability_queries (company_id, osoite, postinumero, kaupunki, saatavilla, ip_address, hostname, org, user_agent, referer, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
[
|
|
$matchedCompany['id'],
|
|
$rawOsoite,
|
|
$rawPostinumero,
|
|
$rawKaupunki,
|
|
$saatavillaDb,
|
|
$ip,
|
|
$hostname,
|
|
$org,
|
|
substr($_SERVER['HTTP_USER_AGENT'] ?? '', 0, 500),
|
|
substr($_SERVER['HTTP_REFERER'] ?? '', 0, 500),
|
|
date('Y-m-d H:i:s'),
|
|
]
|
|
);
|
|
}
|
|
} catch (\Throwable $e) { /* logitus ei saa kaataa API-vastausta */ }
|
|
|
|
echo json_encode(['saatavilla' => $result]);
|
|
break;
|
|
|
|
// ---------- SAATAVUUSKYSELYT ----------
|
|
case 'availability_queries':
|
|
requireAuth();
|
|
$limit = (int)($_GET['limit'] ?? 100);
|
|
$offset = (int)($_GET['offset'] ?? 0);
|
|
if ($limit > 500) $limit = 500;
|
|
|
|
// Näytä kaikkien käyttäjän yritysten kyselyt
|
|
$userCompanyIds = $_SESSION['companies'] ?? [];
|
|
if (empty($userCompanyIds)) {
|
|
echo json_encode(['total' => 0, 'queries' => []]);
|
|
break;
|
|
}
|
|
$placeholders = implode(',', array_fill(0, count($userCompanyIds), '?'));
|
|
$total = (int)_dbFetchScalar("SELECT COUNT(*) FROM availability_queries WHERE company_id IN ($placeholders)", $userCompanyIds);
|
|
$params = array_merge($userCompanyIds, [$limit, $offset]);
|
|
$rows = _dbFetchAll(
|
|
"SELECT aq.id, aq.company_id, c.nimi as company_nimi, aq.osoite, aq.postinumero, aq.kaupunki, aq.saatavilla, aq.ip_address, aq.hostname, aq.org, aq.referer, aq.created_at
|
|
FROM availability_queries aq LEFT JOIN companies c ON c.id = aq.company_id
|
|
WHERE aq.company_id IN ($placeholders) ORDER BY aq.created_at DESC LIMIT ? OFFSET ?",
|
|
$params
|
|
);
|
|
echo json_encode(['total' => $total, 'queries' => $rows]);
|
|
break;
|
|
|
|
// ---------- CONFIG (admin, yrityskohtainen) ----------
|
|
case 'config':
|
|
requireAuth();
|
|
$companyId = requireCompany();
|
|
$globalConf = dbLoadConfig();
|
|
// Telegram: yrityskohtainen config (bot_token + chat_id), globaali fallback
|
|
$teleInteg = dbGetIntegration($companyId, 'telegram');
|
|
$teleConf = ($teleInteg && $teleInteg['config']) ? $teleInteg['config'] : [];
|
|
$saatavuusInteg = dbGetIntegration($companyId, 'saatavuus_api');
|
|
$saatavuusConf = ($saatavuusInteg && $saatavuusInteg['config']) ? $saatavuusInteg['config'] : [];
|
|
echo json_encode([
|
|
'api_key' => dbGetCompanyApiKey($companyId),
|
|
'cors_origins' => dbGetCompanyCorsOrigins($companyId),
|
|
'probable_postcodes' => $saatavuusConf['probable_postcodes'] ?? [],
|
|
'telegram_bot_token' => $teleConf['bot_token'] ?? ($globalConf['telegram_bot_token'] ?? ''),
|
|
'telegram_chat_id' => $teleConf['chat_id'] ?? '',
|
|
]);
|
|
break;
|
|
|
|
case 'config_update':
|
|
requireAdmin();
|
|
$companyId = requireCompany();
|
|
if ($method !== 'POST') break;
|
|
$input = json_decode(file_get_contents('php://input'), true);
|
|
if (isset($input['api_key'])) {
|
|
dbSetCompanyApiKey($companyId, trim($input['api_key']));
|
|
}
|
|
if (isset($input['cors_origins'])) {
|
|
$origins = array_filter(array_map('trim', explode("\n", $input['cors_origins'])));
|
|
dbSetCompanyCorsOrigins($companyId, array_values($origins));
|
|
}
|
|
// Telegram-asetukset: bot_token ja chat_id yrityskohtaisia
|
|
if (isset($input['telegram_bot_token']) || isset($input['telegram_chat_id'])) {
|
|
$teleInteg = dbGetIntegration($companyId, 'telegram');
|
|
$teleConfig = ($teleInteg && $teleInteg['config']) ? $teleInteg['config'] : [];
|
|
$teleEnabled = $teleInteg ? $teleInteg['enabled'] : false;
|
|
if (isset($input['telegram_bot_token'])) {
|
|
$teleConfig['bot_token'] = trim($input['telegram_bot_token']);
|
|
}
|
|
if (isset($input['telegram_chat_id'])) {
|
|
$teleConfig['chat_id'] = trim($input['telegram_chat_id']);
|
|
}
|
|
dbSaveIntegration($companyId, 'telegram', $teleEnabled, $teleConfig);
|
|
}
|
|
// Todennäköinen saatavuus -postinumerot
|
|
if (isset($input['probable_postcodes'])) {
|
|
$postcodes = array_filter(array_map('trim', explode("\n", $input['probable_postcodes'])));
|
|
$saatInteg = dbGetIntegration($companyId, 'saatavuus_api');
|
|
$saatConfig = ($saatInteg && $saatInteg['config']) ? $saatInteg['config'] : [];
|
|
$saatEnabled = $saatInteg ? $saatInteg['enabled'] : true;
|
|
$saatConfig['probable_postcodes'] = array_values($postcodes);
|
|
dbSaveIntegration($companyId, 'saatavuus_api', $saatEnabled, $saatConfig);
|
|
}
|
|
dbAddLog($companyId, currentUser(), 'config_update', '', '', 'Päivitti asetuksia');
|
|
echo json_encode([
|
|
'api_key' => dbGetCompanyApiKey($companyId),
|
|
'cors_origins' => dbGetCompanyCorsOrigins($companyId),
|
|
]);
|
|
break;
|
|
|
|
case 'telegram_test':
|
|
requireAdmin();
|
|
$companyId = requireCompany();
|
|
if ($method !== 'POST') break;
|
|
// Bot token + chat_id: yrityskohtainen, fallback globaaliin
|
|
$teleInteg = dbGetIntegration($companyId, 'telegram');
|
|
$teleConf = ($teleInteg && $teleInteg['config']) ? $teleInteg['config'] : [];
|
|
$botToken = $teleConf['bot_token'] ?? '';
|
|
if (!$botToken) {
|
|
$globalConf = dbLoadConfig();
|
|
$botToken = $globalConf['telegram_bot_token'] ?? '';
|
|
}
|
|
$chatId = $teleConf['chat_id'] ?? '';
|
|
if (!$botToken || !$chatId) {
|
|
http_response_code(400);
|
|
echo json_encode(['error' => 'Bot Token ja Chat ID vaaditaan']);
|
|
break;
|
|
}
|
|
$companies = dbLoadCompanies();
|
|
$companyName = $companyId;
|
|
foreach ($companies as $c) { if ($c['id'] === $companyId) { $companyName = $c['nimi']; break; } }
|
|
$url = "https://api.telegram.org/bot{$botToken}/sendMessage";
|
|
$data = ['chat_id' => $chatId, 'text' => "✅ Noxus HUB Telegram-hälytys toimii!\n🏢 $companyName", '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();
|
|
if ($method !== 'POST') break;
|
|
$newApiKey = bin2hex(random_bytes(16));
|
|
dbSetCompanyApiKey($companyId, $newApiKey);
|
|
dbAddLog($companyId, currentUser(), 'config_update', '', '', 'Generoi uuden API-avaimen');
|
|
echo json_encode([
|
|
'api_key' => $newApiKey,
|
|
'cors_origins' => dbGetCompanyCorsOrigins($companyId),
|
|
]);
|
|
break;
|
|
|
|
// ---------- CAPTCHA ----------
|
|
case 'captcha':
|
|
$a = rand(1, 20);
|
|
$b = rand(1, 20);
|
|
$_SESSION['captcha_answer'] = $a + $b;
|
|
echo json_encode(['question' => "$a + $b = ?"]);
|
|
break;
|
|
|
|
// ---------- BRANDING (julkinen) ----------
|
|
case 'branding':
|
|
$host = strtolower(explode(':', $_SERVER['HTTP_HOST'] ?? '')[0]);
|
|
echo json_encode(dbGetBranding($host));
|
|
break;
|
|
|
|
case 'company_logo':
|
|
$companyId = $_GET['company_id'] ?? '';
|
|
if (empty($companyId) || !preg_match('/^[a-z0-9-]+$/', $companyId)) {
|
|
http_response_code(400);
|
|
echo json_encode(['error' => 'Virheellinen company_id']);
|
|
break;
|
|
}
|
|
$companies = dbLoadCompanies();
|
|
$logoFile = '';
|
|
foreach ($companies as $comp) {
|
|
if ($comp['id'] === $companyId) {
|
|
$logoFile = $comp['logo_file'] ?? '';
|
|
break;
|
|
}
|
|
}
|
|
if (empty($logoFile)) {
|
|
http_response_code(404);
|
|
echo json_encode(['error' => 'Logoa ei löydy']);
|
|
break;
|
|
}
|
|
$logoPath = DATA_DIR . '/companies/' . $companyId . '/' . $logoFile;
|
|
if (!file_exists($logoPath)) {
|
|
http_response_code(404);
|
|
echo json_encode(['error' => 'Logotiedostoa ei löydy']);
|
|
break;
|
|
}
|
|
$ext = strtolower(pathinfo($logoFile, PATHINFO_EXTENSION));
|
|
$mimeTypes = ['png' => 'image/png', 'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'svg' => 'image/svg+xml', 'webp' => 'image/webp'];
|
|
$mime = $mimeTypes[$ext] ?? 'application/octet-stream';
|
|
header('Content-Type: ' . $mime);
|
|
header('Cache-Control: public, max-age=3600');
|
|
readfile($logoPath);
|
|
exit;
|
|
|
|
case 'company_logo_upload':
|
|
requireAdmin();
|
|
if ($method !== 'POST') break;
|
|
$companyId = $_POST['company_id'] ?? '';
|
|
if (empty($companyId) || !preg_match('/^[a-z0-9-]+$/', $companyId)) {
|
|
http_response_code(400);
|
|
echo json_encode(['error' => 'Virheellinen company_id']);
|
|
break;
|
|
}
|
|
// Tarkista yrityksen oikeudet (vain superadmin tai oman yrityksen admin)
|
|
$isSA = ($_SESSION['role'] ?? '') === 'superadmin';
|
|
$userCompanies = $_SESSION['companies'] ?? [];
|
|
if (!$isSA && !in_array($companyId, $userCompanies)) {
|
|
http_response_code(403);
|
|
echo json_encode(['error' => 'Ei oikeutta tähän yritykseen']);
|
|
break;
|
|
}
|
|
if (!isset($_FILES['logo']) || $_FILES['logo']['error'] !== UPLOAD_ERR_OK) {
|
|
http_response_code(400);
|
|
echo json_encode(['error' => 'Logotiedosto puuttuu tai virhe uploadissa']);
|
|
break;
|
|
}
|
|
$file = $_FILES['logo'];
|
|
// Validoi koko (max 2MB)
|
|
if ($file['size'] > 2 * 1024 * 1024) {
|
|
http_response_code(400);
|
|
echo json_encode(['error' => 'Logo on liian suuri (max 2MB)']);
|
|
break;
|
|
}
|
|
// Validoi tyyppi (tiedostopäätteen + mahdollisen finfo:n perusteella)
|
|
$allowedExtensions = ['png', 'jpg', 'jpeg', 'svg', 'webp'];
|
|
$origExt = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
|
|
if (!in_array($origExt, $allowedExtensions)) {
|
|
http_response_code(400);
|
|
echo json_encode(['error' => 'Sallitut tiedostotyypit: PNG, JPG, SVG, WebP']);
|
|
break;
|
|
}
|
|
// Käytä finfo:a jos saatavilla, muuten luota tiedostopäätteeseen
|
|
if (function_exists('finfo_open')) {
|
|
$finfo = finfo_open(FILEINFO_MIME_TYPE);
|
|
$detectedType = finfo_file($finfo, $file['tmp_name']);
|
|
finfo_close($finfo);
|
|
$allowedMimes = ['image/png', 'image/jpeg', 'image/svg+xml', 'image/webp'];
|
|
if (!in_array($detectedType, $allowedMimes)) {
|
|
http_response_code(400);
|
|
echo json_encode(['error' => 'Sallitut tiedostotyypit: PNG, JPG, SVG, WebP']);
|
|
break;
|
|
}
|
|
}
|
|
$extNormalize = ['jpeg' => 'jpg'];
|
|
$ext = $extNormalize[$origExt] ?? $origExt;
|
|
$newFilename = 'logo.' . $ext;
|
|
$compDir = DATA_DIR . '/companies/' . $companyId;
|
|
// Luo kansio tarvittaessa (data on nyt MySQL:ssä, kansio vain logoille)
|
|
if (!file_exists($compDir)) {
|
|
mkdir($compDir, 0755, true);
|
|
}
|
|
// Poista vanha logo ja päivitä kantaan
|
|
$companies = dbLoadCompanies();
|
|
$found = false;
|
|
foreach ($companies as $comp) {
|
|
if ($comp['id'] === $companyId) {
|
|
$oldLogo = $comp['logo_file'] ?? '';
|
|
if ($oldLogo && $oldLogo !== $newFilename && file_exists($compDir . '/' . $oldLogo)) {
|
|
unlink($compDir . '/' . $oldLogo);
|
|
}
|
|
$comp['logo_file'] = $newFilename;
|
|
dbSaveCompany($comp);
|
|
$found = true;
|
|
break;
|
|
}
|
|
}
|
|
if (!$found) {
|
|
http_response_code(404);
|
|
echo json_encode(['error' => 'Yritystä ei löydy']);
|
|
break;
|
|
}
|
|
move_uploaded_file($file['tmp_name'], $compDir . '/' . $newFilename);
|
|
echo json_encode([
|
|
'success' => true,
|
|
'logo_file' => $newFilename,
|
|
'logo_url' => "api.php?action=company_logo&company_id=" . urlencode($companyId),
|
|
]);
|
|
break;
|
|
|
|
// ---------- AUTH ----------
|
|
case 'login':
|
|
if ($method !== 'POST') break;
|
|
$ip = getClientIp();
|
|
if (!dbCheckRateLimit($ip)) {
|
|
http_response_code(429);
|
|
echo json_encode(['error' => 'Liian monta kirjautumisyritystä. Yritä uudelleen 15 minuutin kuluttua.']);
|
|
break;
|
|
}
|
|
$input = json_decode(file_get_contents('php://input'), true);
|
|
// Captcha-tarkistus
|
|
$captchaAnswer = intval($input['captcha'] ?? 0);
|
|
if (!isset($_SESSION['captcha_answer']) || $captchaAnswer !== $_SESSION['captcha_answer']) {
|
|
dbRecordLoginAttempt($ip);
|
|
http_response_code(400);
|
|
echo json_encode(['error' => 'Virheellinen captcha-vastaus']);
|
|
unset($_SESSION['captcha_answer']);
|
|
break;
|
|
}
|
|
unset($_SESSION['captcha_answer']);
|
|
$username = trim($input['username'] ?? '');
|
|
$password = $input['password'] ?? '';
|
|
$u = dbGetUserByUsername($username);
|
|
if ($u && password_verify($password, $u['password_hash'])) {
|
|
$userCompanies = $u['companies'] ?? [];
|
|
// Domain-pohjainen kirjautumisrajoitus
|
|
$host = strtolower(explode(':', $_SERVER['HTTP_HOST'] ?? '')[0]);
|
|
$domainCompany = dbGetCompanyByDomain($host);
|
|
$domainCompanyId = $domainCompany ? $domainCompany['id'] : '';
|
|
// Jos domain kuuluu tietylle yritykselle, vain sen yrityksen käyttäjät + adminit pääsevät sisään
|
|
if ($domainCompanyId && $u['role'] !== 'superadmin' && !in_array($domainCompanyId, $userCompanies)) {
|
|
dbRecordLoginAttempt($ip);
|
|
http_response_code(403);
|
|
echo json_encode(['error' => 'Sinulla ei ole oikeutta kirjautua tälle sivustolle.']);
|
|
break;
|
|
}
|
|
$allCompanies = dbLoadCompanies();
|
|
|
|
// IP-rajoitus: selvitä mihin yrityksiin on pääsy (superadmin ohittaa)
|
|
$allowedCompanyIds = $userCompanies;
|
|
if ($u['role'] !== 'superadmin') {
|
|
$allowedCompanyIds = [];
|
|
foreach ($allCompanies as $comp) {
|
|
if (in_array($comp['id'], $userCompanies) && isIpAllowed($ip, $comp['allowed_ips'] ?? '')) {
|
|
$allowedCompanyIds[] = $comp['id'];
|
|
}
|
|
}
|
|
// Jos kaikki yritykset IP-estetty → estä kirjautuminen
|
|
if (empty($allowedCompanyIds) && !empty($userCompanies)) {
|
|
dbRecordLoginAttempt($ip);
|
|
http_response_code(403);
|
|
echo json_encode(['error' => 'IP-osoitteesi (' . $ip . ') ei ole sallittu. Ota yhteyttä ylläpitoon.']);
|
|
break;
|
|
}
|
|
}
|
|
|
|
// false = säilytä vanha sessio hetken (estää race condition kun frontend lataa heti dataa)
|
|
session_regenerate_id(false);
|
|
$_SESSION['user_id'] = $u['id'];
|
|
$_SESSION['username'] = $u['username'];
|
|
$_SESSION['nimi'] = $u['nimi'];
|
|
$_SESSION['role'] = $u['role'];
|
|
// Superadmin saa pääsyn kaikkiin yrityksiin
|
|
if ($u['role'] === 'superadmin') {
|
|
$allIds = array_map(fn($c) => $c['id'], $allCompanies);
|
|
$_SESSION['companies'] = $allIds;
|
|
} else {
|
|
$_SESSION['companies'] = $userCompanies;
|
|
}
|
|
$_SESSION['company_roles'] = $u['company_roles'] ?? [];
|
|
// Valitse aktiivinen yritys: domain-match > ensimmäinen sallittu
|
|
if ($domainCompanyId && in_array($domainCompanyId, $allowedCompanyIds)) {
|
|
$_SESSION['company_id'] = $domainCompanyId;
|
|
} elseif (!empty($allowedCompanyIds)) {
|
|
$_SESSION['company_id'] = $allowedCompanyIds[0];
|
|
} else {
|
|
$_SESSION['company_id'] = !empty($userCompanies) ? $userCompanies[0] : '';
|
|
}
|
|
// Aseta aktiivisen yrityksen rooli
|
|
$_SESSION['company_role'] = $_SESSION['company_roles'][$_SESSION['company_id']] ?? 'user';
|
|
// Hae yritysten nimet + IP-status
|
|
$companyList = [];
|
|
foreach ($allCompanies as $comp) {
|
|
// Superadmin näkee kaikki yritykset
|
|
if ($u['role'] === 'superadmin' || in_array($comp['id'], $userCompanies)) {
|
|
$entry = ['id' => $comp['id'], 'nimi' => $comp['nimi'], 'phone' => $comp['phone'] ?? ''];
|
|
// Merkitse IP-estetyt yritykset (superadmin ohittaa)
|
|
if ($u['role'] !== 'superadmin' && !isIpAllowed($ip, $comp['allowed_ips'] ?? '')) {
|
|
$entry['ip_blocked'] = true;
|
|
}
|
|
$companyList[] = $entry;
|
|
}
|
|
}
|
|
echo json_encode([
|
|
'success' => true,
|
|
'username' => $u['username'],
|
|
'nimi' => $u['nimi'],
|
|
'role' => $u['role'],
|
|
'company_role' => $_SESSION['company_role'],
|
|
'companies' => $companyList,
|
|
'company_id' => $_SESSION['company_id'],
|
|
'signatures' => buildSignaturesWithDefaults($u, $u['companies'] ?? []),
|
|
]);
|
|
} else {
|
|
dbRecordLoginAttempt($ip);
|
|
http_response_code(401);
|
|
echo json_encode(['error' => 'Väärä käyttäjätunnus tai salasana']);
|
|
}
|
|
break;
|
|
|
|
case 'logout':
|
|
session_destroy();
|
|
echo json_encode(['success' => true]);
|
|
break;
|
|
|
|
case 'check_auth':
|
|
if (isset($_SESSION['user_id'])) {
|
|
// Synkronoi aina tuoreet yritysoikeudet tietokannasta sessioon
|
|
$u = dbGetUser($_SESSION['user_id']);
|
|
if ($u) {
|
|
// Superadmin saa pääsyn kaikkiin yrityksiin
|
|
if (($u['role'] ?? '') === 'superadmin') {
|
|
$allComps = dbLoadCompanies();
|
|
$_SESSION['companies'] = array_map(fn($c) => $c['id'], $allComps);
|
|
} else {
|
|
$_SESSION['companies'] = $u['companies'] ?? [];
|
|
}
|
|
$_SESSION['company_roles'] = $u['company_roles'] ?? [];
|
|
// Varmista aktiivinen yritys on sallittu
|
|
if (!in_array($_SESSION['company_id'] ?? '', $_SESSION['companies'])) {
|
|
$_SESSION['company_id'] = !empty($_SESSION['companies']) ? $_SESSION['companies'][0] : '';
|
|
}
|
|
// Päivitä aktiivisen yrityksen rooli
|
|
$_SESSION['company_role'] = $_SESSION['company_roles'][$_SESSION['company_id'] ?? ''] ?? 'user';
|
|
}
|
|
// Hae yritysten nimet ja IP-status
|
|
$userCompanyIds = $_SESSION['companies'] ?? [];
|
|
$allCompanies = dbLoadCompanies();
|
|
$ip = getClientIp();
|
|
$isSuperAdmin = ($_SESSION['role'] ?? '') === 'superadmin';
|
|
$companyList = [];
|
|
$firstAllowedId = null;
|
|
foreach ($allCompanies as $comp) {
|
|
// Superadmin näkee kaikki yritykset
|
|
if ($isSuperAdmin || in_array($comp['id'], $userCompanyIds)) {
|
|
$entry = ['id' => $comp['id'], 'nimi' => $comp['nimi'], 'phone' => $comp['phone'] ?? ''];
|
|
if (!$isSuperAdmin && !isIpAllowed($ip, $comp['allowed_ips'] ?? '')) {
|
|
$entry['ip_blocked'] = true;
|
|
} else {
|
|
if (!$firstAllowedId) $firstAllowedId = $comp['id'];
|
|
}
|
|
$companyList[] = $entry;
|
|
}
|
|
}
|
|
// Jos aktiivinen yritys on IP-estetty → vaihda sallittuun
|
|
if (!$isSuperAdmin) {
|
|
$activeBlocked = false;
|
|
$activeId = $_SESSION['company_id'] ?? '';
|
|
foreach ($companyList as $c) {
|
|
if ($c['id'] === $activeId && !empty($c['ip_blocked'])) {
|
|
$activeBlocked = true;
|
|
break;
|
|
}
|
|
}
|
|
if ($activeBlocked) {
|
|
if ($firstAllowedId) {
|
|
$_SESSION['company_id'] = $firstAllowedId;
|
|
$_SESSION['company_role'] = $_SESSION['company_roles'][$firstAllowedId] ?? 'user';
|
|
} else {
|
|
// Kaikki yritykset IP-estetty → kirjaa ulos
|
|
session_destroy();
|
|
echo json_encode(['authenticated' => false, 'reason' => 'ip_blocked']);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
// Hae allekirjoitukset (oletus generoituna jos omaa ei ole)
|
|
$userSignatures = $u ? buildSignaturesWithDefaults($u, $u['companies'] ?? []) : [];
|
|
// Brändäystiedot aktiivisen yrityksen mukaan (ei domain-pohjainen)
|
|
$activeCompanyId = $_SESSION['company_id'] ?? '';
|
|
$branding = null;
|
|
$enabledModules = [];
|
|
foreach ($allCompanies as $comp) {
|
|
if ($comp['id'] === $activeCompanyId) {
|
|
$logoUrl = !empty($comp['logo_file'])
|
|
? "api.php?action=company_logo&company_id=" . urlencode($comp['id'])
|
|
: '';
|
|
$branding = [
|
|
'found' => true,
|
|
'company_id' => $comp['id'],
|
|
'nimi' => $comp['nimi'],
|
|
'primary_color' => $comp['primary_color'] ?? '#0f3460',
|
|
'subtitle' => $comp['subtitle'] ?? '',
|
|
'logo_url' => $logoUrl,
|
|
];
|
|
$enabledModules = $comp['enabled_modules'] ?? [];
|
|
break;
|
|
}
|
|
}
|
|
if (!$branding) {
|
|
$branding = ['found' => false, 'nimi' => 'Noxus HUB', 'primary_color' => '#0f3460', 'subtitle' => 'Hallintapaneeli', 'logo_url' => ''];
|
|
}
|
|
// Tarkista onko yrityksellä integraatioita päällä
|
|
$hasIntegrations = false;
|
|
if ($activeCompanyId) {
|
|
$integrations = dbLoadIntegrations($activeCompanyId);
|
|
foreach ($integrations as $integ) {
|
|
if (!empty($integ['enabled'])) { $hasIntegrations = true; break; }
|
|
}
|
|
}
|
|
echo json_encode([
|
|
'authenticated' => true,
|
|
'user_id' => $_SESSION['user_id'],
|
|
'username' => $_SESSION['username'],
|
|
'nimi' => $_SESSION['nimi'],
|
|
'email' => $u['email'] ?? '',
|
|
'role' => $_SESSION['role'],
|
|
'company_role' => $_SESSION['company_role'] ?? 'user',
|
|
'companies' => $companyList,
|
|
'company_id' => $_SESSION['company_id'] ?? '',
|
|
'signatures' => $userSignatures,
|
|
'branding' => $branding,
|
|
'enabled_modules' => $enabledModules,
|
|
'hidden_mailboxes' => $u ? ($u['hidden_mailboxes'] ?? []) : [],
|
|
'has_integrations' => $hasIntegrations,
|
|
]);
|
|
} else {
|
|
echo json_encode(['authenticated' => false]);
|
|
}
|
|
break;
|
|
|
|
// ---------- PASSWORD RESET ----------
|
|
case 'password_reset_request':
|
|
if ($method !== 'POST') break;
|
|
$ip = getClientIp();
|
|
if (!dbCheckRateLimit($ip)) {
|
|
http_response_code(429);
|
|
echo json_encode(['error' => 'Liian monta yritystä. Yritä uudelleen myöhemmin.']);
|
|
break;
|
|
}
|
|
dbRecordLoginAttempt($ip);
|
|
$input = json_decode(file_get_contents('php://input'), true);
|
|
$username = trim($input['username'] ?? '');
|
|
$user = dbGetUserByUsername($username);
|
|
// Palauta aina sama viesti (ei paljasta onko tunnus olemassa)
|
|
if ($user && !empty($user['email'])) {
|
|
$token = generateToken();
|
|
dbSaveToken($user['id'], $token);
|
|
$resetUrl = SITE_URL . '/?reset=' . $token;
|
|
$html = '<div style="font-family:sans-serif;max-width:500px;margin:0 auto;">';
|
|
$html .= '<h2 style="color:#0f3460;">Noxus HUB</h2>';
|
|
$html .= '<p>Hei ' . htmlspecialchars($user['nimi'] ?: $user['username']) . ',</p>';
|
|
$html .= '<p>Sait tämän viestin koska salasanan palautusta pyydettiin tilillesi.</p>';
|
|
$html .= '<p><a href="' . $resetUrl . '" style="display:inline-block;background:#0f3460;color:#fff;padding:12px 24px;border-radius:8px;text-decoration:none;font-weight:600;">Vaihda salasana</a></p>';
|
|
$html .= '<p style="color:#888;font-size:0.9em;">Linkki on voimassa 1 tunnin. Jos et pyytänyt salasanan vaihtoa, voit jättää tämän viestin huomiotta.</p>';
|
|
$html .= '</div>';
|
|
sendMail($user['email'], 'Salasanan palautus - Noxus HUB', $html);
|
|
}
|
|
echo json_encode(['success' => true, 'message' => 'Jos käyttäjätunnus löytyy ja sillä on sähköposti, palautuslinkki lähetetään.']);
|
|
break;
|
|
|
|
case 'password_reset':
|
|
if ($method !== 'POST') break;
|
|
$input = json_decode(file_get_contents('php://input'), true);
|
|
$token = $input['token'] ?? '';
|
|
$newPassword = $input['password'] ?? '';
|
|
if (strlen($newPassword) < 4) {
|
|
http_response_code(400);
|
|
echo json_encode(['error' => 'Salasanan pitää olla vähintään 4 merkkiä']);
|
|
break;
|
|
}
|
|
$userId = dbValidateToken($token);
|
|
if (!$userId) {
|
|
http_response_code(400);
|
|
echo json_encode(['error' => 'Palautuslinkki on vanhentunut tai virheellinen']);
|
|
break;
|
|
}
|
|
$user = dbGetUser($userId);
|
|
if ($user) {
|
|
$user['password_hash'] = password_hash($newPassword, PASSWORD_DEFAULT);
|
|
dbSaveUser($user);
|
|
}
|
|
dbRemoveToken($token);
|
|
echo json_encode(['success' => true, 'message' => 'Salasana vaihdettu onnistuneesti']);
|
|
break;
|
|
|
|
case 'validate_reset_token':
|
|
$token = $_GET['token'] ?? '';
|
|
$userId = dbValidateToken($token);
|
|
echo json_encode(['valid' => $userId !== null]);
|
|
break;
|
|
|
|
// ---------- USERS ----------
|
|
case 'users':
|
|
requireAdmin();
|
|
$users = dbLoadUsers();
|
|
// Admin näkee vain oman yrityksensä käyttäjät, superadmin näkee kaikki
|
|
$role = $_SESSION['role'] ?? '';
|
|
$companyId = $_SESSION['company_id'] ?? '';
|
|
if ($role !== 'superadmin') {
|
|
$users = array_filter($users, function($u) use ($companyId) {
|
|
return in_array($companyId, $u['companies'] ?? []);
|
|
});
|
|
}
|
|
$safe = array_map(function($u) {
|
|
unset($u['password_hash']);
|
|
return $u;
|
|
}, $users);
|
|
echo json_encode(array_values($safe));
|
|
break;
|
|
|
|
case 'user_create':
|
|
requireAdmin();
|
|
if ($method !== 'POST') break;
|
|
$input = json_decode(file_get_contents('php://input'), true);
|
|
$username = trim($input['username'] ?? '');
|
|
$password = $input['password'] ?? '';
|
|
$nimi = trim($input['nimi'] ?? '');
|
|
$email = trim($input['email'] ?? '');
|
|
$isSA = ($_SESSION['role'] ?? '') === 'superadmin';
|
|
// Globaali rooli: user tai superadmin (admin on nyt yrityskohtainen)
|
|
$validRoles = $isSA ? ['superadmin', 'user'] : ['user'];
|
|
$role = in_array($input['role'] ?? '', $validRoles) ? $input['role'] : 'user';
|
|
if (empty($username) || empty($password)) {
|
|
http_response_code(400);
|
|
echo json_encode(['error' => 'Käyttäjätunnus ja salasana vaaditaan']);
|
|
break;
|
|
}
|
|
if (strlen($password) < 4) {
|
|
http_response_code(400);
|
|
echo json_encode(['error' => 'Salasanan pitää olla vähintään 4 merkkiä']);
|
|
break;
|
|
}
|
|
$existingUser = dbGetUserByUsername($username);
|
|
if ($existingUser) {
|
|
http_response_code(400);
|
|
echo json_encode(['error' => 'Käyttäjätunnus on jo käytössä']);
|
|
break;
|
|
}
|
|
$companies = $input['companies'] ?? [];
|
|
// Admin voi lisätä käyttäjiä vain omaan yritykseensä
|
|
if (!$isSA) {
|
|
$myCompanyId = $_SESSION['company_id'] ?? '';
|
|
$companies = [$myCompanyId];
|
|
}
|
|
// Validoi yritys-IDt
|
|
$allCompanies = dbLoadCompanies();
|
|
$validIds = array_column($allCompanies, 'id');
|
|
$companies = array_values(array_filter($companies, fn($c) => in_array($c, $validIds)));
|
|
$signatures = [];
|
|
if (isset($input['signatures']) && is_array($input['signatures'])) {
|
|
foreach ($input['signatures'] as $mbId => $sig) {
|
|
$signatures[(string)$mbId] = (string)$sig;
|
|
}
|
|
}
|
|
// Yrityskohtaiset roolit
|
|
$companyRoles = [];
|
|
if ($isSA && isset($input['company_roles']) && is_array($input['company_roles'])) {
|
|
foreach ($input['company_roles'] as $cid => $crole) {
|
|
if (in_array($cid, $companies) && in_array($crole, ['admin', 'user'])) {
|
|
$companyRoles[$cid] = $crole;
|
|
}
|
|
}
|
|
} elseif (!$isSA) {
|
|
// Admin luo käyttäjiä omaan yritykseensä — voi valita admin tai user
|
|
$myCompanyId = $_SESSION['company_id'] ?? '';
|
|
$requestedRole = $input['company_roles'][$myCompanyId] ?? 'user';
|
|
$companyRoles[$myCompanyId] = in_array($requestedRole, ['admin', 'user']) ? $requestedRole : 'user';
|
|
}
|
|
$newUser = [
|
|
'id' => generateId(),
|
|
'username' => $username,
|
|
'password_hash' => password_hash($password, PASSWORD_DEFAULT),
|
|
'nimi' => $nimi ?: $username,
|
|
'email' => $email,
|
|
'role' => $role,
|
|
'companies' => $companies,
|
|
'company_roles' => $companyRoles,
|
|
'signatures' => $signatures,
|
|
'luotu' => date('Y-m-d H:i:s'),
|
|
];
|
|
dbSaveUser($newUser);
|
|
$companyId = $_SESSION['company_id'] ?? '';
|
|
dbAddLog($companyId, currentUser(), 'user_create', '', '', "Lisäsi käyttäjän: {$username} ({$role})");
|
|
unset($newUser['password_hash']);
|
|
echo json_encode($newUser);
|
|
break;
|
|
|
|
case 'user_update':
|
|
requireAdmin();
|
|
if ($method !== 'POST') break;
|
|
$input = json_decode(file_get_contents('php://input'), true);
|
|
$id = $input['id'] ?? '';
|
|
$u = dbGetUser($id);
|
|
if (!$u) {
|
|
http_response_code(404);
|
|
echo json_encode(['error' => 'Käyttäjää ei löydy']);
|
|
break;
|
|
}
|
|
$isSA = ($_SESSION['role'] ?? '') === 'superadmin';
|
|
$myCompanyId = $_SESSION['company_id'] ?? '';
|
|
// Admin voi muokata vain oman yrityksensä käyttäjiä
|
|
if (!$isSA && !in_array($myCompanyId, $u['companies'] ?? [])) {
|
|
http_response_code(403);
|
|
echo json_encode(['error' => 'Ei oikeutta muokata tätä käyttäjää']);
|
|
break;
|
|
}
|
|
if (isset($input['nimi'])) $u['nimi'] = trim($input['nimi']);
|
|
if (isset($input['email'])) $u['email'] = trim($input['email']);
|
|
if (isset($input['role'])) {
|
|
// Globaali rooli: user tai superadmin (admin on nyt yrityskohtainen)
|
|
$validRoles = $isSA ? ['superadmin', 'user'] : ['user'];
|
|
// Admin ei voi muuttaa superadminia
|
|
if (!$isSA && ($u['role'] === 'superadmin')) {
|
|
// Älä muuta roolia
|
|
} else {
|
|
$u['role'] = in_array($input['role'], $validRoles) ? $input['role'] : 'user';
|
|
}
|
|
}
|
|
if (isset($input['companies'])) {
|
|
$allCompanies = dbLoadCompanies();
|
|
$validIds = array_column($allCompanies, 'id');
|
|
$u['companies'] = array_values(array_filter($input['companies'], fn($c) => in_array($c, $validIds)));
|
|
}
|
|
// Yrityskohtaiset roolit
|
|
if (isset($input['company_roles']) && is_array($input['company_roles'])) {
|
|
$companyRoles = $u['company_roles'] ?? [];
|
|
foreach ($input['company_roles'] as $cid => $crole) {
|
|
if (!in_array($cid, $u['companies'] ?? []) || !in_array($crole, ['admin', 'user'])) continue;
|
|
// Admin voi muuttaa vain oman yrityksensä rooleja
|
|
if (!$isSA && $cid !== $myCompanyId) continue;
|
|
// Admin ei voi muuttaa superadminin roolia
|
|
if (!$isSA && ($u['role'] === 'superadmin')) continue;
|
|
$companyRoles[$cid] = $crole;
|
|
}
|
|
$u['company_roles'] = $companyRoles;
|
|
}
|
|
if (!empty($input['password'])) {
|
|
$u['password_hash'] = password_hash($input['password'], PASSWORD_DEFAULT);
|
|
}
|
|
if (isset($input['signatures']) && is_array($input['signatures'])) {
|
|
$sigs = [];
|
|
foreach ($input['signatures'] as $mbId => $sig) {
|
|
$sigs[(string)$mbId] = (string)$sig;
|
|
}
|
|
$u['signatures'] = $sigs;
|
|
}
|
|
dbSaveUser($u);
|
|
$companyId = $_SESSION['company_id'] ?? '';
|
|
dbAddLog($companyId, currentUser(), 'user_update', '', '', "Muokkasi käyttäjää: {$u['username']}");
|
|
// Päivitä sessio jos muokattiin kirjautunutta käyttäjää
|
|
if ($u['id'] === $_SESSION['user_id']) {
|
|
$_SESSION['companies'] = $u['companies'] ?? [];
|
|
$_SESSION['company_roles'] = $u['company_roles'] ?? [];
|
|
if (!empty($u['companies']) && !in_array($_SESSION['company_id'] ?? '', $u['companies'])) {
|
|
$_SESSION['company_id'] = $u['companies'][0];
|
|
}
|
|
if (empty($u['companies'])) {
|
|
$_SESSION['company_id'] = '';
|
|
}
|
|
// Päivitä aktiivisen yrityksen rooli
|
|
$_SESSION['company_role'] = ($_SESSION['company_roles'][$_SESSION['company_id'] ?? '']) ?? 'user';
|
|
}
|
|
$safe = $u;
|
|
unset($safe['password_hash']);
|
|
echo json_encode($safe);
|
|
break;
|
|
|
|
case 'user_delete':
|
|
requireAdmin();
|
|
if ($method !== 'POST') break;
|
|
$input = json_decode(file_get_contents('php://input'), true);
|
|
$id = $input['id'] ?? '';
|
|
if ($id === $_SESSION['user_id']) {
|
|
http_response_code(400);
|
|
echo json_encode(['error' => 'Et voi poistaa itseäsi']);
|
|
break;
|
|
}
|
|
$deleted = dbGetUser($id);
|
|
$isSA = ($_SESSION['role'] ?? '') === 'superadmin';
|
|
$myCompanyId = $_SESSION['company_id'] ?? '';
|
|
// Admin ei voi poistaa superadmineja eikä toisen yrityksen käyttäjiä
|
|
if (!$isSA) {
|
|
if ($deleted && ($deleted['role'] === 'superadmin' || !in_array($myCompanyId, $deleted['companies'] ?? []))) {
|
|
http_response_code(403);
|
|
echo json_encode(['error' => 'Ei oikeutta poistaa tätä käyttäjää']);
|
|
break;
|
|
}
|
|
}
|
|
$companyId = $_SESSION['company_id'] ?? '';
|
|
if ($isSA) {
|
|
// Superadmin poistaa käyttäjän kokonaan
|
|
dbDeleteUser($id);
|
|
if ($deleted) dbAddLog($companyId, currentUser(), 'user_delete', '', '', "Poisti käyttäjän kokonaan: {$deleted['username']}");
|
|
} else {
|
|
// Admin poistaa käyttäjän vain nykyisestä yrityksestä
|
|
dbRemoveUserFromCompany($id, $companyId);
|
|
if ($deleted) dbAddLog($companyId, currentUser(), 'user_delete', '', '', "Poisti käyttäjän yrityksestä: {$deleted['username']}");
|
|
// Jos käyttäjällä ei ole enää yhtään yritystä, poista kokonaan
|
|
$remaining = dbGetUserCompanies($id);
|
|
if (empty($remaining)) {
|
|
dbDeleteUser($id);
|
|
}
|
|
}
|
|
echo json_encode(['success' => true]);
|
|
break;
|
|
|
|
// ---------- PROFILE (oma profiili) ----------
|
|
case 'profile_update':
|
|
requireAuth();
|
|
if ($method !== 'POST') break;
|
|
$input = json_decode(file_get_contents('php://input'), true);
|
|
$userId = $_SESSION['user_id'];
|
|
$u = dbGetUser($userId);
|
|
if (!$u) {
|
|
http_response_code(404);
|
|
echo json_encode(['error' => 'Käyttäjää ei löydy']);
|
|
break;
|
|
}
|
|
if (isset($input['nimi'])) $u['nimi'] = trim($input['nimi']);
|
|
if (isset($input['email'])) $u['email'] = trim($input['email']);
|
|
if (!empty($input['password'])) {
|
|
if (strlen($input['password']) < 4) {
|
|
http_response_code(400);
|
|
echo json_encode(['error' => 'Salasanan pitää olla vähintään 4 merkkiä']);
|
|
break;
|
|
}
|
|
$u['password_hash'] = password_hash($input['password'], PASSWORD_DEFAULT);
|
|
}
|
|
if (isset($input['signatures']) && is_array($input['signatures'])) {
|
|
$sigs = [];
|
|
foreach ($input['signatures'] as $mbId => $sig) {
|
|
$sigs[(string)$mbId] = (string)$sig;
|
|
}
|
|
$u['signatures'] = $sigs;
|
|
}
|
|
if (isset($input['hidden_mailboxes']) && is_array($input['hidden_mailboxes'])) {
|
|
$u['hidden_mailboxes'] = array_map('strval', $input['hidden_mailboxes']);
|
|
}
|
|
dbSaveUser($u);
|
|
// Päivitä session nimi
|
|
$_SESSION['nimi'] = $u['nimi'];
|
|
$safe = $u;
|
|
unset($safe['password_hash']);
|
|
echo json_encode($safe);
|
|
break;
|
|
|
|
// ---------- CHANGELOG ----------
|
|
case 'changelog':
|
|
requireAuth();
|
|
$companyId = requireCompany();
|
|
$limit = intval($_GET['limit'] ?? 100);
|
|
echo json_encode(dbLoadChangelog($companyId, $limit));
|
|
break;
|
|
|
|
// ---------- CUSTOMERS ----------
|
|
case 'customers':
|
|
requireAuth();
|
|
$companyId = requireCompany();
|
|
if ($method === 'GET') {
|
|
echo json_encode(dbLoadCustomers($companyId));
|
|
}
|
|
break;
|
|
|
|
case 'customer':
|
|
requireAuth();
|
|
$companyId = requireCompany();
|
|
if ($method === 'POST') {
|
|
$input = json_decode(file_get_contents('php://input'), true);
|
|
$customer = [
|
|
'id' => generateId(),
|
|
'yritys' => trim($input['yritys'] ?? ''),
|
|
'yhteyshenkilö' => trim($input['yhteyshenkilö'] ?? ''),
|
|
'puhelin' => trim($input['puhelin'] ?? ''),
|
|
'sahkoposti' => trim($input['sahkoposti'] ?? ''),
|
|
'laskutusosoite' => trim($input['laskutusosoite'] ?? ''),
|
|
'laskutuspostinumero' => trim($input['laskutuspostinumero'] ?? ''),
|
|
'laskutuskaupunki' => trim($input['laskutuskaupunki'] ?? ''),
|
|
'laskutussahkoposti' => trim($input['laskutussahkoposti'] ?? ''),
|
|
'elaskuosoite' => trim($input['elaskuosoite'] ?? ''),
|
|
'elaskuvalittaja' => trim($input['elaskuvalittaja'] ?? ''),
|
|
'ytunnus' => trim($input['ytunnus'] ?? ''),
|
|
'lisatiedot' => trim($input['lisatiedot'] ?? ''),
|
|
'priority_emails' => trim($input['priority_emails'] ?? ''),
|
|
'liittymat' => parseLiittymat($input),
|
|
'luotu' => date('Y-m-d H:i:s'),
|
|
];
|
|
if (empty($customer['yritys'])) {
|
|
http_response_code(400);
|
|
echo json_encode(['error' => 'Yrityksen nimi vaaditaan']);
|
|
break;
|
|
}
|
|
dbSaveCustomer($companyId, $customer);
|
|
dbAddLog($companyId, currentUser(), 'customer_create', $customer['id'], $customer['yritys'], 'Lisäsi asiakkaan');
|
|
echo json_encode($customer);
|
|
}
|
|
break;
|
|
|
|
case 'customer_update':
|
|
requireAuth();
|
|
$companyId = requireCompany();
|
|
if ($method !== 'POST') break;
|
|
$input = json_decode(file_get_contents('php://input'), true);
|
|
$id = $input['id'] ?? '';
|
|
$customers = dbLoadCustomers($companyId);
|
|
$found = false;
|
|
foreach ($customers as $c) {
|
|
if ($c['id'] === $id) {
|
|
$changes = [];
|
|
$fields = ['yritys','yhteyshenkilö','puhelin','sahkoposti','laskutusosoite','laskutuspostinumero','laskutuskaupunki','laskutussahkoposti','elaskuosoite','elaskuvalittaja','ytunnus','lisatiedot','priority_emails'];
|
|
foreach ($fields as $f) {
|
|
if (isset($input[$f])) {
|
|
$old = $c[$f] ?? '';
|
|
$new = trim($input[$f]);
|
|
if ($old !== $new) $changes[] = $f;
|
|
$c[$f] = $new;
|
|
}
|
|
}
|
|
if (isset($input['liittymat'])) {
|
|
$c['liittymat'] = parseLiittymat($input);
|
|
$changes[] = 'liittymat';
|
|
}
|
|
$c['muokattu'] = date('Y-m-d H:i:s');
|
|
$c['muokkaaja'] = currentUser();
|
|
$found = true;
|
|
dbSaveCustomer($companyId, $c);
|
|
dbAddLog($companyId, currentUser(), 'customer_update', $c['id'], $c['yritys'], 'Muokkasi: ' . implode(', ', $changes));
|
|
echo json_encode($c);
|
|
break;
|
|
}
|
|
}
|
|
if (!$found) {
|
|
http_response_code(404);
|
|
echo json_encode(['error' => 'Asiakasta ei löydy']);
|
|
}
|
|
break;
|
|
|
|
case 'customer_delete':
|
|
requireAuth();
|
|
$companyId = requireCompany();
|
|
if ($method !== 'POST') break;
|
|
$input = json_decode(file_get_contents('php://input'), true);
|
|
$id = $input['id'] ?? '';
|
|
$customers = dbLoadCustomers($companyId);
|
|
$archived = null;
|
|
foreach ($customers as $c) {
|
|
if ($c['id'] === $id) {
|
|
$c['arkistoitu'] = date('Y-m-d H:i:s');
|
|
$c['arkistoija'] = currentUser();
|
|
$archived = $c;
|
|
break;
|
|
}
|
|
}
|
|
if ($archived) {
|
|
dbArchiveCustomer($companyId, $archived);
|
|
dbDeleteCustomer($id, $companyId);
|
|
dbAddLog($companyId, currentUser(), 'customer_archive', $archived['id'], $archived['yritys'], 'Arkistoi asiakkaan');
|
|
}
|
|
echo json_encode(['success' => true]);
|
|
break;
|
|
|
|
// ---------- SIJAINNIT (SITES) — YHDISTETTY LAITETILOIHIN ----------
|
|
// sites-endpoint palauttaa nyt laitetilat (taaksepäin yhteensopivuus)
|
|
case 'sites':
|
|
requireAuth();
|
|
$companyId = requireCompany();
|
|
echo json_encode(dbLoadLaitetilat($companyId));
|
|
break;
|
|
|
|
// ---------- LAITTEET (DEVICES) ----------
|
|
case 'devices':
|
|
requireAuth();
|
|
$companyId = requireCompany();
|
|
echo json_encode(dbLoadDevices($companyId));
|
|
break;
|
|
|
|
case 'device_save':
|
|
requireAuth();
|
|
$companyId = requireCompany();
|
|
if ($method !== 'POST') break;
|
|
$input = json_decode(file_get_contents('php://input'), true);
|
|
$isNew = empty($input['id']);
|
|
$device = [
|
|
'id' => $input['id'] ?? generateId(),
|
|
'nimi' => trim($input['nimi'] ?? ''),
|
|
'hallintaosoite' => trim($input['hallintaosoite'] ?? ''),
|
|
'serial' => trim($input['serial'] ?? ''),
|
|
'site_id' => null,
|
|
'laitetila_id' => $input['laitetila_id'] ?? null,
|
|
'funktio' => trim($input['funktio'] ?? ''),
|
|
'tyyppi' => trim($input['tyyppi'] ?? ''),
|
|
'malli' => trim($input['malli'] ?? ''),
|
|
'ping_check' => !empty($input['ping_check']),
|
|
'lisatiedot' => trim($input['lisatiedot'] ?? ''),
|
|
'luotu' => $isNew ? date('Y-m-d H:i:s') : ($input['luotu'] ?? date('Y-m-d H:i:s')),
|
|
'muokattu' => $isNew ? null : date('Y-m-d H:i:s'),
|
|
'muokkaaja' => $isNew ? '' : currentUser(),
|
|
];
|
|
if (empty($device['nimi'])) {
|
|
http_response_code(400);
|
|
echo json_encode(['error' => 'Laitteen nimi vaaditaan']);
|
|
break;
|
|
}
|
|
dbSaveDevice($companyId, $device);
|
|
// Auto-IPAM: varaa hallintaosoite laitteelle IPAM:iin
|
|
$mgmtIp = $device['hallintaosoite'];
|
|
if ($mgmtIp) {
|
|
// Normalisoi: poista mahdollinen /32 suffix
|
|
$cleanIp = explode('/', $mgmtIp)[0];
|
|
// Etsi löytyykö jo IPAM:ista
|
|
$ipamEntries = dbLoadIpam($companyId);
|
|
$existing = null;
|
|
foreach ($ipamEntries as $ie) {
|
|
$ieClean = explode('/', $ie['verkko'])[0];
|
|
if ($ieClean === $cleanIp && $ie['tyyppi'] === 'ip') {
|
|
$existing = $ie;
|
|
break;
|
|
}
|
|
}
|
|
if ($existing) {
|
|
// Päivitä olemassa oleva: merkitse varatuksi laitteelle
|
|
$existing['tila'] = 'varattu';
|
|
$existing['nimi'] = $device['nimi'];
|
|
$existing['site_id'] = $device['laitetila_id'];
|
|
$existing['muokattu'] = date('Y-m-d H:i:s');
|
|
$existing['muokkaaja'] = currentUser();
|
|
dbSaveIpam($companyId, $existing);
|
|
} else {
|
|
// Luo uusi IPAM-merkintä
|
|
$newIpam = [
|
|
'id' => generateId(),
|
|
'tyyppi' => 'ip',
|
|
'nimi' => $device['nimi'],
|
|
'verkko' => $cleanIp,
|
|
'vlan_id' => null,
|
|
'site_id' => $device['laitetila_id'],
|
|
'tila' => 'varattu',
|
|
'asiakas' => '',
|
|
'lisatiedot' => 'Automaattinen varaus laitteelta: ' . $device['nimi'],
|
|
'luotu' => date('Y-m-d H:i:s'),
|
|
'muokattu' => null,
|
|
'muokkaaja' => currentUser(),
|
|
];
|
|
dbSaveIpam($companyId, $newIpam);
|
|
}
|
|
}
|
|
dbAddLog($companyId, currentUser(), $isNew ? 'device_create' : 'device_update', $device['id'], $device['nimi'], ($isNew ? 'Lisäsi' : 'Muokkasi') . ' laitteen');
|
|
echo json_encode($device);
|
|
break;
|
|
|
|
case 'device_delete':
|
|
requireAuth();
|
|
$companyId = requireCompany();
|
|
if ($method !== 'POST') break;
|
|
$input = json_decode(file_get_contents('php://input'), true);
|
|
$id = $input['id'] ?? '';
|
|
$devices = dbLoadDevices($companyId);
|
|
$deviceName = '';
|
|
foreach ($devices as $d) { if ($d['id'] === $id) { $deviceName = $d['nimi']; break; } }
|
|
dbDeleteDevice($id, $companyId);
|
|
dbAddLog($companyId, currentUser(), 'device_delete', $id, $deviceName, 'Poisti laitteen');
|
|
echo json_encode(['success' => true]);
|
|
break;
|
|
|
|
// ---------- IPAM ----------
|
|
case 'ipam':
|
|
requireAuth();
|
|
$companyId = requireCompany();
|
|
echo json_encode(dbLoadIpam($companyId));
|
|
break;
|
|
|
|
case 'ipam_save':
|
|
requireAuth();
|
|
$companyId = requireCompany();
|
|
if ($method !== 'POST') break;
|
|
$input = json_decode(file_get_contents('php://input'), true);
|
|
$entry = [
|
|
'id' => $input['id'] ?? generateId(),
|
|
'tyyppi' => $input['tyyppi'] ?? 'ip',
|
|
'nimi' => trim($input['nimi'] ?? ''),
|
|
'verkko' => trim($input['verkko'] ?? ''),
|
|
'vlan_id' => $input['vlan_id'] ?? null,
|
|
'site_id' => $input['site_id'] ?? null,
|
|
'tila' => $input['tila'] ?? 'vapaa',
|
|
'asiakas' => trim($input['asiakas'] ?? ''),
|
|
'lisatiedot' => trim($input['lisatiedot'] ?? ''),
|
|
'luotu' => $input['luotu'] ?? date('Y-m-d H:i:s'),
|
|
'muokattu' => date('Y-m-d H:i:s'),
|
|
'muokkaaja' => currentUser(),
|
|
];
|
|
// Duplikaatti-tarkistus
|
|
$skipDuplicateCheck = !empty($input['force']);
|
|
if (!$skipDuplicateCheck) {
|
|
$existingAll = dbLoadIpam($companyId);
|
|
// Verkko/IP duplikaatti — estä kokonaan
|
|
if ($entry['verkko'] !== '' && $entry['tyyppi'] !== 'vlan') {
|
|
foreach ($existingAll as $ex) {
|
|
if ($ex['verkko'] === $entry['verkko'] && $ex['id'] !== $entry['id'] && $ex['tyyppi'] !== 'vlan') {
|
|
http_response_code(400);
|
|
echo json_encode(['error' => 'IP-osoite tai verkko "' . $entry['verkko'] . '" on jo olemassa (' . ($ex['nimi'] ?: 'nimetön') . ')']);
|
|
exit;
|
|
}
|
|
}
|
|
}
|
|
// VLAN duplikaatti — varoitus (409 = confirm)
|
|
if ($entry['tyyppi'] === 'vlan' && !empty($entry['vlan_id'])) {
|
|
$vlanNum = (int)$entry['vlan_id'];
|
|
foreach ($existingAll as $ex) {
|
|
if ($ex['tyyppi'] === 'vlan' && (int)$ex['vlan_id'] === $vlanNum && $ex['id'] !== $entry['id']) {
|
|
http_response_code(409);
|
|
echo json_encode(['warning' => 'VLAN ' . $vlanNum . ' on jo olemassa (' . ($ex['nimi'] ?: 'nimetön') . '). Lisätäänkö silti?']);
|
|
exit;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
dbSaveIpam($companyId, $entry);
|
|
// Auto-VLAN: jos subnet/ip:llä on vlan_id, luo VLAN automaattisesti jos ei vielä ole
|
|
if ($entry['tyyppi'] !== 'vlan' && !empty($entry['vlan_id'])) {
|
|
$vlanNum = (int)$entry['vlan_id'];
|
|
$existingIpam = dbLoadIpam($companyId);
|
|
$vlanExists = false;
|
|
foreach ($existingIpam as $ie) {
|
|
if ($ie['tyyppi'] === 'vlan' && (int)$ie['vlan_id'] === $vlanNum) {
|
|
$vlanExists = true;
|
|
break;
|
|
}
|
|
}
|
|
if (!$vlanExists) {
|
|
$newVlan = [
|
|
'id' => generateId(),
|
|
'tyyppi' => 'vlan',
|
|
'nimi' => trim($entry['nimi']) ?: ('VLAN ' . $vlanNum),
|
|
'verkko' => '',
|
|
'vlan_id' => $vlanNum,
|
|
'site_id' => $entry['site_id'],
|
|
'tila' => 'varattu',
|
|
'asiakas' => '',
|
|
'lisatiedot' => 'Luotu automaattisesti',
|
|
'luotu' => date('Y-m-d H:i:s'),
|
|
'muokattu' => null,
|
|
'muokkaaja' => currentUser(),
|
|
];
|
|
dbSaveIpam($companyId, $newVlan);
|
|
}
|
|
}
|
|
$action_label = isset($input['id']) && !empty($input['id']) ? 'ipam_update' : 'ipam_create';
|
|
$desc = ($entry['tyyppi'] === 'vlan' ? 'VLAN ' . ($entry['vlan_id'] ?? '') : $entry['verkko']) . ' ' . $entry['nimi'];
|
|
dbAddLog($companyId, currentUser(), $action_label, $entry['id'], $desc, $action_label === 'ipam_create' ? 'Lisäsi IPAM-merkinnän' : 'Muokkasi IPAM-merkintää');
|
|
echo json_encode($entry);
|
|
break;
|
|
|
|
case 'ipam_delete':
|
|
requireAuth();
|
|
$companyId = requireCompany();
|
|
if ($method !== 'POST') break;
|
|
$input = json_decode(file_get_contents('php://input'), true);
|
|
$id = $input['id'] ?? '';
|
|
$all = dbLoadIpam($companyId);
|
|
$entryName = '';
|
|
foreach ($all as $e) { if ($e['id'] === $id) { $entryName = ($e['tyyppi'] === 'vlan' ? 'VLAN ' . $e['vlan_id'] : $e['verkko']) . ' ' . $e['nimi']; break; } }
|
|
dbDeleteIpam($id, $companyId);
|
|
dbAddLog($companyId, currentUser(), 'ipam_delete', $id, $entryName, 'Poisti IPAM-merkinnän');
|
|
echo json_encode(['success' => true]);
|
|
break;
|
|
|
|
// ---------- OHJEET (GUIDES) ----------
|
|
case 'guides':
|
|
requireAuth();
|
|
$companyId = requireCompany();
|
|
echo json_encode(dbLoadGuides($companyId));
|
|
break;
|
|
|
|
case 'guide':
|
|
requireAuth();
|
|
requireCompany();
|
|
$id = $_GET['id'] ?? '';
|
|
$guide = dbLoadGuide($id);
|
|
if (!$guide) { http_response_code(404); echo json_encode(['error' => 'Ohjetta ei löydy']); exit; }
|
|
echo json_encode($guide);
|
|
break;
|
|
|
|
case 'guide_save':
|
|
requireAuth();
|
|
requireAdmin();
|
|
$companyId = requireCompany();
|
|
if ($method !== 'POST') break;
|
|
$input = json_decode(file_get_contents('php://input'), true);
|
|
$isNew = empty($input['id']);
|
|
$guide = [
|
|
'id' => $input['id'] ?? generateId(),
|
|
'category_id' => $input['category_id'] ?? null,
|
|
'title' => trim($input['title'] ?? ''),
|
|
'content' => $input['content'] ?? '',
|
|
'tags' => trim($input['tags'] ?? ''),
|
|
'author' => $isNew ? currentUser() : ($input['author'] ?? currentUser()),
|
|
'pinned' => !empty($input['pinned']),
|
|
'luotu' => $isNew ? date('Y-m-d H:i:s') : ($input['luotu'] ?? date('Y-m-d H:i:s')),
|
|
'muokattu' => $isNew ? null : date('Y-m-d H:i:s'),
|
|
'muokkaaja' => $isNew ? '' : currentUser(),
|
|
];
|
|
if (empty($guide['title'])) {
|
|
http_response_code(400);
|
|
echo json_encode(['error' => 'Otsikko vaaditaan']);
|
|
exit;
|
|
}
|
|
dbSaveGuide($companyId, $guide);
|
|
dbAddLog($companyId, currentUser(), $isNew ? 'guide_create' : 'guide_update', $guide['id'], $guide['title'], ($isNew ? 'Loi' : 'Muokkasi') . ' ohjeen');
|
|
echo json_encode($guide);
|
|
break;
|
|
|
|
case 'guide_delete':
|
|
requireAuth();
|
|
requireAdmin();
|
|
$companyId = requireCompany();
|
|
if ($method !== 'POST') break;
|
|
$input = json_decode(file_get_contents('php://input'), true);
|
|
$id = $input['id'] ?? '';
|
|
$guide = dbLoadGuide($id);
|
|
dbDeleteGuide($id, $companyId);
|
|
dbAddLog($companyId, currentUser(), 'guide_delete', $id, $guide ? $guide['title'] : '', 'Poisti ohjeen');
|
|
echo json_encode(['success' => true]);
|
|
break;
|
|
|
|
case 'guide_categories':
|
|
requireAuth();
|
|
$companyId = requireCompany();
|
|
echo json_encode(dbLoadGuideCategories($companyId));
|
|
break;
|
|
|
|
case 'guide_category_save':
|
|
requireAuth();
|
|
requireAdmin();
|
|
$companyId = requireCompany();
|
|
if ($method !== 'POST') break;
|
|
$input = json_decode(file_get_contents('php://input'), true);
|
|
$cat = [
|
|
'id' => $input['id'] ?? generateId(),
|
|
'nimi' => trim($input['nimi'] ?? ''),
|
|
'sort_order' => (int)($input['sort_order'] ?? 0),
|
|
];
|
|
if (empty($cat['nimi'])) {
|
|
http_response_code(400);
|
|
echo json_encode(['error' => 'Kategorian nimi vaaditaan']);
|
|
exit;
|
|
}
|
|
dbSaveGuideCategory($companyId, $cat);
|
|
echo json_encode($cat);
|
|
break;
|
|
|
|
case 'guide_category_delete':
|
|
requireAuth();
|
|
requireAdmin();
|
|
$companyId = requireCompany();
|
|
if ($method !== 'POST') break;
|
|
$input = json_decode(file_get_contents('php://input'), true);
|
|
dbDeleteGuideCategory($input['id'] ?? '', $companyId);
|
|
echo json_encode(['success' => true]);
|
|
break;
|
|
|
|
case 'guide_image_upload':
|
|
requireAuth();
|
|
requireAdmin();
|
|
$companyId = requireCompany();
|
|
if ($method !== 'POST') break;
|
|
if (empty($_FILES['image'])) {
|
|
http_response_code(400);
|
|
echo json_encode(['error' => 'Kuva puuttuu']);
|
|
break;
|
|
}
|
|
$file = $_FILES['image'];
|
|
if ($file['error'] !== UPLOAD_ERR_OK) {
|
|
http_response_code(400);
|
|
echo json_encode(['error' => 'Kuvan lähetys epäonnistui']);
|
|
break;
|
|
}
|
|
if ($file['size'] > 5 * 1024 * 1024) {
|
|
http_response_code(400);
|
|
echo json_encode(['error' => 'Kuva on liian suuri (max 5 MB)']);
|
|
break;
|
|
}
|
|
$allowedExt = ['png', 'jpg', 'jpeg', 'gif', 'webp'];
|
|
$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
|
|
if (!in_array($ext, $allowedExt)) {
|
|
http_response_code(400);
|
|
echo json_encode(['error' => 'Sallitut tiedostotyypit: PNG, JPG, GIF, WebP']);
|
|
break;
|
|
}
|
|
$imgDir = getCompanyDir($companyId) . '/guide_images';
|
|
if (!file_exists($imgDir)) mkdir($imgDir, 0755, true);
|
|
$filename = uniqid() . '.' . ($ext === 'jpeg' ? 'jpg' : $ext);
|
|
if (move_uploaded_file($file['tmp_name'], $imgDir . '/' . $filename)) {
|
|
$url = 'api.php?action=guide_image&file=' . urlencode($filename);
|
|
echo json_encode(['success' => true, 'url' => $url, 'filename' => $filename]);
|
|
} else {
|
|
http_response_code(500);
|
|
echo json_encode(['error' => 'Tallennusvirhe']);
|
|
}
|
|
break;
|
|
|
|
case 'guide_image':
|
|
requireAuth();
|
|
$companyId = requireCompany();
|
|
$filename = basename($_GET['file'] ?? '');
|
|
if (!$filename || !preg_match('/^[a-f0-9]+\.(png|jpg|gif|webp)$/', $filename)) {
|
|
http_response_code(400);
|
|
echo 'Virheellinen tiedostonimi';
|
|
break;
|
|
}
|
|
$path = getCompanyDir($companyId) . '/guide_images/' . $filename;
|
|
if (!file_exists($path)) {
|
|
http_response_code(404);
|
|
echo 'Kuvaa ei löydy';
|
|
break;
|
|
}
|
|
$mimes = ['png' => 'image/png', 'jpg' => 'image/jpeg', 'gif' => 'image/gif', 'webp' => 'image/webp'];
|
|
$ext = pathinfo($filename, PATHINFO_EXTENSION);
|
|
header('Content-Type: ' . ($mimes[$ext] ?? 'application/octet-stream'));
|
|
header('Cache-Control: public, max-age=86400');
|
|
readfile($path);
|
|
exit;
|
|
|
|
// ---------- TEHTÄVÄT (TODOS) ----------
|
|
case 'todos':
|
|
requireAuth();
|
|
$companyId = requireCompany();
|
|
echo json_encode(dbLoadTodos($companyId));
|
|
break;
|
|
|
|
case 'todo_detail':
|
|
requireAuth();
|
|
$companyId = requireCompany();
|
|
$id = $_GET['id'] ?? '';
|
|
$todo = dbLoadTodo($id);
|
|
if (!$todo || $todo['company_id'] !== $companyId) {
|
|
http_response_code(404);
|
|
echo json_encode(['error' => 'Tehtävää ei löydy']);
|
|
break;
|
|
}
|
|
unset($todo['company_id']);
|
|
echo json_encode($todo);
|
|
break;
|
|
|
|
case 'todo_save':
|
|
requireAuth();
|
|
$companyId = requireCompany();
|
|
if ($method !== 'POST') break;
|
|
$input = json_decode(file_get_contents('php://input'), true);
|
|
$type = $input['type'] ?? 'task';
|
|
$isNew = empty($input['id']);
|
|
// Task: vain admin. Feature request: kaikki voivat luoda, mutta muokata vain omia (tai admin)
|
|
if ($type === 'task') {
|
|
requireAdmin();
|
|
} elseif (!$isNew) {
|
|
$existing = dbLoadTodo($input['id']);
|
|
if ($existing && $existing['created_by'] !== currentUser() && !isCompanyAdmin()) {
|
|
http_response_code(403);
|
|
echo json_encode(['error' => 'Voit muokata vain omia ehdotuksiasi']);
|
|
break;
|
|
}
|
|
}
|
|
$todo = [
|
|
'id' => $input['id'] ?? generateId(),
|
|
'type' => $type,
|
|
'title' => trim($input['title'] ?? ''),
|
|
'description' => $input['description'] ?? '',
|
|
'status' => $input['status'] ?? ($type === 'task' ? 'avoin' : 'ehdotettu'),
|
|
'priority' => $input['priority'] ?? 'normaali',
|
|
'category' => $input['category'] ?? '',
|
|
'assigned_to' => $input['assigned_to'] ?? '',
|
|
'created_by' => $isNew ? currentUser() : ($input['created_by'] ?? currentUser()),
|
|
'deadline' => $input['deadline'] ?? null,
|
|
'luotu' => $isNew ? date('Y-m-d H:i:s') : ($input['luotu'] ?? date('Y-m-d H:i:s')),
|
|
'muokkaaja' => currentUser(),
|
|
];
|
|
if (empty($todo['title'])) {
|
|
http_response_code(400);
|
|
echo json_encode(['error' => 'Otsikko on pakollinen']);
|
|
break;
|
|
}
|
|
dbSaveTodo($companyId, $todo);
|
|
dbAddLog($companyId, currentUser(), $isNew ? 'todo_create' : 'todo_update', $todo['id'], $todo['title'], '');
|
|
echo json_encode($todo);
|
|
break;
|
|
|
|
case 'todo_delete':
|
|
requireAuth();
|
|
requireAdmin();
|
|
$companyId = requireCompany();
|
|
if ($method !== 'POST') break;
|
|
$input = json_decode(file_get_contents('php://input'), true);
|
|
$id = $input['id'] ?? '';
|
|
$todo = dbLoadTodo($id);
|
|
if ($todo) {
|
|
dbDeleteTodo($id, $companyId);
|
|
dbAddLog($companyId, currentUser(), 'todo_delete', $id, $todo['title'] ?? '', '');
|
|
}
|
|
echo json_encode(['success' => true]);
|
|
break;
|
|
|
|
case 'todo_status':
|
|
requireAuth();
|
|
$companyId = requireCompany();
|
|
if ($method !== 'POST') { echo json_encode(['error' => 'POST required']); break; }
|
|
try {
|
|
$input = json_decode(file_get_contents('php://input'), true);
|
|
$id = $input['id'] ?? '';
|
|
$status = $input['status'] ?? '';
|
|
if (!$id || !$status) { echo json_encode(['error' => 'id ja status vaaditaan']); break; }
|
|
// Tarkista oikeudet kevyellä querylla (ei dbLoadTodo joka voi kaatua)
|
|
$rows = _dbFetchAll("SELECT type, company_id FROM todos WHERE id = ?", [$id]);
|
|
if (empty($rows) || $rows[0]['company_id'] !== $companyId) {
|
|
http_response_code(404);
|
|
echo json_encode(['error' => 'Tehtävää ei löytynyt']);
|
|
break;
|
|
}
|
|
$type = $rows[0]['type'];
|
|
if ($type === 'feature_request' && !isCompanyAdmin()) {
|
|
http_response_code(403);
|
|
echo json_encode(['error' => 'Vain admin voi muuttaa ehdotuksen statusta']);
|
|
break;
|
|
}
|
|
if ($type === 'task' && !isCompanyAdmin()) {
|
|
http_response_code(403);
|
|
echo json_encode(['error' => 'Vain admin voi muuttaa tehtävän statusta']);
|
|
break;
|
|
}
|
|
_dbExecute("UPDATE todos SET status = ?, muokattu = NOW(), muokkaaja = ? WHERE id = ?", [$status, currentUser(), $id]);
|
|
echo json_encode(['success' => true]);
|
|
} catch (\Throwable $e) {
|
|
http_response_code(500);
|
|
echo json_encode(['error' => 'Virhe: ' . $e->getMessage()]);
|
|
}
|
|
break;
|
|
|
|
case 'todo_assign':
|
|
requireAuth();
|
|
requireAdmin();
|
|
$companyId = requireCompany();
|
|
if ($method !== 'POST') { echo json_encode(['error' => 'POST required']); break; }
|
|
try {
|
|
$input = json_decode(file_get_contents('php://input'), true);
|
|
$id = $input['id'] ?? '';
|
|
$assignedTo = $input['assigned_to'] ?? '';
|
|
$rows = _dbFetchAll("SELECT company_id FROM todos WHERE id = ?", [$id]);
|
|
if (empty($rows) || $rows[0]['company_id'] !== $companyId) {
|
|
http_response_code(404);
|
|
echo json_encode(['error' => 'Tehtävää ei löytynyt']);
|
|
break;
|
|
}
|
|
_dbExecute("UPDATE todos SET assigned_to = ?, muokattu = NOW(), muokkaaja = ? WHERE id = ?", [$assignedTo, currentUser(), $id]);
|
|
echo json_encode(['success' => true]);
|
|
} catch (\Throwable $e) {
|
|
http_response_code(500);
|
|
echo json_encode(['error' => 'Virhe: ' . $e->getMessage()]);
|
|
}
|
|
break;
|
|
|
|
case 'todo_comment':
|
|
requireAuth();
|
|
$companyId = requireCompany();
|
|
if ($method !== 'POST') break;
|
|
$input = json_decode(file_get_contents('php://input'), true);
|
|
$todoId = $input['todo_id'] ?? '';
|
|
// Tarkista kuuluuko todo yritykseen
|
|
$todoCheck = _dbFetchOne("SELECT company_id FROM todos WHERE id = ?", [$todoId]);
|
|
if (!$todoCheck || $todoCheck['company_id'] !== $companyId) {
|
|
http_response_code(403);
|
|
echo json_encode(['error' => 'Ei oikeutta']);
|
|
break;
|
|
}
|
|
$body = trim($input['body'] ?? '');
|
|
if (empty($body)) {
|
|
http_response_code(400);
|
|
echo json_encode(['error' => 'Kommentti ei voi olla tyhjä']);
|
|
break;
|
|
}
|
|
$comment = [
|
|
'id' => generateId(),
|
|
'author' => currentUser(),
|
|
'body' => $body,
|
|
'luotu' => date('Y-m-d H:i:s'),
|
|
];
|
|
dbAddTodoComment($todoId, $comment);
|
|
echo json_encode($comment);
|
|
break;
|
|
|
|
case 'todo_comment_delete':
|
|
requireAuth();
|
|
$companyId = requireCompany();
|
|
if ($method !== 'POST') break;
|
|
$input = json_decode(file_get_contents('php://input'), true);
|
|
$commentId = $input['id'] ?? '';
|
|
// Tarkista onko oma kommentti tai admin + kuuluuko todo yritykseen
|
|
$rows = _dbFetchAll("SELECT tc.author, t.company_id FROM todo_comments tc JOIN todos t ON t.id = tc.todo_id WHERE tc.id = ?", [$commentId]);
|
|
if (!empty($rows) && $rows[0]['company_id'] !== $companyId) {
|
|
http_response_code(403);
|
|
echo json_encode(['error' => 'Ei oikeutta']);
|
|
break;
|
|
}
|
|
if (!empty($rows) && ($rows[0]['author'] === currentUser() || isCompanyAdmin())) {
|
|
dbDeleteTodoComment($commentId);
|
|
echo json_encode(['success' => true]);
|
|
} else {
|
|
http_response_code(403);
|
|
echo json_encode(['error' => 'Ei oikeutta']);
|
|
}
|
|
break;
|
|
|
|
case 'todo_time_add':
|
|
requireAuth();
|
|
$companyId = requireCompany();
|
|
if ($method !== 'POST') break;
|
|
$input = json_decode(file_get_contents('php://input'), true);
|
|
$todoId = $input['todo_id'] ?? '';
|
|
// Tarkista kuuluuko todo yritykseen
|
|
$todoCheck = _dbFetchOne("SELECT company_id FROM todos WHERE id = ?", [$todoId]);
|
|
if (!$todoCheck || $todoCheck['company_id'] !== $companyId) {
|
|
http_response_code(403);
|
|
echo json_encode(['error' => 'Ei oikeutta']);
|
|
break;
|
|
}
|
|
$hours = floatval($input['hours'] ?? 0);
|
|
if ($hours <= 0) {
|
|
http_response_code(400);
|
|
echo json_encode(['error' => 'Tunnit pitää olla > 0']);
|
|
break;
|
|
}
|
|
$entry = [
|
|
'id' => generateId(),
|
|
'user' => currentUser(),
|
|
'hours' => $hours,
|
|
'description' => trim($input['description'] ?? ''),
|
|
'work_date' => $input['work_date'] ?? date('Y-m-d'),
|
|
'luotu' => date('Y-m-d H:i:s'),
|
|
];
|
|
dbAddTodoTimeEntry($todoId, $entry);
|
|
echo json_encode($entry);
|
|
break;
|
|
|
|
case 'todo_time_delete':
|
|
requireAuth();
|
|
$companyId = requireCompany();
|
|
if ($method !== 'POST') break;
|
|
$input = json_decode(file_get_contents('php://input'), true);
|
|
$entryId = $input['id'] ?? '';
|
|
$rows = _dbFetchAll("SELECT te.user, t.company_id FROM todo_time_entries te JOIN todos t ON t.id = te.todo_id WHERE te.id = ?", [$entryId]);
|
|
if (!empty($rows) && $rows[0]['company_id'] !== $companyId) {
|
|
http_response_code(403);
|
|
echo json_encode(['error' => 'Ei oikeutta']);
|
|
break;
|
|
}
|
|
if (!empty($rows) && ($rows[0]['user'] === currentUser() || isCompanyAdmin())) {
|
|
dbDeleteTodoTimeEntry($entryId);
|
|
echo json_encode(['success' => true]);
|
|
} else {
|
|
http_response_code(403);
|
|
echo json_encode(['error' => 'Ei oikeutta']);
|
|
}
|
|
break;
|
|
|
|
case 'todo_subtask_add':
|
|
requireAuth();
|
|
$companyId = requireCompany();
|
|
if ($method !== 'POST') { echo json_encode(['error' => 'POST required']); break; }
|
|
try {
|
|
$input = json_decode(file_get_contents('php://input'), true);
|
|
$todoId = $input['todo_id'] ?? '';
|
|
$title = trim($input['title'] ?? '');
|
|
if (!$todoId || !$title) { echo json_encode(['error' => 'todo_id ja title vaaditaan']); break; }
|
|
$rows = _dbFetchAll("SELECT company_id FROM todos WHERE id = ?", [$todoId]);
|
|
if (empty($rows) || $rows[0]['company_id'] !== $companyId) {
|
|
http_response_code(404);
|
|
echo json_encode(['error' => 'Tehtävää ei löytynyt']);
|
|
break;
|
|
}
|
|
$id = generateId();
|
|
dbAddTodoSubtask($todoId, ['id' => $id, 'title' => $title, 'created_by' => currentUser()]);
|
|
echo json_encode(['success' => true, 'id' => $id]);
|
|
} catch (\Throwable $e) {
|
|
http_response_code(500);
|
|
echo json_encode(['error' => 'Virhe: ' . $e->getMessage()]);
|
|
}
|
|
break;
|
|
|
|
case 'todo_subtask_toggle':
|
|
requireAuth();
|
|
$companyId = requireCompany();
|
|
if ($method !== 'POST') { echo json_encode(['error' => 'POST required']); break; }
|
|
try {
|
|
$input = json_decode(file_get_contents('php://input'), true);
|
|
$subtaskId = $input['id'] ?? '';
|
|
if (!$subtaskId) { echo json_encode(['error' => 'id vaaditaan']); break; }
|
|
// Tarkista kuuluuko subtaskin todo yritykseen
|
|
$stCheck = _dbFetchOne("SELECT t.company_id FROM todo_subtasks s JOIN todos t ON t.id = s.todo_id WHERE s.id = ?", [$subtaskId]);
|
|
if (!$stCheck || $stCheck['company_id'] !== $companyId) { http_response_code(403); echo json_encode(['error' => 'Ei oikeutta']); break; }
|
|
$completed = dbToggleTodoSubtask($subtaskId);
|
|
echo json_encode(['success' => true, 'completed' => $completed]);
|
|
} catch (\Throwable $e) {
|
|
http_response_code(500);
|
|
echo json_encode(['error' => 'Virhe: ' . $e->getMessage()]);
|
|
}
|
|
break;
|
|
|
|
case 'todo_subtask_delete':
|
|
requireAuth();
|
|
$companyId = requireCompany();
|
|
if ($method !== 'POST') { echo json_encode(['error' => 'POST required']); break; }
|
|
try {
|
|
$input = json_decode(file_get_contents('php://input'), true);
|
|
$subtaskId = $input['id'] ?? '';
|
|
if (!$subtaskId) { echo json_encode(['error' => 'id vaaditaan']); break; }
|
|
// Tarkista kuuluuko subtaskin todo yritykseen
|
|
$stCheck = _dbFetchOne("SELECT t.company_id FROM todo_subtasks s JOIN todos t ON t.id = s.todo_id WHERE s.id = ?", [$subtaskId]);
|
|
if (!$stCheck || $stCheck['company_id'] !== $companyId) { http_response_code(403); echo json_encode(['error' => 'Ei oikeutta']); break; }
|
|
$rows = _dbFetchAll("SELECT created_by FROM todo_subtasks WHERE id = ?", [$subtaskId]);
|
|
if (!empty($rows) && ($rows[0]['created_by'] === currentUser() || isCompanyAdmin())) {
|
|
dbDeleteTodoSubtask($subtaskId);
|
|
echo json_encode(['success' => true]);
|
|
} else {
|
|
http_response_code(403);
|
|
echo json_encode(['error' => 'Ei oikeutta']);
|
|
}
|
|
} catch (\Throwable $e) {
|
|
http_response_code(500);
|
|
echo json_encode(['error' => 'Virhe: ' . $e->getMessage()]);
|
|
}
|
|
break;
|
|
|
|
// ---------- ARCHIVE ----------
|
|
case 'archived_customers':
|
|
requireAuth();
|
|
$companyId = requireCompany();
|
|
echo json_encode(dbLoadArchive($companyId));
|
|
break;
|
|
|
|
case 'customer_restore':
|
|
requireAuth();
|
|
$companyId = requireCompany();
|
|
if ($method !== 'POST') break;
|
|
$input = json_decode(file_get_contents('php://input'), true);
|
|
$id = $input['id'] ?? '';
|
|
$restored = dbRestoreArchive($id);
|
|
if ($restored) {
|
|
unset($restored['arkistoitu'], $restored['arkistoija'], $restored['archived_at']);
|
|
dbSaveCustomer($companyId, $restored);
|
|
dbAddLog($companyId, currentUser(), 'customer_restore', $restored['id'], $restored['yritys'] ?? '', 'Palautti asiakkaan arkistosta');
|
|
}
|
|
echo json_encode(['success' => true]);
|
|
break;
|
|
|
|
case 'customer_permanent_delete':
|
|
requireAdmin();
|
|
$companyId = requireCompany();
|
|
if ($method !== 'POST') break;
|
|
$input = json_decode(file_get_contents('php://input'), true);
|
|
$id = $input['id'] ?? '';
|
|
// Hae arkistoidun tiedot ennen poistoa
|
|
$archive = dbLoadArchive($companyId);
|
|
$deleted = null;
|
|
foreach ($archive as $c) {
|
|
if ($c['id'] === $id) { $deleted = $c; break; }
|
|
}
|
|
dbDeleteArchive($id, $companyId);
|
|
$filesDir = getCompanyDir() . '/files/' . $id;
|
|
if (is_dir($filesDir)) {
|
|
array_map('unlink', glob($filesDir . '/*'));
|
|
rmdir($filesDir);
|
|
}
|
|
if ($deleted) dbAddLog($companyId, currentUser(), 'customer_permanent_delete', $id, $deleted['yritys'] ?? '', 'Poisti pysyvästi');
|
|
echo json_encode(['success' => true]);
|
|
break;
|
|
|
|
// ---------- LEADS ----------
|
|
case 'leads':
|
|
requireAuth();
|
|
$companyId = requireCompany();
|
|
echo json_encode(dbLoadLeads($companyId));
|
|
break;
|
|
|
|
case 'lead_create':
|
|
requireAuth();
|
|
$companyId = requireCompany();
|
|
if ($method !== 'POST') break;
|
|
$input = json_decode(file_get_contents('php://input'), true);
|
|
$lead = [
|
|
'id' => generateId(),
|
|
'yritys' => trim($input['yritys'] ?? ''),
|
|
'yhteyshenkilo' => trim($input['yhteyshenkilo'] ?? ''),
|
|
'puhelin' => trim($input['puhelin'] ?? ''),
|
|
'sahkoposti' => trim($input['sahkoposti'] ?? ''),
|
|
'osoite' => trim($input['osoite'] ?? ''),
|
|
'kaupunki' => trim($input['kaupunki'] ?? ''),
|
|
'tila' => trim($input['tila'] ?? 'uusi'),
|
|
'muistiinpanot' => trim($input['muistiinpanot'] ?? ''),
|
|
'luotu' => date('Y-m-d H:i:s'),
|
|
'luoja' => currentUser(),
|
|
];
|
|
if (empty($lead['yritys'])) {
|
|
http_response_code(400);
|
|
echo json_encode(['error' => 'Yrityksen nimi vaaditaan']);
|
|
break;
|
|
}
|
|
dbSaveLead($companyId, $lead);
|
|
dbAddLog($companyId, currentUser(), 'lead_create', $lead['id'], $lead['yritys'], 'Lisäsi liidin');
|
|
echo json_encode($lead);
|
|
break;
|
|
|
|
case 'lead_update':
|
|
requireAuth();
|
|
$companyId = requireCompany();
|
|
if ($method !== 'POST') break;
|
|
$input = json_decode(file_get_contents('php://input'), true);
|
|
$id = $input['id'] ?? '';
|
|
$leads = dbLoadLeads($companyId);
|
|
$found = false;
|
|
foreach ($leads as $l) {
|
|
if ($l['id'] === $id) {
|
|
$fields = ['yritys','yhteyshenkilo','puhelin','sahkoposti','osoite','kaupunki','tila','muistiinpanot'];
|
|
foreach ($fields as $f) {
|
|
if (isset($input[$f])) $l[$f] = trim($input[$f]);
|
|
}
|
|
$l['muokattu'] = date('Y-m-d H:i:s');
|
|
$l['muokkaaja'] = currentUser();
|
|
$found = true;
|
|
dbSaveLead($companyId, $l);
|
|
dbAddLog($companyId, currentUser(), 'lead_update', $l['id'], $l['yritys'], 'Muokkasi liidiä');
|
|
echo json_encode($l);
|
|
break;
|
|
}
|
|
}
|
|
if (!$found) {
|
|
http_response_code(404);
|
|
echo json_encode(['error' => 'Liidiä ei löydy']);
|
|
}
|
|
break;
|
|
|
|
case 'lead_delete':
|
|
requireAuth();
|
|
$companyId = requireCompany();
|
|
if ($method !== 'POST') break;
|
|
$input = json_decode(file_get_contents('php://input'), true);
|
|
$id = $input['id'] ?? '';
|
|
$leads = dbLoadLeads($companyId);
|
|
$deleted = null;
|
|
foreach ($leads as $l) {
|
|
if ($l['id'] === $id) { $deleted = $l; break; }
|
|
}
|
|
dbDeleteLead($id, $companyId);
|
|
if ($deleted) dbAddLog($companyId, currentUser(), 'lead_delete', $id, $deleted['yritys'] ?? '', 'Poisti liidin');
|
|
echo json_encode(['success' => true]);
|
|
break;
|
|
|
|
case 'lead_to_customer':
|
|
requireAuth();
|
|
$companyId = requireCompany();
|
|
if ($method !== 'POST') break;
|
|
$input = json_decode(file_get_contents('php://input'), true);
|
|
$id = $input['id'] ?? '';
|
|
$leads = dbLoadLeads($companyId);
|
|
$lead = null;
|
|
foreach ($leads as $l) {
|
|
if ($l['id'] === $id) { $lead = $l; break; }
|
|
}
|
|
if (!$lead) {
|
|
http_response_code(404);
|
|
echo json_encode(['error' => 'Liidiä ei löydy']);
|
|
break;
|
|
}
|
|
// Luo asiakas liidistä
|
|
$customer = [
|
|
'id' => generateId(),
|
|
'yritys' => $lead['yritys'],
|
|
'yhteyshenkilö' => $lead['yhteyshenkilo'] ?? '',
|
|
'puhelin' => $lead['puhelin'] ?? '',
|
|
'sahkoposti' => $lead['sahkoposti'] ?? '',
|
|
'laskutusosoite' => '',
|
|
'laskutuspostinumero' => '',
|
|
'laskutuskaupunki' => '',
|
|
'laskutussahkoposti' => '',
|
|
'elaskuosoite' => '',
|
|
'elaskuvalittaja' => '',
|
|
'ytunnus' => '',
|
|
'lisatiedot' => $lead['muistiinpanot'] ?? '',
|
|
'liittymat' => [['asennusosoite' => $lead['osoite'] ?? '', 'postinumero' => '', 'kaupunki' => $lead['kaupunki'] ?? '', 'liittymanopeus' => '', 'hinta' => 0, 'sopimuskausi' => '', 'alkupvm' => '', 'vlan' => '', 'laite' => '', 'portti' => '', 'ip' => '']],
|
|
'luotu' => date('Y-m-d H:i:s'),
|
|
];
|
|
dbSaveCustomer($companyId, $customer);
|
|
// Poista liidi
|
|
dbDeleteLead($id, $companyId);
|
|
dbAddLog($companyId, currentUser(), 'lead_to_customer', $customer['id'], $customer['yritys'], 'Muutti liidin asiakkaaksi');
|
|
echo json_encode($customer);
|
|
break;
|
|
|
|
// ---------- FILES ----------
|
|
case 'file_upload':
|
|
requireAuth();
|
|
requireCompany();
|
|
if ($method !== 'POST') break;
|
|
$customerId = $_POST['customer_id'] ?? '';
|
|
if (!$customerId || !preg_match('/^[a-f0-9]+$/', $customerId)) {
|
|
http_response_code(400);
|
|
echo json_encode(['error' => 'Virheellinen asiakas-ID']);
|
|
break;
|
|
}
|
|
if (empty($_FILES['file'])) {
|
|
http_response_code(400);
|
|
echo json_encode(['error' => 'Tiedosto puuttuu']);
|
|
break;
|
|
}
|
|
$file = $_FILES['file'];
|
|
if ($file['error'] !== UPLOAD_ERR_OK) {
|
|
http_response_code(400);
|
|
echo json_encode(['error' => 'Tiedoston lähetys epäonnistui']);
|
|
break;
|
|
}
|
|
if ($file['size'] > 20 * 1024 * 1024) {
|
|
http_response_code(400);
|
|
echo json_encode(['error' => 'Tiedosto on liian suuri (max 20 MB)']);
|
|
break;
|
|
}
|
|
$uploadDir = getCompanyDir() . '/files/' . $customerId;
|
|
if (!file_exists($uploadDir)) mkdir($uploadDir, 0755, true);
|
|
$safeName = preg_replace('/[^a-zA-Z0-9._-]/', '_', basename($file['name']));
|
|
$dest = $uploadDir . '/' . $safeName;
|
|
if (file_exists($dest)) {
|
|
$ext = pathinfo($safeName, PATHINFO_EXTENSION);
|
|
$base = pathinfo($safeName, PATHINFO_FILENAME);
|
|
$safeName = $base . '_' . date('His') . ($ext ? '.' . $ext : '');
|
|
$dest = $uploadDir . '/' . $safeName;
|
|
}
|
|
if (move_uploaded_file($file['tmp_name'], $dest)) {
|
|
echo json_encode(['success' => true, 'filename' => $safeName, 'size' => $file['size']]);
|
|
} else {
|
|
http_response_code(500);
|
|
echo json_encode(['error' => 'Tallennusvirhe']);
|
|
}
|
|
break;
|
|
|
|
case 'file_list':
|
|
requireAuth();
|
|
requireCompany();
|
|
$customerId = $_GET['customer_id'] ?? '';
|
|
if (!$customerId || !preg_match('/^[a-f0-9]+$/', $customerId)) {
|
|
echo json_encode([]);
|
|
break;
|
|
}
|
|
$dir = getCompanyDir() . '/files/' . $customerId;
|
|
$files = [];
|
|
if (is_dir($dir)) {
|
|
foreach (scandir($dir) as $f) {
|
|
if ($f === '.' || $f === '..') continue;
|
|
$path = $dir . '/' . $f;
|
|
$files[] = ['filename' => $f, 'size' => filesize($path), 'modified' => date('Y-m-d H:i', filemtime($path))];
|
|
}
|
|
}
|
|
usort($files, fn($a, $b) => strcmp($b['modified'], $a['modified']));
|
|
echo json_encode($files);
|
|
break;
|
|
|
|
case 'file_download':
|
|
requireAuth();
|
|
requireCompany();
|
|
$customerId = $_GET['customer_id'] ?? '';
|
|
$filename = $_GET['filename'] ?? '';
|
|
if (!$customerId || !preg_match('/^[a-f0-9]+$/', $customerId) || !$filename) {
|
|
http_response_code(400);
|
|
echo json_encode(['error' => 'Virheelliset parametrit']);
|
|
break;
|
|
}
|
|
$safeName = basename($filename);
|
|
$path = getCompanyDir() . '/files/' . $customerId . '/' . $safeName;
|
|
if (!file_exists($path)) {
|
|
http_response_code(404);
|
|
echo json_encode(['error' => 'Tiedostoa ei löydy']);
|
|
break;
|
|
}
|
|
header('Content-Type: application/octet-stream');
|
|
header('Content-Disposition: attachment; filename="' . $safeName . '"');
|
|
header('Content-Length: ' . filesize($path));
|
|
readfile($path);
|
|
exit;
|
|
|
|
case 'file_delete':
|
|
requireAuth();
|
|
requireCompany();
|
|
if ($method !== 'POST') break;
|
|
$input = json_decode(file_get_contents('php://input'), true);
|
|
$customerId = $input['customer_id'] ?? '';
|
|
$filename = $input['filename'] ?? '';
|
|
if (!$customerId || !preg_match('/^[a-f0-9]+$/', $customerId) || !$filename) {
|
|
http_response_code(400);
|
|
echo json_encode(['error' => 'Virheelliset parametrit']);
|
|
break;
|
|
}
|
|
$safeName = basename($filename);
|
|
$path = getCompanyDir() . '/files/' . $customerId . '/' . $safeName;
|
|
if (file_exists($path)) unlink($path);
|
|
echo json_encode(['success' => true]);
|
|
break;
|
|
|
|
// ---------- TICKETS ----------
|
|
case 'tickets':
|
|
requireAuth();
|
|
$allCompaniesMode = !empty($_GET['all']);
|
|
$userCompanyIds = $_SESSION['companies'] ?? [];
|
|
|
|
// Kerää yritykset joista haetaan
|
|
$companiesToQuery = [];
|
|
if ($allCompaniesMode && count($userCompanyIds) > 1) {
|
|
$allComps = dbLoadCompanies();
|
|
foreach ($allComps as $c) {
|
|
if (in_array($c['id'], $userCompanyIds)) {
|
|
$companiesToQuery[] = $c;
|
|
}
|
|
}
|
|
} else {
|
|
requireCompany();
|
|
$companiesToQuery[] = ['id' => $_SESSION['company_id'], 'nimi' => ''];
|
|
}
|
|
|
|
$list = [];
|
|
foreach ($companiesToQuery as $comp) {
|
|
$tickets = dbLoadTickets($comp['id']);
|
|
|
|
// Auto-close tarkistus
|
|
$now = date('Y-m-d H:i:s');
|
|
foreach ($tickets as &$tc) {
|
|
if (!empty($tc['auto_close_at']) && $tc['auto_close_at'] <= $now && !in_array($tc['status'], ['suljettu'])) {
|
|
$tc['status'] = 'suljettu';
|
|
$tc['updated'] = $now;
|
|
dbSaveTicket($comp['id'], $tc);
|
|
}
|
|
}
|
|
unset($tc);
|
|
|
|
// Ajastettujen sääntöjen suoritus (delay_execute_at saavutettu)
|
|
foreach ($tickets as &$tc) {
|
|
if (empty($tc['delay_rule_id']) || empty($tc['delay_execute_at'])) continue;
|
|
if ($tc['delay_execute_at'] > $now) continue;
|
|
if (in_array($tc['status'], ['suljettu', 'ratkaistu'])) {
|
|
// Suljettu/ratkaistu ennen ajastinta → peruuta
|
|
unset($tc['delay_rule_id'], $tc['delay_execute_at']);
|
|
dbSaveTicket($comp['id'], $tc);
|
|
continue;
|
|
}
|
|
// Hae sääntö ja suorita toimenpiteet
|
|
$allRules = dbLoadTicketRules($comp['id']);
|
|
$rule = null;
|
|
foreach ($allRules as $r) { if ($r['id'] === $tc['delay_rule_id']) { $rule = $r; break; } }
|
|
if ($rule) {
|
|
if (!empty($rule['status_set'])) $tc['status'] = $rule['status_set'];
|
|
if (!empty($rule['type_set'])) $tc['type'] = $rule['type_set'];
|
|
if (!empty($rule['set_priority'])) $tc['priority'] = $rule['set_priority'];
|
|
if (!empty($rule['set_tags'])) {
|
|
$ruleTags = array_map('trim', explode(',', $rule['set_tags']));
|
|
$tc['tags'] = array_values(array_unique(array_merge($tc['tags'] ?? [], $ruleTags)));
|
|
}
|
|
}
|
|
unset($tc['delay_rule_id'], $tc['delay_execute_at']);
|
|
$tc['updated'] = $now;
|
|
dbSaveTicket($comp['id'], $tc);
|
|
}
|
|
unset($tc);
|
|
|
|
// Resolve mailbox names for this company
|
|
$mailboxes = dbLoadMailboxes($comp['id']);
|
|
$mailboxNames = [];
|
|
foreach ($mailboxes as $mb) {
|
|
$mailboxNames[$mb['id']] = $mb['nimi'];
|
|
}
|
|
|
|
foreach ($tickets as $t) {
|
|
$msgCount = count($t['messages'] ?? []);
|
|
$lastMsg = $msgCount > 0 ? $t['messages'][$msgCount - 1] : null;
|
|
$hasAttachments = false;
|
|
foreach ($t['messages'] ?? [] as $m) {
|
|
if (!empty($m['attachments'])) { $hasAttachments = true; break; }
|
|
}
|
|
$list[] = [
|
|
'id' => $t['id'],
|
|
'subject' => $t['subject'],
|
|
'from_email' => $t['from_email'],
|
|
'from_name' => $t['from_name'],
|
|
'status' => $t['status'],
|
|
'type' => $t['type'] ?? 'muu',
|
|
'assigned_to' => $t['assigned_to'] ?? '',
|
|
'customer_id' => $t['customer_id'] ?? '',
|
|
'customer_name' => $t['customer_name'] ?? '',
|
|
'tags' => $t['tags'] ?? [],
|
|
'priority' => $t['priority'] ?? 'normaali',
|
|
'auto_close_at' => $t['auto_close_at'] ?? '',
|
|
'mailbox_id' => $t['mailbox_id'] ?? '',
|
|
'mailbox_name' => $mailboxNames[$t['mailbox_id'] ?? ''] ?? '',
|
|
'company_id' => $comp['id'],
|
|
'company_name' => $comp['nimi'] ?? '',
|
|
'created' => $t['created'],
|
|
'updated' => $t['updated'],
|
|
'message_count' => $msgCount,
|
|
'last_message_type' => $lastMsg ? ($lastMsg['type'] ?? '') : '',
|
|
'last_message_time' => $lastMsg ? ($lastMsg['timestamp'] ?? '') : '',
|
|
'has_attachments' => $hasAttachments,
|
|
'source' => $t['source'] ?? '',
|
|
'zammad_group' => $t['zammad_group'] ?? '',
|
|
'ticket_number' => $t['ticket_number'] ?? '',
|
|
];
|
|
}
|
|
}
|
|
echo json_encode($list);
|
|
break;
|
|
|
|
case 'ticket_detail':
|
|
requireAuth();
|
|
$companyId = requireCompanyOrParam();
|
|
$id = $_GET['id'] ?? '';
|
|
$tickets = dbLoadTickets($companyId);
|
|
$ticket = null;
|
|
foreach ($tickets as $t) {
|
|
if ($t['id'] === $id) { $ticket = $t; break; }
|
|
}
|
|
if (!$ticket) {
|
|
http_response_code(404);
|
|
echo json_encode(['error' => 'Tikettiä ei löydy']);
|
|
break;
|
|
}
|
|
// Zammad-tiketti: hae artikkelit on-demand jos ei vielä haettu
|
|
if (!empty($ticket['zammad_ticket_id']) && empty($ticket['messages'])) {
|
|
try {
|
|
$integ = dbGetIntegration($companyId, 'zammad');
|
|
if ($integ && $integ['enabled']) {
|
|
$z = new ZammadClient($integ['config']['url'], $integ['config']['token']);
|
|
$articles = $z->getArticles((int)$ticket['zammad_ticket_id']);
|
|
$toEmail = '';
|
|
foreach ($articles as $art) {
|
|
if (($art['internal'] ?? false)) continue;
|
|
// Tallenna ensimmäisen saapuneen viestin to-osoite
|
|
if (!$toEmail && ($art['sender'] ?? '') === 'Customer' && !empty($art['to'])) {
|
|
$toEmail = $art['to'];
|
|
}
|
|
$artId = (int)$art['id'];
|
|
$existingMsg = dbGetMessageByZammadArticleId($ticket['id'], $artId);
|
|
if ($existingMsg) continue;
|
|
$msgId = substr(uniqid(), -8) . bin2hex(random_bytes(2));
|
|
$msgType = ($art['sender'] ?? '') === 'Customer' ? 'incoming' : 'outgoing';
|
|
$body = $art['body'] ?? '';
|
|
if (($art['content_type'] ?? '') === 'text/html') {
|
|
$body = strip_tags($body, '<br><p><div><a><b><i><strong><em><ul><ol><li>');
|
|
}
|
|
// Liitteiden metadata
|
|
$attJson = '';
|
|
if (!empty($art['attachments'])) {
|
|
$atts = [];
|
|
foreach ($art['attachments'] as $att) {
|
|
$atts[] = [
|
|
'id' => $att['id'] ?? 0,
|
|
'filename' => $att['filename'] ?? '',
|
|
'size' => $att['size'] ?? 0,
|
|
'type' => $att['preferences']['Content-Type'] ?? ($att['content_type'] ?? 'application/octet-stream'),
|
|
];
|
|
}
|
|
$attJson = json_encode($atts);
|
|
}
|
|
_dbExecute(
|
|
"INSERT INTO ticket_messages (id, ticket_id, type, from_email, from_name, body, timestamp, message_id, zammad_article_id, attachments)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
[$msgId, $ticket['id'], $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, $attJson]
|
|
);
|
|
}
|
|
// Tallenna Zammad to-osoite tikettiin
|
|
if ($toEmail) {
|
|
_dbExecute("UPDATE tickets SET zammad_to_email = ? WHERE id = ?", [$toEmail, $ticket['id']]);
|
|
}
|
|
// Lataa tiketti uudelleen viestien kanssa
|
|
$tickets = dbLoadTickets($companyId);
|
|
foreach ($tickets as $t) {
|
|
if ($t['id'] === $id) { $ticket = $t; break; }
|
|
}
|
|
}
|
|
} catch (\Throwable $e) { /* Zammad ei saatavilla — näytä tiketti ilman viestejä */ }
|
|
}
|
|
echo json_encode($ticket);
|
|
break;
|
|
|
|
case 'ticket_fetch':
|
|
requireAuth();
|
|
$companyId = requireCompany();
|
|
if ($method !== 'POST') break;
|
|
|
|
$companyConf = dbGetCompanyConfig($companyId);
|
|
$mailboxes = array_filter($companyConf['mailboxes'] ?? [], fn($mb) => !empty($mb['aktiivinen']));
|
|
|
|
if (empty($mailboxes)) {
|
|
http_response_code(400);
|
|
echo json_encode(['error' => 'Postilaatikoita ei ole määritetty. Lisää ne Yritykset-välilehdellä.']);
|
|
break;
|
|
}
|
|
|
|
$tickets = dbLoadTickets($companyId);
|
|
$newCount = 0;
|
|
$threadedCount = 0;
|
|
$errors = [];
|
|
|
|
// Collect all previously fetched message IDs (persists even after ticket deletion)
|
|
$existingMsgIds = dbGetFetchedMessageIds($companyId);
|
|
// Also include message IDs from current tickets (for backward compatibility)
|
|
foreach ($tickets as $t) {
|
|
if ($t['message_id']) $existingMsgIds[$t['message_id']] = true;
|
|
foreach ($t['messages'] ?? [] as $m) {
|
|
if (!empty($m['message_id'])) $existingMsgIds[$m['message_id']] = true;
|
|
}
|
|
}
|
|
|
|
// Hae kaikista aktiivisista postilaatikoista
|
|
foreach ($mailboxes as $mailbox) {
|
|
$imapConfig = [
|
|
'imap_host' => $mailbox['imap_host'] ?? '',
|
|
'imap_port' => $mailbox['imap_port'] ?? 993,
|
|
'imap_user' => $mailbox['imap_user'] ?? '',
|
|
'imap_password' => $mailbox['imap_password'] ?? '',
|
|
'imap_encryption' => $mailbox['imap_encryption'] ?? 'ssl',
|
|
];
|
|
|
|
$imap = new ImapClient();
|
|
if (!$imap->connect($imapConfig)) {
|
|
$errors[] = ($mailbox['nimi'] ?? 'Tuntematon') . ': ' . $imap->lastError;
|
|
continue;
|
|
}
|
|
|
|
$emails = $imap->fetchMessages(100);
|
|
$imap->disconnect();
|
|
|
|
$rules = $companyConf['ticket_rules'] ?? [];
|
|
|
|
foreach ($emails as $email) {
|
|
if (!empty($email['message_id']) && isset($existingMsgIds[$email['message_id']])) {
|
|
continue;
|
|
}
|
|
|
|
$msg = [
|
|
'id' => generateId(),
|
|
'type' => 'email_in',
|
|
'from' => $email['from_email'],
|
|
'from_name' => $email['from_name'],
|
|
'body' => $email['body'],
|
|
'timestamp' => $email['date'],
|
|
'message_id' => $email['message_id'],
|
|
];
|
|
|
|
// Threading: find existing ticket by references
|
|
$existingTicket = null;
|
|
if ($email['in_reply_to']) {
|
|
$existingTicket = dbFindTicketByMessageId($companyId, $email['in_reply_to']);
|
|
}
|
|
if (!$existingTicket && $email['references']) {
|
|
$refs = preg_split('/\s+/', $email['references']);
|
|
foreach ($refs as $ref) {
|
|
$ref = trim($ref);
|
|
if (!$ref) continue;
|
|
$existingTicket = dbFindTicketByMessageId($companyId, $ref);
|
|
if ($existingTicket) break;
|
|
}
|
|
}
|
|
|
|
if ($existingTicket) {
|
|
// Load full ticket with messages
|
|
$fullTickets = dbLoadTickets($companyId);
|
|
foreach ($fullTickets as $ft) {
|
|
if ($ft['id'] === $existingTicket['id']) {
|
|
$ft['messages'][] = $msg;
|
|
$ft['updated'] = $email['date'];
|
|
if (in_array($ft['status'], ['ratkaistu', 'suljettu'])) {
|
|
$ft['status'] = 'uusi';
|
|
}
|
|
dbSaveTicket($companyId, $ft);
|
|
$threadedCount++;
|
|
break;
|
|
}
|
|
}
|
|
} else {
|
|
// Tarkista onko lähettäjä priority-listalla
|
|
$ticketPriority = 'normaali';
|
|
if (dbIsPriorityEmail($companyId, $email['from_email'])) {
|
|
$ticketPriority = 'tärkeä';
|
|
}
|
|
|
|
// Generoi tikettinumero (VVNKKNN)
|
|
$ticketNumber = dbNextTicketNumber($companyId);
|
|
$originalSubject = $email['subject'] ?: '(Ei aihetta)';
|
|
$numberedSubject = "Tiketti #{$ticketNumber}: {$originalSubject}";
|
|
|
|
$ticket = [
|
|
'id' => generateId(),
|
|
'ticket_number' => $ticketNumber,
|
|
'subject' => $numberedSubject,
|
|
'from_email' => $email['from_email'],
|
|
'from_name' => $email['from_name'],
|
|
'status' => 'uusi',
|
|
'type' => 'muu',
|
|
'assigned_to' => '',
|
|
'customer_id' => '',
|
|
'customer_name' => '',
|
|
'tags' => [],
|
|
'cc' => $email['cc'] ?? '',
|
|
'priority' => $ticketPriority,
|
|
'auto_close_at' => '',
|
|
'mailbox_id' => $mailbox['id'],
|
|
'created' => $email['date'],
|
|
'updated' => $email['date'],
|
|
'message_id' => $email['message_id'],
|
|
'messages' => [$msg],
|
|
];
|
|
|
|
// Apply auto-rules
|
|
$toAddresses = $email['to'] ?? '';
|
|
foreach ($rules as $rule) {
|
|
if (empty($rule['enabled'])) continue;
|
|
$match = true;
|
|
if (!empty($rule['from_contains'])) {
|
|
$needle = strtolower($rule['from_contains']);
|
|
if (strpos(strtolower($email['from_email'] . ' ' . $email['from_name']), $needle) === false) {
|
|
$match = false;
|
|
}
|
|
}
|
|
if (!empty($rule['subject_contains'])) {
|
|
$needle = strtolower($rule['subject_contains']);
|
|
if (strpos(strtolower($email['subject'] ?? ''), $needle) === false) {
|
|
$match = false;
|
|
}
|
|
}
|
|
if (!empty($rule['to_contains'])) {
|
|
$needle = strtolower($rule['to_contains']);
|
|
if (strpos(strtolower($toAddresses), $needle) === false) {
|
|
$match = false;
|
|
}
|
|
}
|
|
if ($match) {
|
|
$delayDays = intval($rule['delay_days'] ?? 0);
|
|
if ($delayDays > 0) {
|
|
// Ajastettu sääntö: älä suorita heti, tallenna ajastus tikettiin
|
|
$executeAt = date('Y-m-d', strtotime("+{$delayDays} days")) . ' 10:00:00';
|
|
$ticket['delay_rule_id'] = $rule['id'];
|
|
$ticket['delay_execute_at'] = $executeAt;
|
|
} else {
|
|
// Välitön sääntö: suorita heti
|
|
if (!empty($rule['status_set'])) $ticket['status'] = $rule['status_set'];
|
|
if (!empty($rule['type_set'])) $ticket['type'] = $rule['type_set'];
|
|
if (!empty($rule['set_priority'])) $ticket['priority'] = $rule['set_priority'];
|
|
if (!empty($rule['set_tags'])) {
|
|
$ruleTags = array_map('trim', explode(',', $rule['set_tags']));
|
|
$ticket['tags'] = array_values(array_unique(array_merge($ticket['tags'], $ruleTags)));
|
|
}
|
|
}
|
|
if (!empty($rule['auto_close_days'])) {
|
|
$days = intval($rule['auto_close_days']);
|
|
if ($days > 0) {
|
|
$ticket['auto_close_at'] = date('Y-m-d H:i:s', strtotime("+{$days} days"));
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
dbSaveTicket($companyId, $ticket);
|
|
|
|
// Autoreply — lähetä automaattinen vastaus asiakkaalle
|
|
if (!empty($mailbox['auto_reply_enabled']) && !empty($mailbox['auto_reply_body'])) {
|
|
$arSubject = 'Re: ' . $ticket['subject'];
|
|
$arBody = $mailbox['auto_reply_body'];
|
|
$arSent = sendTicketMail(
|
|
$email['from_email'],
|
|
$arSubject,
|
|
$arBody,
|
|
$email['message_id'], // In-Reply-To
|
|
$email['message_id'], // References
|
|
$mailbox,
|
|
'' // ei CC
|
|
);
|
|
if ($arSent) {
|
|
// Tallenna autoreply tiketin viestiksi
|
|
$arMsg = [
|
|
'id' => generateId(),
|
|
'type' => 'auto_reply',
|
|
'from' => $mailbox['smtp_from_email'] ?? $mailbox['imap_user'] ?? '',
|
|
'from_name' => $mailbox['smtp_from_name'] ?? $mailbox['nimi'] ?? '',
|
|
'body' => $arBody,
|
|
'timestamp' => date('Y-m-d H:i:s'),
|
|
'message_id' => '',
|
|
];
|
|
$ticket['messages'][] = $arMsg;
|
|
dbSaveTicket($companyId, $ticket);
|
|
}
|
|
}
|
|
|
|
// Telegram-hälytys tärkeille/urgentille
|
|
if ($ticket['priority'] === 'urgent' || $ticket['priority'] === 'tärkeä') {
|
|
sendTelegramAlert($companyId, $ticket);
|
|
}
|
|
$newCount++;
|
|
}
|
|
|
|
// Merkitse haetuksi pysyvästi
|
|
if ($email['message_id']) {
|
|
dbMarkMessageIdFetched($companyId, $email['message_id']);
|
|
$existingMsgIds[$email['message_id']] = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Reload for total count
|
|
$allTickets = dbLoadTickets($companyId);
|
|
$result = ['success' => true, 'new_tickets' => $newCount, 'threaded' => $threadedCount, 'total' => count($allTickets)];
|
|
if (!empty($errors)) $result['errors'] = $errors;
|
|
echo json_encode($result);
|
|
break;
|
|
|
|
case 'ticket_reply':
|
|
requireAuth();
|
|
$companyId = requireCompanyOrParam();
|
|
if ($method !== 'POST') break;
|
|
$input = json_decode(file_get_contents('php://input'), true);
|
|
$id = $input['id'] ?? '';
|
|
$body = trim($input['body'] ?? '');
|
|
$replyMailboxId = $input['mailbox_id'] ?? '';
|
|
$replyTo = trim($input['to'] ?? '');
|
|
$replyCc = trim($input['cc'] ?? '');
|
|
$replyBcc = trim($input['bcc'] ?? '');
|
|
if (empty($body)) {
|
|
http_response_code(400);
|
|
echo json_encode(['error' => 'Viesti ei voi olla tyhjä']);
|
|
break;
|
|
}
|
|
$tickets = dbLoadTickets($companyId);
|
|
$found = false;
|
|
foreach ($tickets as $t) {
|
|
if ($t['id'] === $id) {
|
|
// Find last message_id for threading
|
|
$lastMsgId = $t['message_id'] ?? '';
|
|
$allRefs = $lastMsgId;
|
|
foreach ($t['messages'] as $m) {
|
|
if (!empty($m['message_id'])) {
|
|
$lastMsgId = $m['message_id'];
|
|
$allRefs .= ' ' . $m['message_id'];
|
|
}
|
|
}
|
|
|
|
// 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'] === $useMailboxId) { $replyMailbox = $mb; break; }
|
|
}
|
|
// Fallback: käytä ensimmäistä postilaatikkoa
|
|
if (!$replyMailbox && !empty($companyConf['mailboxes'])) {
|
|
$replyMailbox = $companyConf['mailboxes'][0];
|
|
}
|
|
|
|
// Hae käyttäjän allekirjoitus tälle postilaatikolle (ellei estetty)
|
|
$noSignature = !empty($input['no_signature']);
|
|
$mailboxId = $replyMailbox['id'] ?? '';
|
|
$signature = '';
|
|
if (!$noSignature) {
|
|
$sigUser = dbGetUser($_SESSION['user_id']);
|
|
if ($sigUser) {
|
|
$allSigs = buildSignaturesWithDefaults($sigUser, $sigUser['companies'] ?? []);
|
|
$signature = trim($allSigs[$mailboxId] ?? '');
|
|
}
|
|
}
|
|
$emailBody = $signature ? $body . "\n\n-- \n" . $signature : $body;
|
|
|
|
// Rakenna viestiketju (quoted thread) vastaukseen
|
|
$threadMessages = _dbFetchAll(
|
|
"SELECT from_name, from_email, timestamp, body, type FROM ticket_messages WHERE ticket_id = ? ORDER BY timestamp DESC",
|
|
[$t['id']]
|
|
);
|
|
if (!empty($threadMessages)) {
|
|
$emailBody .= "\n";
|
|
foreach ($threadMessages as $tm) {
|
|
// Älä sisällytä sisäisiä muistiinpanoja sähköpostiin
|
|
if ($tm['type'] === 'note') continue;
|
|
$tmSender = $tm['from_name'] ?: $tm['from_email'];
|
|
$tmDate = date('d.m.Y H:i', strtotime($tm['timestamp']));
|
|
$tmBody = strip_tags(str_replace(['<br>', '<br/>', '<br />', '</p>', '</div>'], "\n", $tm['body'] ?: ''));
|
|
$tmBody = html_entity_decode($tmBody, ENT_QUOTES, 'UTF-8');
|
|
$tmBody = trim(preg_replace('/\n{3,}/', "\n\n", $tmBody));
|
|
$quoted = implode("\n", array_map(fn($l) => "> " . $l, explode("\n", $tmBody)));
|
|
$emailBody .= "\n{$tmSender} — {$tmDate}:\n{$quoted}\n";
|
|
}
|
|
}
|
|
|
|
// CC/BCC: käytä frontendistä annettua arvoa, tai tiketin tallennettua
|
|
$ccToSend = $replyCc !== '' ? $replyCc : ($t['cc'] ?? '');
|
|
$bccToSend = $replyBcc;
|
|
|
|
$subject = 'Re: ' . $t['subject'];
|
|
$toAddress = $replyTo !== '' ? $replyTo : $t['from_email'];
|
|
$replyAttachments = $input['attachments'] ?? [];
|
|
$sent = sendTicketMail($toAddress, $subject, $emailBody, $lastMsgId, trim($allRefs), $replyMailbox, $ccToSend, $bccToSend, $replyAttachments);
|
|
|
|
if (!$sent) {
|
|
http_response_code(500);
|
|
$smtpErr = $GLOBALS['smtp_last_error'] ?? '';
|
|
$detail = $smtpErr ? " ({$smtpErr})" : '';
|
|
$mbDebug = $replyMailbox ? " [smtp_host=" . ($replyMailbox['smtp_host'] ?? 'EMPTY')
|
|
. " smtp_port=" . ($replyMailbox['smtp_port'] ?? '?')
|
|
. " smtp_user=" . ($replyMailbox['smtp_user'] ?? 'EMPTY')
|
|
. " smtp_pass_len=" . strlen($replyMailbox['smtp_password'] ?? '')
|
|
. " smtp_enc=" . ($replyMailbox['smtp_encryption'] ?? '?')
|
|
. " imap_host=" . ($replyMailbox['imap_host'] ?? '')
|
|
. " imap_user=" . ($replyMailbox['imap_user'] ?? '')
|
|
. " imap_pass_len=" . strlen($replyMailbox['imap_password'] ?? '') . "]" : ' [no mailbox]';
|
|
echo json_encode(['error' => "Sähköpostin lähetys epäonnistui{$detail}{$mbDebug}"]);
|
|
break 2;
|
|
}
|
|
|
|
// Tallenna To/CC/BCC tiketille pysyvästi
|
|
_dbExecute(
|
|
"UPDATE tickets SET to_email = ?, cc = ?, bcc = ? WHERE id = ? AND company_id = ?",
|
|
[$toAddress, $ccToSend, $bccToSend, $id, $companyId]
|
|
);
|
|
|
|
// Add reply to ticket (tallennetaan allekirjoituksen kanssa)
|
|
$reply = [
|
|
'id' => generateId(),
|
|
'type' => 'reply_out',
|
|
'from' => currentUser(),
|
|
'from_name' => $_SESSION['nimi'] ?? currentUser(),
|
|
'body' => $emailBody,
|
|
'timestamp' => date('Y-m-d H:i:s'),
|
|
'message_id' => '',
|
|
];
|
|
$t['messages'][] = $reply;
|
|
$t['updated'] = date('Y-m-d H:i:s');
|
|
if ($t['status'] === 'uusi') $t['status'] = 'kasittelyssa';
|
|
|
|
dbSaveTicket($companyId, $t);
|
|
$found = true;
|
|
dbAddLog($companyId, currentUser(), 'ticket_reply', $t['id'], $t['subject'], 'Vastasi tikettiin');
|
|
echo json_encode($t);
|
|
break;
|
|
}
|
|
}
|
|
if (!$found) {
|
|
http_response_code(404);
|
|
echo json_encode(['error' => 'Tikettiä ei löydy']);
|
|
}
|
|
break;
|
|
|
|
case 'ticket_status':
|
|
requireAuth();
|
|
$companyId = requireCompanyOrParam();
|
|
if ($method !== 'POST') break;
|
|
$input = json_decode(file_get_contents('php://input'), true);
|
|
$id = $input['id'] ?? '';
|
|
$status = $input['status'] ?? '';
|
|
$validStatuses = ['uusi', 'kasittelyssa', 'odottaa', 'suljettu'];
|
|
if (!in_array($status, $validStatuses)) {
|
|
http_response_code(400);
|
|
echo json_encode(['error' => 'Virheellinen tila']);
|
|
break;
|
|
}
|
|
$tickets = dbLoadTickets($companyId);
|
|
$found = false;
|
|
foreach ($tickets as $t) {
|
|
if ($t['id'] === $id) {
|
|
$oldStatus = $t['status'];
|
|
$t['status'] = $status;
|
|
$t['updated'] = date('Y-m-d H:i:s');
|
|
dbSaveTicket($companyId, $t);
|
|
$found = true;
|
|
dbAddLog($companyId, currentUser(), 'ticket_status', $t['id'], $t['subject'], "Tila: {$oldStatus} → {$status}");
|
|
// Synkkaa tila Zammadiin
|
|
if (!empty($t['zammad_ticket_id'])) {
|
|
$integ = dbGetIntegration($companyId, 'zammad');
|
|
if ($integ && $integ['enabled']) {
|
|
try {
|
|
$reverseStateMap = [
|
|
'uusi' => 'new', 'kasittelyssa' => 'open',
|
|
'odottaa' => 'pending reminder', 'suljettu' => 'closed',
|
|
];
|
|
$zState = $reverseStateMap[$status] ?? null;
|
|
if ($zState) {
|
|
$z = new ZammadClient($integ['config']['url'], $integ['config']['token']);
|
|
$z->updateTicket((int)$t['zammad_ticket_id'], ['state' => $zState]);
|
|
}
|
|
} catch (\Throwable $e) {
|
|
error_log("Zammad status sync failed: " . $e->getMessage());
|
|
}
|
|
}
|
|
}
|
|
echo json_encode($t);
|
|
break;
|
|
}
|
|
}
|
|
if (!$found) {
|
|
http_response_code(404);
|
|
echo json_encode(['error' => 'Tikettiä ei löydy']);
|
|
}
|
|
break;
|
|
|
|
case 'ticket_type':
|
|
requireAuth();
|
|
$companyId = requireCompanyOrParam();
|
|
if ($method !== 'POST') break;
|
|
$input = json_decode(file_get_contents('php://input'), true);
|
|
$id = $input['id'] ?? '';
|
|
$type = $input['type'] ?? '';
|
|
$validTypes = array_map(fn($t) => $t['value'], dbLoadTicketTypes($companyId));
|
|
if (!in_array($type, $validTypes)) {
|
|
http_response_code(400);
|
|
echo json_encode(['error' => 'Virheellinen tyyppi']);
|
|
break;
|
|
}
|
|
$tickets = dbLoadTickets($companyId);
|
|
$found = false;
|
|
foreach ($tickets as $t) {
|
|
if ($t['id'] === $id) {
|
|
$oldType = $t['type'] ?? 'muu';
|
|
$t['type'] = $type;
|
|
$t['updated'] = date('Y-m-d H:i:s');
|
|
dbSaveTicket($companyId, $t);
|
|
$found = true;
|
|
dbAddLog($companyId, currentUser(), 'ticket_type', $t['id'], $t['subject'], "Tyyppi: {$oldType} → {$type}");
|
|
echo json_encode($t);
|
|
break;
|
|
}
|
|
}
|
|
if (!$found) {
|
|
http_response_code(404);
|
|
echo json_encode(['error' => 'Tikettiä ei löydy']);
|
|
}
|
|
break;
|
|
|
|
case 'ticket_customer':
|
|
requireAuth();
|
|
$companyId = requireCompanyOrParam();
|
|
if ($method !== 'POST') break;
|
|
$input = json_decode(file_get_contents('php://input'), true);
|
|
$id = $input['id'] ?? '';
|
|
$customerId = $input['customer_id'] ?? '';
|
|
$customerName = $input['customer_name'] ?? '';
|
|
$tickets = dbLoadTickets($companyId);
|
|
$found = false;
|
|
foreach ($tickets as $t) {
|
|
if ($t['id'] === $id) {
|
|
$t['customer_id'] = $customerId;
|
|
$t['customer_name'] = $customerName;
|
|
$t['updated'] = date('Y-m-d H:i:s');
|
|
dbSaveTicket($companyId, $t);
|
|
$found = true;
|
|
dbAddLog($companyId, currentUser(), 'ticket_customer', $t['id'], $t['subject'], "Asiakkuus: {$customerName}");
|
|
echo json_encode($t);
|
|
break;
|
|
}
|
|
}
|
|
if (!$found) {
|
|
http_response_code(404);
|
|
echo json_encode(['error' => 'Tikettiä ei löydy']);
|
|
}
|
|
break;
|
|
|
|
case 'ticket_assign':
|
|
requireAuth();
|
|
$companyId = requireCompanyOrParam();
|
|
if ($method !== 'POST') break;
|
|
$input = json_decode(file_get_contents('php://input'), true);
|
|
$id = $input['id'] ?? '';
|
|
$assignTo = trim($input['assigned_to'] ?? '');
|
|
$tickets = dbLoadTickets($companyId);
|
|
$found = false;
|
|
foreach ($tickets as $t) {
|
|
if ($t['id'] === $id) {
|
|
$t['assigned_to'] = $assignTo;
|
|
$t['updated'] = date('Y-m-d H:i:s');
|
|
dbSaveTicket($companyId, $t);
|
|
$found = true;
|
|
dbAddLog($companyId, currentUser(), 'ticket_assign', $t['id'], $t['subject'], "Osoitettu: {$assignTo}");
|
|
echo json_encode($t);
|
|
break;
|
|
}
|
|
}
|
|
if (!$found) {
|
|
http_response_code(404);
|
|
echo json_encode(['error' => 'Tikettiä ei löydy']);
|
|
}
|
|
break;
|
|
|
|
case 'ticket_note':
|
|
requireAuth();
|
|
$companyId = requireCompanyOrParam();
|
|
if ($method !== 'POST') break;
|
|
$input = json_decode(file_get_contents('php://input'), true);
|
|
$id = $input['id'] ?? '';
|
|
$body = trim($input['body'] ?? '');
|
|
if (empty($body)) {
|
|
http_response_code(400);
|
|
echo json_encode(['error' => 'Muistiinpano ei voi olla tyhjä']);
|
|
break;
|
|
}
|
|
$tickets = dbLoadTickets($companyId);
|
|
$found = false;
|
|
foreach ($tickets as $t) {
|
|
if ($t['id'] === $id) {
|
|
$note = [
|
|
'id' => generateId(),
|
|
'type' => 'note',
|
|
'from' => currentUser(),
|
|
'from_name' => $_SESSION['nimi'] ?? currentUser(),
|
|
'body' => $body,
|
|
'timestamp' => date('Y-m-d H:i:s'),
|
|
'message_id' => '',
|
|
];
|
|
$t['messages'][] = $note;
|
|
$t['updated'] = date('Y-m-d H:i:s');
|
|
dbSaveTicket($companyId, $t);
|
|
$found = true;
|
|
dbAddLog($companyId, currentUser(), 'ticket_note', $t['id'], $t['subject'], 'Lisäsi muistiinpanon');
|
|
echo json_encode($t);
|
|
break;
|
|
}
|
|
}
|
|
if (!$found) {
|
|
http_response_code(404);
|
|
echo json_encode(['error' => 'Tikettiä ei löydy']);
|
|
}
|
|
break;
|
|
|
|
case 'ticket_delete':
|
|
requireAuth();
|
|
$companyId = requireCompanyOrParam();
|
|
if ($method !== 'POST') break;
|
|
$input = json_decode(file_get_contents('php://input'), true);
|
|
$id = $input['id'] ?? '';
|
|
$tickets = dbLoadTickets($companyId);
|
|
$deleted = null;
|
|
foreach ($tickets as $t) {
|
|
if ($t['id'] === $id) { $deleted = $t; break; }
|
|
}
|
|
dbDeleteTicket($id, $companyId);
|
|
if ($deleted) dbAddLog($companyId, currentUser(), 'ticket_delete', $id, $deleted['subject'] ?? '', 'Poisti tiketin');
|
|
echo json_encode(['success' => true]);
|
|
break;
|
|
|
|
case 'ticket_tags':
|
|
requireAuth();
|
|
$companyId = requireCompanyOrParam();
|
|
if ($method !== 'POST') break;
|
|
$input = json_decode(file_get_contents('php://input'), true);
|
|
$id = $input['id'] ?? '';
|
|
$tags = $input['tags'] ?? [];
|
|
// Sanitize tags: trim, lowercase, remove empty
|
|
$tags = array_values(array_filter(array_map(function($t) {
|
|
return trim(strtolower($t));
|
|
}, $tags)));
|
|
$tickets = dbLoadTickets($companyId);
|
|
$found = false;
|
|
foreach ($tickets as $t) {
|
|
if ($t['id'] === $id) {
|
|
$t['tags'] = $tags;
|
|
$t['updated'] = date('Y-m-d H:i:s');
|
|
dbSaveTicket($companyId, $t);
|
|
$found = true;
|
|
dbAddLog($companyId, currentUser(), 'ticket_tags', $t['id'], $t['subject'], 'Tagit: ' . implode(', ', $tags));
|
|
echo json_encode($t);
|
|
break;
|
|
}
|
|
}
|
|
if (!$found) {
|
|
http_response_code(404);
|
|
echo json_encode(['error' => 'Tikettiä ei löydy']);
|
|
}
|
|
break;
|
|
|
|
case 'ticket_rules':
|
|
requireAuth();
|
|
$companyId = requireCompany();
|
|
echo json_encode(dbLoadTicketRules($companyId));
|
|
break;
|
|
|
|
case 'ticket_rule_save':
|
|
requireAuth();
|
|
$companyId = requireCompany();
|
|
if ($method !== 'POST') break;
|
|
$input = json_decode(file_get_contents('php://input'), true);
|
|
|
|
$rule = [
|
|
'id' => $input['id'] ?? generateId(),
|
|
'name' => trim($input['name'] ?? ''),
|
|
'from_contains' => trim($input['from_contains'] ?? ''),
|
|
'subject_contains' => trim($input['subject_contains'] ?? ''),
|
|
'to_contains' => trim($input['to_contains'] ?? ''),
|
|
'set_status' => $input['set_status'] ?? '',
|
|
'set_type' => $input['set_type'] ?? '',
|
|
'set_priority' => $input['set_priority'] ?? '',
|
|
'set_tags' => trim($input['set_tags'] ?? ''),
|
|
'auto_close_days' => intval($input['auto_close_days'] ?? 0),
|
|
'delay_days' => intval($input['delay_days'] ?? 0),
|
|
'enabled' => $input['enabled'] ?? true,
|
|
];
|
|
|
|
if (empty($rule['name'])) {
|
|
http_response_code(400);
|
|
echo json_encode(['error' => 'Säännön nimi puuttuu']);
|
|
break;
|
|
}
|
|
|
|
dbSaveTicketRule($companyId, $rule);
|
|
dbAddLog($companyId, currentUser(), 'config_update', '', '', 'Tikettisääntö: ' . $rule['name']);
|
|
echo json_encode($rule);
|
|
break;
|
|
|
|
case 'ticket_bulk_status':
|
|
requireAuth();
|
|
$companyId = requireCompany();
|
|
if ($method !== 'POST') break;
|
|
$input = json_decode(file_get_contents('php://input'), true);
|
|
$ids = $input['ids'] ?? [];
|
|
$newStatus = $input['status'] ?? '';
|
|
$validStatuses = ['uusi','kasittelyssa','odottaa','suljettu'];
|
|
if (!in_array($newStatus, $validStatuses)) {
|
|
http_response_code(400);
|
|
echo json_encode(['error' => 'Virheellinen tila']);
|
|
break;
|
|
}
|
|
$tickets = dbLoadTickets($companyId);
|
|
$changed = 0;
|
|
foreach ($tickets as $t) {
|
|
if (in_array($t['id'], $ids)) {
|
|
$t['status'] = $newStatus;
|
|
$t['updated'] = date('Y-m-d H:i:s');
|
|
dbSaveTicket($companyId, $t);
|
|
$changed++;
|
|
}
|
|
}
|
|
dbAddLog($companyId, currentUser(), 'ticket_status', '', '', "Massapäivitys: $changed tikettiä → $newStatus");
|
|
echo json_encode(['success' => true, 'changed' => $changed]);
|
|
break;
|
|
|
|
case 'ticket_bulk_delete':
|
|
requireAuth();
|
|
$companyId = requireCompany();
|
|
if ($method !== 'POST') break;
|
|
$input = json_decode(file_get_contents('php://input'), true);
|
|
$ids = $input['ids'] ?? [];
|
|
$deleted = 0;
|
|
foreach ($ids as $ticketId) {
|
|
dbDeleteTicket($ticketId, $companyId);
|
|
$deleted++;
|
|
}
|
|
dbAddLog($companyId, currentUser(), 'ticket_delete', '', '', "Massapoisto: $deleted tikettiä");
|
|
echo json_encode(['success' => true, 'deleted' => $deleted]);
|
|
break;
|
|
|
|
case 'ticket_rule_delete':
|
|
requireAuth();
|
|
$companyId = requireCompany();
|
|
if ($method !== 'POST') break;
|
|
$input = json_decode(file_get_contents('php://input'), true);
|
|
$ruleId = $input['id'] ?? '';
|
|
dbDeleteTicketRule($ruleId, $companyId);
|
|
echo json_encode(['success' => true]);
|
|
break;
|
|
|
|
case 'ticket_types':
|
|
requireAuth();
|
|
$companyId = requireCompany();
|
|
echo json_encode(dbLoadTicketTypes($companyId));
|
|
break;
|
|
|
|
case 'ticket_type_save':
|
|
requireAuth();
|
|
$companyId = requireCompany();
|
|
if ($method !== 'POST') break;
|
|
$input = json_decode(file_get_contents('php://input'), true);
|
|
$value = preg_replace('/[^a-z0-9_-]/', '', strtolower(trim($input['value'] ?? '')));
|
|
$label = trim($input['label'] ?? '');
|
|
if (!$value || !$label) {
|
|
http_response_code(400);
|
|
echo json_encode(['error' => 'Tunnus ja nimi vaaditaan']);
|
|
break;
|
|
}
|
|
dbSaveTicketType($companyId, [
|
|
'id' => $input['id'] ?? null,
|
|
'value' => $value,
|
|
'label' => $label,
|
|
'color' => $input['color'] ?? '',
|
|
'sort_order' => intval($input['sort_order'] ?? 0),
|
|
]);
|
|
dbAddLog($companyId, currentUser(), 'config_update', '', '', 'Tikettityyppi: ' . $label);
|
|
echo json_encode(dbLoadTicketTypes($companyId));
|
|
break;
|
|
|
|
case 'ticket_type_delete':
|
|
requireAuth();
|
|
$companyId = requireCompany();
|
|
if ($method !== 'POST') break;
|
|
$input = json_decode(file_get_contents('php://input'), true);
|
|
$value = $input['value'] ?? '';
|
|
if (!$value) {
|
|
http_response_code(400);
|
|
echo json_encode(['error' => 'Tyyppi puuttuu']);
|
|
break;
|
|
}
|
|
// Tarkista onko käytössä
|
|
$tickets = dbLoadTickets($companyId);
|
|
$inUse = 0;
|
|
foreach ($tickets as $t) {
|
|
if (($t['type'] ?? '') === $value) $inUse++;
|
|
}
|
|
if ($inUse > 0) {
|
|
http_response_code(400);
|
|
echo json_encode(['error' => "Tyyppiä käytetään {$inUse} tiketissä, ei voi poistaa"]);
|
|
break;
|
|
}
|
|
dbDeleteTicketType($companyId, $value);
|
|
echo json_encode(dbLoadTicketTypes($companyId));
|
|
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'] ?? '', $companyId);
|
|
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();
|
|
$userCompanyIds = $_SESSION['companies'] ?? [];
|
|
$allCompanies = dbLoadCompanies();
|
|
$result = array_values(array_filter($allCompanies, fn($c) => in_array($c['id'], $userCompanyIds)));
|
|
echo json_encode($result);
|
|
break;
|
|
|
|
case 'companies_all':
|
|
requireAdmin();
|
|
$all = dbLoadCompanies();
|
|
if (isSuperAdmin()) {
|
|
echo json_encode($all);
|
|
} else {
|
|
// Yritysadmin näkee vain omat yrityksensä
|
|
$userCompanyIds = $_SESSION['companies'] ?? [];
|
|
$filtered = array_values(array_filter($all, fn($c) => in_array($c['id'], $userCompanyIds)));
|
|
echo json_encode($filtered);
|
|
}
|
|
break;
|
|
|
|
case 'all_mailboxes':
|
|
requireAuth();
|
|
// Palauttaa kaikki postilaatikot käyttäjän yrityksistä (allekirjoituksia varten)
|
|
$userCompanyIds = $_SESSION['companies'] ?? [];
|
|
$allCompanies = dbLoadCompanies();
|
|
$result = [];
|
|
foreach ($allCompanies as $comp) {
|
|
if (!in_array($comp['id'], $userCompanyIds)) continue;
|
|
$mailboxes = dbLoadMailboxes($comp['id']);
|
|
foreach ($mailboxes as $mb) {
|
|
$result[] = [
|
|
'id' => $mb['id'],
|
|
'nimi' => $mb['nimi'] ?? $mb['imap_user'] ?? '',
|
|
'smtp_from_email' => $mb['smtp_from_email'] ?? $mb['imap_user'] ?? '',
|
|
'company_id' => $comp['id'],
|
|
'company_nimi' => $comp['nimi'],
|
|
'company_phone' => $comp['phone'] ?? '',
|
|
];
|
|
}
|
|
}
|
|
echo json_encode($result);
|
|
break;
|
|
|
|
case 'ticket_zammad_groups':
|
|
requireAuth();
|
|
$userCompanyIds = $_SESSION['companies'] ?? [];
|
|
$groups = [];
|
|
foreach ($userCompanyIds as $cid) {
|
|
$rows = _dbFetchAll("SELECT DISTINCT zammad_group FROM tickets WHERE company_id = ? AND source = 'zammad' AND zammad_group IS NOT NULL AND zammad_group != ''", [$cid]);
|
|
foreach ($rows as $r) {
|
|
if (!in_array($r['zammad_group'], $groups)) $groups[] = $r['zammad_group'];
|
|
}
|
|
}
|
|
sort($groups);
|
|
echo json_encode($groups);
|
|
break;
|
|
|
|
case 'ticket_zammad_emails':
|
|
requireAuth();
|
|
$userCompanyIds = $_SESSION['companies'] ?? [];
|
|
$emails = [];
|
|
foreach ($userCompanyIds as $cid) {
|
|
$rows = _dbFetchAll("SELECT DISTINCT zammad_to_email FROM tickets WHERE company_id = ? AND source = 'zammad' AND zammad_to_email IS NOT NULL AND zammad_to_email != ''", [$cid]);
|
|
foreach ($rows as $r) {
|
|
if (!in_array($r['zammad_to_email'], $emails)) $emails[] = $r['zammad_to_email'];
|
|
}
|
|
}
|
|
sort($emails);
|
|
echo json_encode($emails);
|
|
break;
|
|
|
|
case 'company_create':
|
|
requireSuperAdmin();
|
|
if ($method !== 'POST') break;
|
|
$input = json_decode(file_get_contents('php://input'), true);
|
|
$id = preg_replace('/[^a-z0-9-]/', '', strtolower(trim($input['id'] ?? '')));
|
|
$nimi = trim($input['nimi'] ?? '');
|
|
if (empty($id) || empty($nimi)) {
|
|
http_response_code(400);
|
|
echo json_encode(['error' => 'ID ja nimi vaaditaan']);
|
|
break;
|
|
}
|
|
$companies = dbLoadCompanies();
|
|
foreach ($companies as $c) {
|
|
if ($c['id'] === $id) {
|
|
http_response_code(400);
|
|
echo json_encode(['error' => 'Yritys-ID on jo käytössä']);
|
|
break 2;
|
|
}
|
|
}
|
|
// Brändäyskentät
|
|
$domains = [];
|
|
if (isset($input['domains']) && is_array($input['domains'])) {
|
|
$domains = array_values(array_filter(array_map('trim', $input['domains'])));
|
|
}
|
|
$company = [
|
|
'id' => $id,
|
|
'nimi' => $nimi,
|
|
'domains' => $domains,
|
|
'primary_color' => trim($input['primary_color'] ?? '#0f3460'),
|
|
'subtitle' => trim($input['subtitle'] ?? ''),
|
|
'logo_file' => '',
|
|
'luotu' => date('Y-m-d H:i:s'),
|
|
'aktiivinen' => true,
|
|
];
|
|
dbSaveCompany($company);
|
|
// Luo hakemisto (tiedostoja varten)
|
|
$compDir = DATA_DIR . '/companies/' . $id;
|
|
if (!file_exists($compDir)) mkdir($compDir, 0755, true);
|
|
// Lisää luoja yrityksen käyttäjäksi
|
|
$u = dbGetUser($_SESSION['user_id']);
|
|
if ($u) {
|
|
$u['companies'] = array_unique(array_merge($u['companies'] ?? [], [$id]));
|
|
dbSaveUser($u);
|
|
$_SESSION['companies'] = $u['companies'];
|
|
}
|
|
echo json_encode($company);
|
|
break;
|
|
|
|
case 'company_update':
|
|
requireAdmin();
|
|
if ($method !== 'POST') break;
|
|
$input = json_decode(file_get_contents('php://input'), true);
|
|
$id = $input['id'] ?? '';
|
|
// Yritysadmin saa muokata vain omia yrityksiään
|
|
if (!isSuperAdmin()) {
|
|
$userCompanyIds = $_SESSION['companies'] ?? [];
|
|
if (!in_array($id, $userCompanyIds)) {
|
|
http_response_code(403);
|
|
echo json_encode(['error' => 'Ei oikeuksia muokata tätä yritystä']);
|
|
break;
|
|
}
|
|
}
|
|
$companies = dbLoadCompanies();
|
|
$found = false;
|
|
foreach ($companies as $c) {
|
|
if ($c['id'] === $id) {
|
|
if (isset($input['nimi'])) $c['nimi'] = trim($input['nimi']);
|
|
if (isset($input['aktiivinen'])) $c['aktiivinen'] = (bool)$input['aktiivinen'];
|
|
if (isset($input['domains']) && is_array($input['domains'])) {
|
|
$c['domains'] = array_values(array_filter(array_map('trim', $input['domains'])));
|
|
}
|
|
if (isset($input['primary_color'])) $c['primary_color'] = trim($input['primary_color']);
|
|
if (isset($input['subtitle'])) $c['subtitle'] = trim($input['subtitle']);
|
|
if (isset($input['phone'])) $c['phone'] = trim($input['phone']);
|
|
if (isset($input['enabled_modules']) && is_array($input['enabled_modules'])) {
|
|
$c['enabled_modules'] = array_values($input['enabled_modules']);
|
|
}
|
|
// Vain superadmin saa muuttaa IP-rajoituksia
|
|
if (isset($input['allowed_ips']) && isSuperAdmin()) {
|
|
$c['allowed_ips'] = trim($input['allowed_ips']);
|
|
}
|
|
dbSaveCompany($c);
|
|
$found = true;
|
|
echo json_encode($c);
|
|
break;
|
|
}
|
|
}
|
|
if (!$found) {
|
|
http_response_code(404);
|
|
echo json_encode(['error' => 'Yritystä ei löydy']);
|
|
}
|
|
break;
|
|
|
|
case 'company_delete':
|
|
requireSuperAdmin();
|
|
if ($method !== 'POST') break;
|
|
$input = json_decode(file_get_contents('php://input'), true);
|
|
$id = $input['id'] ?? '';
|
|
if (empty($id) || !preg_match('/^[a-z0-9-]+$/', $id)) {
|
|
http_response_code(400);
|
|
echo json_encode(['error' => 'Virheellinen yritys-ID']);
|
|
break;
|
|
}
|
|
dbDeleteCompany($id);
|
|
// Poista yritys käyttäjiltä (CASCADE hoitaa tietokannan puolella, mutta päivitetään sessio)
|
|
$users = dbLoadUsers();
|
|
foreach ($users as $u) {
|
|
$u['companies'] = array_values(array_filter($u['companies'] ?? [], fn($c) => $c !== $id));
|
|
dbSaveUser($u);
|
|
}
|
|
echo json_encode(['success' => true]);
|
|
break;
|
|
|
|
case 'provision_ssl':
|
|
requireSuperAdmin();
|
|
if ($method !== 'POST') break;
|
|
$input = json_decode(file_get_contents('php://input'), true);
|
|
$domains = $input['domains'] ?? [];
|
|
if (empty($domains)) {
|
|
http_response_code(400);
|
|
echo json_encode(['error' => 'Domainit puuttuvat']);
|
|
break;
|
|
}
|
|
// Validoi domainit
|
|
foreach ($domains as $d) {
|
|
if (!preg_match('/^[a-z0-9.-]+\.[a-z]{2,}$/', $d)) {
|
|
http_response_code(400);
|
|
echo json_encode(['error' => "Virheellinen domain: $d"]);
|
|
break 2;
|
|
}
|
|
}
|
|
// Suorita provisiointi shell-skriptinä
|
|
$escapedDomains = array_map('escapeshellarg', $domains);
|
|
$domainList = implode(' ', $escapedDomains);
|
|
$output = [];
|
|
$exitCode = 0;
|
|
exec("sudo /usr/local/bin/provision-ssl.sh $domainList 2>&1", $output, $exitCode);
|
|
if ($exitCode !== 0) {
|
|
http_response_code(500);
|
|
echo json_encode(['error' => 'SSL-provisiointi epäonnistui: ' . implode("\n", $output)]);
|
|
} else {
|
|
echo json_encode(['success' => true, 'message' => 'SSL-sertifikaatti päivitetty: ' . implode(', ', $domains)]);
|
|
}
|
|
break;
|
|
|
|
case 'company_switch':
|
|
requireAuth();
|
|
if ($method !== 'POST') break;
|
|
$input = json_decode(file_get_contents('php://input'), true);
|
|
$companyId = $input['company_id'] ?? '';
|
|
$userCompanies = $_SESSION['companies'] ?? [];
|
|
$isSuperadmin = ($_SESSION['role'] ?? '') === 'superadmin';
|
|
if (!$isSuperadmin && !in_array($companyId, $userCompanies)) {
|
|
http_response_code(403);
|
|
echo json_encode(['error' => 'Ei oikeutta tähän yritykseen']);
|
|
break;
|
|
}
|
|
// IP-rajoitus yritystä vaihdettaessa (superadmin ohittaa)
|
|
if (($_SESSION['role'] ?? '') !== 'superadmin') {
|
|
$companies = dbLoadCompanies();
|
|
foreach ($companies as $comp) {
|
|
if ($comp['id'] === $companyId) {
|
|
if (!isIpAllowed(getClientIp(), $comp['allowed_ips'] ?? '')) {
|
|
http_response_code(403);
|
|
echo json_encode(['error' => 'IP-osoitteesi ei ole sallittu tälle yritykselle.']);
|
|
break 2;
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
$_SESSION['company_id'] = $companyId;
|
|
// Päivitä aktiivisen yrityksen rooli
|
|
$_SESSION['company_role'] = ($_SESSION['company_roles'] ?? [])[$companyId] ?? 'user';
|
|
echo json_encode([
|
|
'success' => true,
|
|
'company_id' => $companyId,
|
|
'company_role' => $_SESSION['company_role'],
|
|
]);
|
|
break;
|
|
|
|
case 'company_config':
|
|
requireAdmin();
|
|
$companyId = requireCompany();
|
|
echo json_encode(dbGetCompanyConfig($companyId));
|
|
break;
|
|
|
|
case 'company_config_update':
|
|
requireAdmin();
|
|
$companyId = requireCompany();
|
|
if ($method !== 'POST') break;
|
|
$input = json_decode(file_get_contents('php://input'), true);
|
|
if (isset($input['mailboxes'])) {
|
|
// Delete all existing mailboxes and re-save
|
|
$existingMailboxes = dbLoadMailboxes($companyId);
|
|
foreach ($existingMailboxes as $existing) {
|
|
dbDeleteMailbox($existing['id'], $companyId);
|
|
}
|
|
foreach ($input['mailboxes'] as $mb) {
|
|
if (empty($mb['id'])) $mb['id'] = generateId();
|
|
dbSaveMailbox($companyId, $mb);
|
|
}
|
|
}
|
|
if (isset($input['ticket_rules'])) {
|
|
// Delete all existing rules and re-save
|
|
$existingRules = dbLoadTicketRules($companyId);
|
|
foreach ($existingRules as $existing) {
|
|
dbDeleteTicketRule($existing['id'], $companyId);
|
|
}
|
|
foreach ($input['ticket_rules'] as $rule) {
|
|
if (empty($rule['id'])) $rule['id'] = generateId();
|
|
dbSaveTicketRule($companyId, $rule);
|
|
}
|
|
}
|
|
echo json_encode(dbGetCompanyConfig($companyId));
|
|
break;
|
|
|
|
// ---------- MAILBOXES ----------
|
|
case 'mailboxes':
|
|
requireAuth();
|
|
$companyId = requireCompany();
|
|
$mailboxes = dbLoadMailboxes($companyId);
|
|
// Palauta postilaatikot ilman salasanoja
|
|
$mbs = array_map(function($mb) {
|
|
$mb['imap_password'] = !empty($mb['imap_password']) ? '********' : '';
|
|
$mb['smtp_password'] = !empty($mb['smtp_password']) ? '********' : '';
|
|
return $mb;
|
|
}, $mailboxes);
|
|
echo json_encode($mbs);
|
|
break;
|
|
|
|
case 'smtp_test':
|
|
requireAdmin();
|
|
$companyId = requireCompany();
|
|
$input = json_decode(file_get_contents('php://input'), true);
|
|
$mailboxId = $input['mailbox_id'] ?? '';
|
|
$mailbox = dbGetMailbox($mailboxId, $companyId);
|
|
if (!$mailbox) {
|
|
echo json_encode(['error' => 'Postilaatikkoa ei löydy']);
|
|
break;
|
|
}
|
|
// Näytä tietokannan raakatiedot
|
|
$dbInfo = [
|
|
'imap_user' => $mailbox['imap_user'] ?? '',
|
|
'imap_pass_len' => strlen($mailbox['imap_password'] ?? ''),
|
|
'smtp_host' => $mailbox['smtp_host'] ?? '',
|
|
'smtp_port' => $mailbox['smtp_port'] ?? '',
|
|
'smtp_user' => $mailbox['smtp_user'] ?? '',
|
|
'smtp_pass_len' => strlen($mailbox['smtp_password'] ?? ''),
|
|
'smtp_encryption' => $mailbox['smtp_encryption'] ?? '',
|
|
'smtp_from_email' => $mailbox['smtp_from_email'] ?? '',
|
|
];
|
|
// Laske fallback-arvot
|
|
$effectiveUser = $mailbox['smtp_user'] ?? '';
|
|
if ($effectiveUser === '') $effectiveUser = $mailbox['imap_user'] ?? '';
|
|
if ($effectiveUser === '') $effectiveUser = $mailbox['smtp_from_email'] ?? '';
|
|
$effectivePass = $mailbox['smtp_password'] ?? '';
|
|
if ($effectivePass === '') $effectivePass = $mailbox['imap_password'] ?? '';
|
|
$host = $mailbox['smtp_host'] ?? '';
|
|
$port = (int)($mailbox['smtp_port'] ?? 587);
|
|
$encryption = $mailbox['smtp_encryption'] ?? 'tls';
|
|
// Salasana-vihje: näytä ensimmäiset 3 ja viimeiset 2 merkkiä
|
|
$passHint = strlen($effectivePass) > 5
|
|
? substr($effectivePass, 0, 3) . '***' . substr($effectivePass, -2)
|
|
: (strlen($effectivePass) > 0 ? '***(' . strlen($effectivePass) . ' merkkiä)' : '(tyhjä)');
|
|
|
|
$result = [
|
|
'db_values' => $dbInfo,
|
|
'effective_user' => $effectiveUser,
|
|
'effective_pass_hint' => $passHint,
|
|
'effective_pass_len' => strlen($effectivePass),
|
|
'steps' => [],
|
|
];
|
|
|
|
if (empty($host)) {
|
|
$result['steps'][] = '❌ SMTP-palvelin puuttuu';
|
|
echo json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
|
break;
|
|
}
|
|
if (empty($effectivePass)) {
|
|
$result['steps'][] = '❌ Salasana puuttuu (tietokannassa ei SMTP- eikä IMAP-salasanaa)';
|
|
echo json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
|
break;
|
|
}
|
|
|
|
// Testaa yhteys
|
|
$connStr = ($encryption === 'ssl' ? 'ssl' : 'tcp') . "://{$host}:{$port}";
|
|
$result['steps'][] = "🔌 Yhdistetään: {$connStr}";
|
|
$ctx = stream_context_create(['ssl' => ['verify_peer' => false, 'verify_peer_name' => false, 'allow_self_signed' => true]]);
|
|
$fp = @stream_socket_client($connStr, $errno, $errstr, 15, STREAM_CLIENT_CONNECT, $ctx);
|
|
if (!$fp) {
|
|
$result['steps'][] = "❌ Yhteys epäonnistui: {$errstr} ({$errno})";
|
|
echo json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
|
break;
|
|
}
|
|
stream_set_timeout($fp, 15);
|
|
$result['steps'][] = '✅ Yhteys muodostettu';
|
|
|
|
// Banner
|
|
$resp = smtpReadResponse($fp);
|
|
$result['steps'][] = '📨 Banner: ' . trim($resp);
|
|
if (smtpCode($resp) !== '220') {
|
|
$result['steps'][] = '❌ Virheellinen banner';
|
|
fclose($fp);
|
|
echo json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
|
break;
|
|
}
|
|
|
|
// EHLO
|
|
$ehlo = smtpCommand($fp, "EHLO " . gethostname());
|
|
$result['steps'][] = '📨 EHLO: ' . smtpCode($ehlo);
|
|
$result['ehlo_capabilities'] = trim($ehlo);
|
|
if (smtpCode($ehlo) !== '250') {
|
|
$result['steps'][] = '❌ EHLO hylätty';
|
|
fclose($fp);
|
|
echo json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
|
break;
|
|
}
|
|
|
|
// STARTTLS
|
|
if ($encryption === 'tls') {
|
|
$resp = smtpCommand($fp, "STARTTLS");
|
|
$result['steps'][] = '🔒 STARTTLS: ' . smtpCode($resp);
|
|
if (smtpCode($resp) !== '220') {
|
|
$result['steps'][] = '❌ STARTTLS hylätty: ' . trim($resp);
|
|
fclose($fp);
|
|
echo json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
|
break;
|
|
}
|
|
$crypto = @stream_socket_enable_crypto($fp, true,
|
|
STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT | STREAM_CRYPTO_METHOD_TLSv1_3_CLIENT);
|
|
if (!$crypto) {
|
|
$result['steps'][] = '❌ TLS-neuvottelu epäonnistui';
|
|
fclose($fp);
|
|
echo json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
|
break;
|
|
}
|
|
$result['steps'][] = '✅ TLS OK';
|
|
// Re-EHLO
|
|
$ehlo = smtpCommand($fp, "EHLO " . gethostname());
|
|
$result['steps'][] = '📨 EHLO2: ' . smtpCode($ehlo);
|
|
$result['ehlo_capabilities_tls'] = trim($ehlo);
|
|
}
|
|
|
|
// AUTH PLAIN
|
|
$cred = base64_encode("\0{$effectiveUser}\0{$effectivePass}");
|
|
$resp = smtpCommand($fp, "AUTH PLAIN {$cred}");
|
|
$result['steps'][] = '🔑 AUTH PLAIN: ' . trim($resp);
|
|
if (smtpCode($resp) === '235') {
|
|
$result['steps'][] = '✅ Kirjautuminen onnistui (AUTH PLAIN)!';
|
|
smtpCommand($fp, "QUIT");
|
|
fclose($fp);
|
|
echo json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
|
break;
|
|
}
|
|
|
|
// AUTH LOGIN fallback
|
|
$resp = smtpCommand($fp, "AUTH LOGIN");
|
|
$result['steps'][] = '🔑 AUTH LOGIN: ' . smtpCode($resp);
|
|
if (smtpCode($resp) === '334') {
|
|
$resp = smtpCommand($fp, base64_encode($effectiveUser));
|
|
$result['steps'][] = '🔑 Käyttäjä: ' . smtpCode($resp);
|
|
if (smtpCode($resp) === '334') {
|
|
$resp = smtpCommand($fp, base64_encode($effectivePass));
|
|
$result['steps'][] = '🔑 Salasana: ' . trim($resp);
|
|
if (smtpCode($resp) === '235') {
|
|
$result['steps'][] = '✅ Kirjautuminen onnistui (AUTH LOGIN)!';
|
|
smtpCommand($fp, "QUIT");
|
|
fclose($fp);
|
|
echo json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
$result['steps'][] = "❌ Kirjautuminen epäonnistui (käyttäjä: {$effectiveUser}, salasanan pituus: " . strlen($effectivePass) . ")";
|
|
smtpCommand($fp, "QUIT");
|
|
fclose($fp);
|
|
echo json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
|
break;
|
|
|
|
case 'mailbox_save':
|
|
requireAdmin();
|
|
$companyId = requireCompany();
|
|
if ($method !== 'POST') break;
|
|
$input = json_decode(file_get_contents('php://input'), true);
|
|
|
|
$mb = [
|
|
'id' => $input['id'] ?? generateId(),
|
|
'nimi' => trim($input['nimi'] ?? ''),
|
|
'imap_host' => trim($input['imap_host'] ?? ''),
|
|
'imap_port' => intval($input['imap_port'] ?? 993),
|
|
'imap_user' => trim($input['imap_user'] ?? ''),
|
|
'imap_encryption' => trim($input['imap_encryption'] ?? 'ssl'),
|
|
'smtp_from_email' => trim($input['smtp_from_email'] ?? ''),
|
|
'smtp_from_name' => trim($input['smtp_from_name'] ?? ''),
|
|
'smtp_host' => trim($input['smtp_host'] ?? ''),
|
|
'smtp_port' => intval($input['smtp_port'] ?? 587),
|
|
'smtp_user' => trim($input['smtp_user'] ?? ''),
|
|
'smtp_encryption' => trim($input['smtp_encryption'] ?? 'tls'),
|
|
'aktiivinen' => $input['aktiivinen'] ?? true,
|
|
'auto_reply_enabled' => !empty($input['auto_reply_enabled']),
|
|
'auto_reply_body' => trim($input['auto_reply_body'] ?? ''),
|
|
];
|
|
// Hae vanha mailbox salasanojen vertailua varten
|
|
$existingMb = dbGetMailbox($mb['id'], $companyId);
|
|
// IMAP-salasana: jos ******** -> pidä vanha, muuten päivitä
|
|
if (isset($input['imap_password']) && $input['imap_password'] !== '********') {
|
|
$mb['imap_password'] = $input['imap_password'];
|
|
} else {
|
|
$mb['imap_password'] = $existingMb ? ($existingMb['imap_password'] ?? '') : '';
|
|
}
|
|
// SMTP-salasana: jos ******** -> pidä vanha, muuten päivitä
|
|
if (isset($input['smtp_password']) && $input['smtp_password'] !== '********') {
|
|
$mb['smtp_password'] = $input['smtp_password'];
|
|
} else {
|
|
$mb['smtp_password'] = $existingMb ? ($existingMb['smtp_password'] ?? '') : '';
|
|
}
|
|
// Jos SMTP-salasana on tyhjä mutta käyttäjä on sama kuin IMAP → kopioi IMAP-salasana
|
|
if (empty($mb['smtp_password']) && $mb['smtp_user'] === $mb['imap_user']) {
|
|
$mb['smtp_password'] = $mb['imap_password'];
|
|
}
|
|
|
|
if (empty($mb['nimi'])) {
|
|
http_response_code(400);
|
|
echo json_encode(['error' => 'Postilaatikon nimi puuttuu']);
|
|
break;
|
|
}
|
|
|
|
dbSaveMailbox($companyId, $mb);
|
|
dbAddLog($companyId, currentUser(), 'mailbox_save', '', '', 'Postilaatikko: ' . $mb['nimi']);
|
|
// Palauta ilman salasanaa
|
|
$mb['imap_password'] = '********';
|
|
echo json_encode($mb);
|
|
break;
|
|
|
|
case 'mailbox_delete':
|
|
requireAdmin();
|
|
$companyId = requireCompany();
|
|
if ($method !== 'POST') break;
|
|
$input = json_decode(file_get_contents('php://input'), true);
|
|
$mbId = $input['id'] ?? '';
|
|
dbDeleteMailbox($mbId, $companyId);
|
|
echo json_encode(['success' => true]);
|
|
break;
|
|
|
|
// ==================== DOKUMENTIT ====================
|
|
|
|
case 'documents':
|
|
requireAuth();
|
|
$companyId = requireCompany();
|
|
try {
|
|
$customerId = $_GET['customer_id'] ?? null;
|
|
$docs = dbLoadDocuments($companyId, $customerId ?: null);
|
|
echo json_encode($docs);
|
|
} catch (Exception $e) {
|
|
http_response_code(500);
|
|
echo json_encode(['error' => 'Dokumenttien haku epäonnistui: ' . $e->getMessage()]);
|
|
}
|
|
break;
|
|
|
|
case 'document':
|
|
requireAuth();
|
|
$companyId = requireCompany();
|
|
try {
|
|
$docId = $_GET['id'] ?? '';
|
|
if (empty($docId)) {
|
|
http_response_code(400);
|
|
echo json_encode(['error' => 'Dokumentin ID puuttuu']);
|
|
break;
|
|
}
|
|
$doc = dbLoadDocument($docId);
|
|
if (!$doc || $doc['company_id'] !== $companyId) {
|
|
http_response_code(404);
|
|
echo json_encode(['error' => 'Dokumenttia ei löytynyt']);
|
|
break;
|
|
}
|
|
echo json_encode($doc);
|
|
} catch (Exception $e) {
|
|
http_response_code(500);
|
|
echo json_encode(['error' => 'Dokumentin haku epäonnistui: ' . $e->getMessage()]);
|
|
}
|
|
break;
|
|
|
|
case 'document_save':
|
|
requireAuth();
|
|
$companyId = requireCompany();
|
|
if ($method !== 'POST') break;
|
|
try {
|
|
$input = json_decode(file_get_contents('php://input'), true);
|
|
$input['created_by'] = $input['created_by'] ?? currentUser();
|
|
$input['muokkaaja'] = currentUser();
|
|
$id = dbSaveDocument($companyId, $input);
|
|
$doc = dbLoadDocument($id);
|
|
echo json_encode($doc);
|
|
} catch (Exception $e) {
|
|
http_response_code(500);
|
|
echo json_encode(['error' => 'Dokumentin tallennus epäonnistui: ' . $e->getMessage()]);
|
|
}
|
|
break;
|
|
|
|
case 'document_upload':
|
|
requireAuth();
|
|
$companyId = requireCompany();
|
|
if ($method !== 'POST') break;
|
|
try {
|
|
$docId = $_POST['document_id'] ?? '';
|
|
if (empty($docId)) {
|
|
http_response_code(400);
|
|
echo json_encode(['error' => 'Dokumentin ID puuttuu']);
|
|
break;
|
|
}
|
|
// Tarkista dokumentin olemassaolo
|
|
$doc = dbLoadDocument($docId);
|
|
if (!$doc || $doc['company_id'] !== $companyId) {
|
|
http_response_code(404);
|
|
echo json_encode(['error' => 'Dokumenttia ei löytynyt']);
|
|
break;
|
|
}
|
|
if (!isset($_FILES['file']) || $_FILES['file']['error'] !== UPLOAD_ERR_OK) {
|
|
http_response_code(400);
|
|
echo json_encode(['error' => 'Tiedosto puuttuu tai lähetys epäonnistui']);
|
|
break;
|
|
}
|
|
$file = $_FILES['file'];
|
|
// Max 50MB
|
|
if ($file['size'] > 50 * 1024 * 1024) {
|
|
http_response_code(400);
|
|
echo json_encode(['error' => 'Tiedosto on liian suuri (max 50MB)']);
|
|
break;
|
|
}
|
|
// Luo hakemisto
|
|
$docDir = DATA_DIR . '/companies/' . $companyId . '/documents/' . $docId;
|
|
if (!file_exists($docDir)) mkdir($docDir, 0755, true);
|
|
|
|
$nextVersion = (int)$doc['current_version'] + 1;
|
|
$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
|
|
$safeFilename = $nextVersion . '_' . preg_replace('/[^a-zA-Z0-9._-]/', '_', $file['name']);
|
|
|
|
move_uploaded_file($file['tmp_name'], $docDir . '/' . $safeFilename);
|
|
|
|
$mimeType = '';
|
|
if (function_exists('finfo_open')) {
|
|
$finfo = finfo_open(FILEINFO_MIME_TYPE);
|
|
$mimeType = finfo_file($finfo, $docDir . '/' . $safeFilename);
|
|
finfo_close($finfo);
|
|
}
|
|
|
|
dbAddDocumentVersion($docId, [
|
|
'filename' => $safeFilename,
|
|
'original_name' => $file['name'],
|
|
'file_size' => $file['size'],
|
|
'mime_type' => $mimeType ?: ($file['type'] ?? ''),
|
|
'change_notes' => $_POST['change_notes'] ?? '',
|
|
'created_by' => currentUser()
|
|
]);
|
|
|
|
$updatedDoc = dbLoadDocument($docId);
|
|
echo json_encode($updatedDoc);
|
|
} catch (Exception $e) {
|
|
http_response_code(500);
|
|
echo json_encode(['error' => 'Tiedoston lataus epäonnistui: ' . $e->getMessage()]);
|
|
}
|
|
break;
|
|
|
|
case 'document_download':
|
|
requireAuth();
|
|
$companyId = requireCompany();
|
|
try {
|
|
$docId = $_GET['id'] ?? '';
|
|
$versionNum = (int)($_GET['version'] ?? 0);
|
|
if (empty($docId)) {
|
|
http_response_code(400);
|
|
echo json_encode(['error' => 'Dokumentin ID puuttuu']);
|
|
break;
|
|
}
|
|
$doc = dbLoadDocument($docId);
|
|
if (!$doc || $doc['company_id'] !== $companyId) {
|
|
http_response_code(404);
|
|
echo json_encode(['error' => 'Dokumenttia ei löytynyt']);
|
|
break;
|
|
}
|
|
// Jos versiota ei annettu, käytä nykyistä
|
|
if ($versionNum <= 0) $versionNum = (int)$doc['current_version'];
|
|
$version = dbGetDocumentVersion($docId, $versionNum);
|
|
if (!$version) {
|
|
http_response_code(404);
|
|
echo json_encode(['error' => 'Versiota ei löytynyt']);
|
|
break;
|
|
}
|
|
$filePath = DATA_DIR . '/companies/' . $companyId . '/documents/' . $docId . '/' . $version['filename'];
|
|
if (!file_exists($filePath)) {
|
|
http_response_code(404);
|
|
echo json_encode(['error' => 'Tiedostoa ei löytynyt palvelimelta']);
|
|
break;
|
|
}
|
|
header('Content-Type: ' . ($version['mime_type'] ?: 'application/octet-stream'));
|
|
header('Content-Disposition: attachment; filename="' . $version['original_name'] . '"');
|
|
header('Content-Length: ' . filesize($filePath));
|
|
readfile($filePath);
|
|
exit;
|
|
} catch (Exception $e) {
|
|
http_response_code(500);
|
|
echo json_encode(['error' => 'Lataus epäonnistui: ' . $e->getMessage()]);
|
|
}
|
|
break;
|
|
|
|
case 'document_restore':
|
|
requireAdmin();
|
|
$companyId = requireCompany();
|
|
if ($method !== 'POST') break;
|
|
try {
|
|
$input = json_decode(file_get_contents('php://input'), true);
|
|
$docId = $input['document_id'] ?? '';
|
|
$versionId = $input['version_id'] ?? '';
|
|
if (empty($docId) || empty($versionId)) {
|
|
http_response_code(400);
|
|
echo json_encode(['error' => 'Parametrit puuttuvat']);
|
|
break;
|
|
}
|
|
$doc = dbLoadDocument($docId);
|
|
if (!$doc || $doc['company_id'] !== $companyId) {
|
|
http_response_code(404);
|
|
echo json_encode(['error' => 'Dokumenttia ei löytynyt']);
|
|
break;
|
|
}
|
|
// Hae vanha versio ja kopioi tiedosto
|
|
$oldVersion = null;
|
|
foreach ($doc['versions'] as $v) {
|
|
if ($v['id'] === $versionId) { $oldVersion = $v; break; }
|
|
}
|
|
if (!$oldVersion) {
|
|
http_response_code(404);
|
|
echo json_encode(['error' => 'Versiota ei löytynyt']);
|
|
break;
|
|
}
|
|
// Kopioi tiedosto uudella nimellä
|
|
$docDir = DATA_DIR . '/companies/' . $companyId . '/documents/' . $docId;
|
|
$oldFilePath = $docDir . '/' . $oldVersion['filename'];
|
|
if (file_exists($oldFilePath)) {
|
|
$nextVersion = (int)$doc['current_version'] + 1;
|
|
$newFilename = $nextVersion . '_' . $oldVersion['original_name'];
|
|
$newFilename = preg_replace('/[^a-zA-Z0-9._-]/', '_', $newFilename);
|
|
copy($oldFilePath, $docDir . '/' . $newFilename);
|
|
}
|
|
$result = dbRestoreDocumentVersion($docId, $versionId, currentUser());
|
|
if ($result === null) {
|
|
http_response_code(400);
|
|
echo json_encode(['error' => 'Palautus epäonnistui']);
|
|
break;
|
|
}
|
|
$updatedDoc = dbLoadDocument($docId);
|
|
echo json_encode($updatedDoc);
|
|
} catch (Exception $e) {
|
|
http_response_code(500);
|
|
echo json_encode(['error' => 'Palautus epäonnistui: ' . $e->getMessage()]);
|
|
}
|
|
break;
|
|
|
|
case 'document_delete':
|
|
requireAuth();
|
|
$companyId = requireCompany();
|
|
if ($method !== 'POST') break;
|
|
try {
|
|
$input = json_decode(file_get_contents('php://input'), true);
|
|
$docId = $input['id'] ?? '';
|
|
$doc = dbLoadDocument($docId);
|
|
if (!$doc || $doc['company_id'] !== $companyId) {
|
|
http_response_code(404);
|
|
echo json_encode(['error' => 'Dokumenttia ei löytynyt']);
|
|
break;
|
|
}
|
|
// Salli poisto adminille tai dokumentin luojalle
|
|
if (!isCompanyAdmin() && $doc['created_by'] !== currentUser()) {
|
|
http_response_code(403);
|
|
echo json_encode(['error' => 'Ei oikeutta poistaa']);
|
|
break;
|
|
}
|
|
// Poista tiedostot levyltä
|
|
$docDir = DATA_DIR . '/companies/' . $companyId . '/documents/' . $docId;
|
|
if (is_dir($docDir)) {
|
|
$files = glob($docDir . '/*');
|
|
foreach ($files as $f) { if (is_file($f)) unlink($f); }
|
|
rmdir($docDir);
|
|
}
|
|
dbDeleteDocument($docId);
|
|
echo json_encode(['success' => true]);
|
|
} catch (Exception $e) {
|
|
http_response_code(500);
|
|
echo json_encode(['error' => 'Poisto epäonnistui: ' . $e->getMessage()]);
|
|
}
|
|
break;
|
|
|
|
// ==================== DOKUMENTTIKANSIOT ====================
|
|
|
|
case 'document_folders':
|
|
requireAuth();
|
|
$companyId = requireCompany();
|
|
$customerId = $_GET['customer_id'] ?? null;
|
|
echo json_encode(dbLoadFolders($companyId, $customerId));
|
|
break;
|
|
|
|
case 'document_folder_save':
|
|
requireAuth();
|
|
$companyId = requireCompany();
|
|
if ($method !== 'POST') break;
|
|
$input = json_decode(file_get_contents('php://input'), true);
|
|
if (empty($input['name'])) {
|
|
http_response_code(400);
|
|
echo json_encode(['error' => 'Kansion nimi puuttuu']);
|
|
break;
|
|
}
|
|
$input['created_by'] = currentUser();
|
|
$id = dbSaveFolder($companyId, $input);
|
|
echo json_encode(['id' => $id, 'name' => $input['name']]);
|
|
break;
|
|
|
|
case 'document_folder_delete':
|
|
requireAdmin();
|
|
$companyId = requireCompany();
|
|
if ($method !== 'POST') break;
|
|
$input = json_decode(file_get_contents('php://input'), true);
|
|
$result = dbDeleteFolder($companyId, $input['id'] ?? '');
|
|
echo json_encode(['ok' => $result]);
|
|
break;
|
|
|
|
case 'document_content_save':
|
|
requireAuth();
|
|
$companyId = requireCompany();
|
|
if ($method !== 'POST') break;
|
|
try {
|
|
$input = json_decode(file_get_contents('php://input'), true);
|
|
$docId = $input['document_id'] ?? '';
|
|
$doc = dbLoadDocument($docId);
|
|
if (!$doc || $doc['company_id'] !== $companyId) {
|
|
http_response_code(404);
|
|
echo json_encode(['error' => 'Dokumenttia ei löytynyt']);
|
|
break;
|
|
}
|
|
dbAddDocumentVersion($docId, [
|
|
'content' => $input['content'] ?? '',
|
|
'change_notes' => $input['change_notes'] ?? '',
|
|
'created_by' => currentUser(),
|
|
'filename' => '',
|
|
'original_name' => '',
|
|
'file_size' => strlen($input['content'] ?? ''),
|
|
'mime_type' => 'text/plain'
|
|
]);
|
|
$updated = dbLoadDocument($docId);
|
|
echo json_encode($updated);
|
|
} catch (Exception $e) {
|
|
http_response_code(500);
|
|
echo json_encode(['error' => 'Tallennus epäonnistui: ' . $e->getMessage()]);
|
|
}
|
|
break;
|
|
|
|
case 'document_move':
|
|
requireAuth();
|
|
$companyId = requireCompany();
|
|
if ($method !== 'POST') break;
|
|
try {
|
|
$input = json_decode(file_get_contents('php://input'), true);
|
|
$docId = $input['document_id'] ?? '';
|
|
$folderId = $input['folder_id'] ?? null;
|
|
$doc = dbLoadDocument($docId);
|
|
if (!$doc || $doc['company_id'] !== $companyId) {
|
|
http_response_code(404);
|
|
echo json_encode(['error' => 'Dokumenttia ei löytynyt']);
|
|
break;
|
|
}
|
|
_dbExecute("UPDATE documents SET folder_id = ? WHERE id = ?", [$folderId ?: null, $docId]);
|
|
echo json_encode(['ok' => true]);
|
|
} catch (Exception $e) {
|
|
http_response_code(500);
|
|
echo json_encode(['error' => 'Siirto epäonnistui: ' . $e->getMessage()]);
|
|
}
|
|
break;
|
|
|
|
// ==================== NETADMIN ====================
|
|
|
|
case 'netadmin_connections':
|
|
requireAuth();
|
|
$companyId = requireCompany();
|
|
try {
|
|
$connections = dbLoadAllConnections($companyId);
|
|
// Hae myös laitteet ja IPAM-tiedot aggregointia varten
|
|
$devices = _dbFetchAll("SELECT id, nimi, hallintaosoite, site_id, malli, ping_status FROM devices WHERE company_id = ?", [$companyId]);
|
|
$deviceMap = [];
|
|
foreach ($devices as $d) { $deviceMap[$d['nimi']] = $d; }
|
|
|
|
// Hae IPAM VLANit, IP:t ja subnetit valikkoja varten
|
|
$ipamAll = dbLoadIpam($companyId);
|
|
$vlans = array_values(array_filter($ipamAll, fn($e) => $e['tyyppi'] === 'vlan'));
|
|
$ips = array_values(array_filter($ipamAll, fn($e) => $e['tyyppi'] === 'ip' || $e['tyyppi'] === 'subnet'));
|
|
|
|
// Rikasta liittymädata laitetiedoilla ja IPAM-tiedoilla
|
|
// Rakenna IPAM-lookup asiakkaan nimen perusteella
|
|
$ipamByCustomer = [];
|
|
foreach ($ipamAll as $entry) {
|
|
$asiakas = $entry['asiakas'] ?? '';
|
|
if ($asiakas) {
|
|
$ipamByCustomer[$asiakas][] = $entry;
|
|
}
|
|
}
|
|
|
|
foreach ($connections as &$conn) {
|
|
$deviceName = $conn['laite'] ?? '';
|
|
if ($deviceName && isset($deviceMap[$deviceName])) {
|
|
$conn['device_info'] = $deviceMap[$deviceName];
|
|
}
|
|
// Auto-populate VLAN ja IP IPAMista asiakkaan nimen perusteella
|
|
$custName = $conn['customer_name'] ?? '';
|
|
$ipamEntries = $ipamByCustomer[$custName] ?? [];
|
|
$conn['ipam_vlans'] = [];
|
|
$conn['ipam_ips'] = [];
|
|
foreach ($ipamEntries as $ie) {
|
|
if ($ie['tyyppi'] === 'vlan') {
|
|
$conn['ipam_vlans'][] = ['vlan_id' => $ie['vlan_id'], 'nimi' => $ie['nimi'], 'site_name' => $ie['site_name'] ?? ''];
|
|
} elseif ($ie['tyyppi'] === 'ip' || $ie['tyyppi'] === 'subnet') {
|
|
$conn['ipam_ips'][] = ['verkko' => $ie['verkko'], 'nimi' => $ie['nimi'], 'tila' => $ie['tila'], 'site_name' => $ie['site_name'] ?? ''];
|
|
}
|
|
}
|
|
}
|
|
|
|
echo json_encode([
|
|
'connections' => $connections,
|
|
'total' => count($connections),
|
|
'devices' => $devices,
|
|
'vlans' => $vlans,
|
|
'ips' => $ips
|
|
]);
|
|
} catch (Exception $e) {
|
|
http_response_code(500);
|
|
echo json_encode(['error' => 'Liittymien haku epäonnistui: ' . $e->getMessage()]);
|
|
}
|
|
break;
|
|
|
|
case 'netadmin_connection':
|
|
requireAuth();
|
|
$companyId = requireCompany();
|
|
try {
|
|
$connId = (int)($_GET['id'] ?? 0);
|
|
if (!$connId) {
|
|
http_response_code(400);
|
|
echo json_encode(['error' => 'Liittymän ID puuttuu']);
|
|
break;
|
|
}
|
|
$conn = dbLoadConnection($connId);
|
|
if (!$conn || $conn['company_id'] !== $companyId) {
|
|
http_response_code(404);
|
|
echo json_encode(['error' => 'Liittymää ei löytynyt']);
|
|
break;
|
|
}
|
|
// Lisää IPAM-data asiakkaan nimen perusteella
|
|
$ipamAll = dbLoadIpam($companyId);
|
|
$custName = $conn['customer_name'] ?? '';
|
|
$conn['ipam_vlans'] = [];
|
|
$conn['ipam_ips'] = [];
|
|
if ($custName) {
|
|
foreach ($ipamAll as $ie) {
|
|
$asiakas = $ie['asiakas'] ?? '';
|
|
if ($asiakas !== $custName) continue;
|
|
if ($ie['tyyppi'] === 'vlan') {
|
|
$conn['ipam_vlans'][] = ['vlan_id' => $ie['vlan_id'], 'nimi' => $ie['nimi'], 'site_name' => $ie['site_name'] ?? ''];
|
|
} elseif ($ie['tyyppi'] === 'ip' || $ie['tyyppi'] === 'subnet') {
|
|
$conn['ipam_ips'][] = ['verkko' => $ie['verkko'], 'nimi' => $ie['nimi'], 'tila' => $ie['tila'], 'site_name' => $ie['site_name'] ?? ''];
|
|
}
|
|
}
|
|
}
|
|
echo json_encode($conn);
|
|
} catch (Exception $e) {
|
|
http_response_code(500);
|
|
echo json_encode(['error' => 'Liittymän haku epäonnistui: ' . $e->getMessage()]);
|
|
}
|
|
break;
|
|
|
|
case 'netadmin_connection_update':
|
|
requireAuth();
|
|
$companyId = requireCompany();
|
|
if ($method !== 'POST') break;
|
|
try {
|
|
$input = json_decode(file_get_contents('php://input'), true);
|
|
$connId = (int)($input['id'] ?? 0);
|
|
if (!$connId) {
|
|
http_response_code(400);
|
|
echo json_encode(['error' => 'Liittymän ID puuttuu']);
|
|
break;
|
|
}
|
|
$conn = dbLoadConnection($connId);
|
|
if (!$conn || $conn['company_id'] !== $companyId) {
|
|
http_response_code(404);
|
|
echo json_encode(['error' => 'Liittymää ei löytynyt']);
|
|
break;
|
|
}
|
|
// Automaattinen IPAM-tilan päivitys kun IP muuttuu
|
|
$oldIp = trim($conn['ip'] ?? '');
|
|
$newIp = trim($input['ip'] ?? '');
|
|
if ($oldIp !== $newIp) {
|
|
$ipamAll = dbLoadIpam($companyId);
|
|
// Vapauta vanha IP
|
|
if ($oldIp) {
|
|
foreach ($ipamAll as $entry) {
|
|
if ($entry['tyyppi'] === 'ip' && $entry['verkko'] === $oldIp && $entry['tila'] === 'varattu') {
|
|
$entry['tila'] = 'vapaa';
|
|
$entry['asiakas'] = '';
|
|
$entry['muokattu'] = date('Y-m-d H:i:s');
|
|
$entry['muokkaaja'] = currentUser();
|
|
dbSaveIpam($companyId, $entry);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
// Varaa uusi IP
|
|
if ($newIp) {
|
|
$customerName = $conn['customer_name'] ?? '';
|
|
foreach ($ipamAll as $entry) {
|
|
if ($entry['tyyppi'] === 'ip' && $entry['verkko'] === $newIp) {
|
|
$entry['tila'] = 'varattu';
|
|
$entry['asiakas'] = $customerName;
|
|
$entry['muokattu'] = date('Y-m-d H:i:s');
|
|
$entry['muokkaaja'] = currentUser();
|
|
dbSaveIpam($companyId, $entry);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
dbUpdateConnection($connId, $input);
|
|
$updated = dbLoadConnection($connId);
|
|
echo json_encode($updated);
|
|
} catch (Exception $e) {
|
|
http_response_code(500);
|
|
echo json_encode(['error' => 'Liittymän päivitys epäonnistui: ' . $e->getMessage()]);
|
|
}
|
|
break;
|
|
|
|
// ==================== LAITETILAT ====================
|
|
|
|
case 'laitetilat':
|
|
requireAuth();
|
|
$companyId = requireCompany();
|
|
try {
|
|
$tilat = dbLoadLaitetilat($companyId);
|
|
echo json_encode($tilat);
|
|
} catch (Exception $e) {
|
|
http_response_code(500);
|
|
echo json_encode(['error' => 'Laitetilojen haku epäonnistui: ' . $e->getMessage()]);
|
|
}
|
|
break;
|
|
|
|
case 'laitetila':
|
|
requireAuth();
|
|
$companyId = requireCompany();
|
|
try {
|
|
$tilaId = $_GET['id'] ?? '';
|
|
if (empty($tilaId)) {
|
|
http_response_code(400);
|
|
echo json_encode(['error' => 'Laitetilan ID puuttuu']);
|
|
break;
|
|
}
|
|
$tila = dbLoadLaitetila($tilaId);
|
|
if (!$tila || $tila['company_id'] !== $companyId) {
|
|
http_response_code(404);
|
|
echo json_encode(['error' => 'Laitetilaa ei löytynyt']);
|
|
break;
|
|
}
|
|
echo json_encode($tila);
|
|
} catch (Exception $e) {
|
|
http_response_code(500);
|
|
echo json_encode(['error' => 'Laitetilan haku epäonnistui: ' . $e->getMessage()]);
|
|
}
|
|
break;
|
|
|
|
case 'laitetila_save':
|
|
requireAuth();
|
|
$companyId = requireCompany();
|
|
if ($method !== 'POST') break;
|
|
try {
|
|
$input = json_decode(file_get_contents('php://input'), true);
|
|
if (empty($input['nimi'])) {
|
|
http_response_code(400);
|
|
echo json_encode(['error' => 'Laitetilan nimi puuttuu']);
|
|
break;
|
|
}
|
|
$input['muokkaaja'] = currentUser();
|
|
$id = dbSaveLaitetila($companyId, $input);
|
|
$tila = dbLoadLaitetila($id);
|
|
echo json_encode($tila);
|
|
} catch (Exception $e) {
|
|
http_response_code(500);
|
|
echo json_encode(['error' => 'Laitetilan tallennus epäonnistui: ' . $e->getMessage()]);
|
|
}
|
|
break;
|
|
|
|
case 'laitetila_delete':
|
|
requireAdmin();
|
|
$companyId = requireCompany();
|
|
if ($method !== 'POST') break;
|
|
try {
|
|
$input = json_decode(file_get_contents('php://input'), true);
|
|
$tilaId = $input['id'] ?? '';
|
|
if (empty($tilaId)) {
|
|
http_response_code(400);
|
|
echo json_encode(['error' => 'Laitetilan ID puuttuu']);
|
|
break;
|
|
}
|
|
$tila = dbLoadLaitetila($tilaId);
|
|
if (!$tila || $tila['company_id'] !== $companyId) {
|
|
http_response_code(404);
|
|
echo json_encode(['error' => 'Laitetilaa ei löytynyt']);
|
|
break;
|
|
}
|
|
// Poista tiedostot levyltä
|
|
$tilaDir = DATA_DIR . '/companies/' . $companyId . '/laitetilat/' . $tilaId;
|
|
if (is_dir($tilaDir)) {
|
|
$items = glob($tilaDir . '/{,.}*', GLOB_BRACE);
|
|
foreach ($items as $item) {
|
|
if (is_file($item)) @unlink($item);
|
|
}
|
|
@rmdir($tilaDir);
|
|
}
|
|
dbDeleteLaitetila($tilaId);
|
|
dbAddLog($companyId, currentUser(), 'laitetila_delete', $tilaId, $tila['nimi'] ?? '', 'Poisti laitetilan');
|
|
echo json_encode(['success' => true]);
|
|
} catch (\Throwable $e) {
|
|
http_response_code(500);
|
|
echo json_encode(['error' => 'Poisto epäonnistui: ' . $e->getMessage()]);
|
|
}
|
|
break;
|
|
|
|
case 'laitetila_file_upload':
|
|
requireAuth();
|
|
$companyId = requireCompany();
|
|
if ($method !== 'POST') break;
|
|
try {
|
|
$tilaId = $_POST['laitetila_id'] ?? '';
|
|
if (empty($tilaId)) {
|
|
http_response_code(400);
|
|
echo json_encode(['error' => 'Laitetilan ID puuttuu']);
|
|
break;
|
|
}
|
|
$tila = dbLoadLaitetila($tilaId);
|
|
if (!$tila || $tila['company_id'] !== $companyId) {
|
|
http_response_code(404);
|
|
echo json_encode(['error' => 'Laitetilaa ei löytynyt']);
|
|
break;
|
|
}
|
|
if (!isset($_FILES['file']) || $_FILES['file']['error'] !== UPLOAD_ERR_OK) {
|
|
http_response_code(400);
|
|
echo json_encode(['error' => 'Tiedosto puuttuu tai lähetys epäonnistui']);
|
|
break;
|
|
}
|
|
$file = $_FILES['file'];
|
|
if ($file['size'] > 50 * 1024 * 1024) {
|
|
http_response_code(400);
|
|
echo json_encode(['error' => 'Tiedosto on liian suuri (max 50MB)']);
|
|
break;
|
|
}
|
|
$tilaDir = DATA_DIR . '/companies/' . $companyId . '/laitetilat/' . $tilaId;
|
|
if (!file_exists($tilaDir)) mkdir($tilaDir, 0755, true);
|
|
|
|
$safeFilename = time() . '_' . preg_replace('/[^a-zA-Z0-9._-]/', '_', $file['name']);
|
|
move_uploaded_file($file['tmp_name'], $tilaDir . '/' . $safeFilename);
|
|
|
|
$mimeType = '';
|
|
if (function_exists('finfo_open')) {
|
|
$finfo = finfo_open(FILEINFO_MIME_TYPE);
|
|
$mimeType = finfo_file($finfo, $tilaDir . '/' . $safeFilename);
|
|
finfo_close($finfo);
|
|
}
|
|
|
|
dbAddLaitetilaFile($tilaId, [
|
|
'filename' => $safeFilename,
|
|
'original_name' => $file['name'],
|
|
'file_size' => $file['size'],
|
|
'mime_type' => $mimeType ?: ($file['type'] ?? ''),
|
|
'description' => $_POST['description'] ?? '',
|
|
'created_by' => currentUser()
|
|
]);
|
|
|
|
$updatedTila = dbLoadLaitetila($tilaId);
|
|
echo json_encode($updatedTila);
|
|
} catch (Exception $e) {
|
|
http_response_code(500);
|
|
echo json_encode(['error' => 'Tiedoston lataus epäonnistui: ' . $e->getMessage()]);
|
|
}
|
|
break;
|
|
|
|
case 'laitetila_file_delete':
|
|
requireAdmin();
|
|
$companyId = requireCompany();
|
|
if ($method !== 'POST') break;
|
|
try {
|
|
$input = json_decode(file_get_contents('php://input'), true);
|
|
$fileId = $input['id'] ?? '';
|
|
$file = dbDeleteLaitetilaFile($fileId);
|
|
if ($file) {
|
|
$filePath = DATA_DIR . '/companies/' . $file['company_id'] . '/laitetilat/' . $file['laitetila_id'] . '/' . $file['filename'];
|
|
if (file_exists($filePath)) unlink($filePath);
|
|
}
|
|
echo json_encode(['success' => true]);
|
|
} catch (Exception $e) {
|
|
http_response_code(500);
|
|
echo json_encode(['error' => 'Tiedoston poisto epäonnistui: ' . $e->getMessage()]);
|
|
}
|
|
break;
|
|
|
|
case 'laitetila_file_download':
|
|
requireAuth();
|
|
$companyId = requireCompany();
|
|
try {
|
|
$tilaId = $_GET['laitetila_id'] ?? '';
|
|
$fileId = $_GET['file_id'] ?? '';
|
|
if (empty($tilaId) || empty($fileId)) {
|
|
http_response_code(400);
|
|
echo json_encode(['error' => 'Parametrit puuttuvat']);
|
|
break;
|
|
}
|
|
$tila = dbLoadLaitetila($tilaId);
|
|
if (!$tila || $tila['company_id'] !== $companyId) {
|
|
http_response_code(404);
|
|
echo json_encode(['error' => 'Laitetilaa ei löytynyt']);
|
|
break;
|
|
}
|
|
$targetFile = null;
|
|
foreach ($tila['files'] as $f) {
|
|
if ($f['id'] === $fileId) { $targetFile = $f; break; }
|
|
}
|
|
if (!$targetFile) {
|
|
http_response_code(404);
|
|
echo json_encode(['error' => 'Tiedostoa ei löytynyt']);
|
|
break;
|
|
}
|
|
$filePath = DATA_DIR . '/companies/' . $companyId . '/laitetilat/' . $tilaId . '/' . $targetFile['filename'];
|
|
if (!file_exists($filePath)) {
|
|
http_response_code(404);
|
|
echo json_encode(['error' => 'Tiedostoa ei löytynyt palvelimelta']);
|
|
break;
|
|
}
|
|
header('Content-Type: ' . ($targetFile['mime_type'] ?: 'application/octet-stream'));
|
|
header('Content-Disposition: attachment; filename="' . $targetFile['original_name'] . '"');
|
|
header('Content-Length: ' . filesize($filePath));
|
|
readfile($filePath);
|
|
exit;
|
|
} catch (Exception $e) {
|
|
http_response_code(500);
|
|
echo json_encode(['error' => 'Lataus epäonnistui: ' . $e->getMessage()]);
|
|
}
|
|
break;
|
|
|
|
// ==================== INTEGRAATIOT ====================
|
|
|
|
case 'integrations':
|
|
$companyId = 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);
|
|
if (isset($cfg['db_password'])) $cfg['db_password'] = str_repeat('*', 8);
|
|
$integ['config'] = $cfg;
|
|
}
|
|
}
|
|
echo json_encode($integrations);
|
|
break;
|
|
|
|
case 'integration_save':
|
|
$companyId = requireCompany();
|
|
$input = json_decode(file_get_contents('php://input'), true);
|
|
$type = $input['type'] ?? '';
|
|
$enabled = (bool)($input['enabled'] ?? false);
|
|
$config = $input['config'] ?? null;
|
|
if (!$type) { http_response_code(400); echo json_encode(['error' => 'Tyyppi puuttuu']); break; }
|
|
|
|
// Jos config ei lähetetty tai on tyhjä, säilytä vanha config (toggle ei ylikirjoita asetuksia)
|
|
$old = dbGetIntegration($companyId, $type);
|
|
if ($config === null || (is_array($config) && empty($config))) {
|
|
$config = ($old && isset($old['config'])) ? (is_string($old['config']) ? json_decode($old['config'], true) : $old['config']) : [];
|
|
} else {
|
|
// Jos token/password on maskattua (********), säilytä vanha
|
|
if (isset($config['token']) && preg_match('/^\*+$/', $config['token'])) {
|
|
if ($old && isset($old['config']['token'])) {
|
|
$config['token'] = is_string($old['config']) ? json_decode($old['config'], true)['token'] : $old['config']['token'];
|
|
}
|
|
}
|
|
if (isset($config['db_password']) && preg_match('/^\*+$/', $config['db_password'])) {
|
|
if ($old && isset($old['config']['db_password'])) {
|
|
$config['db_password'] = is_string($old['config']) ? json_decode($old['config'], true)['db_password'] : $old['config']['db_password'];
|
|
}
|
|
}
|
|
}
|
|
|
|
dbSaveIntegration($companyId, $type, $enabled, $config);
|
|
echo json_encode(['ok' => true]);
|
|
break;
|
|
|
|
case 'integration_test':
|
|
$companyId = requireCompany();
|
|
$input = json_decode(file_get_contents('php://input'), true);
|
|
$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);
|
|
} elseif ($type === 'iredmail') {
|
|
$client = getIRedMailClient($companyId);
|
|
$result = $client->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':
|
|
$companyId = 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':
|
|
$companyId = requireCompany();
|
|
$integ = dbGetIntegration($companyId, 'zammad');
|
|
if (!$integ || !$integ['enabled']) { http_response_code(400); echo json_encode(['error' => 'Zammad ei käytössä']); break; }
|
|
|
|
// full=1 pakottaa täyden synkkauksen (esim. "Hae postit" -napista)
|
|
$fullSync = ($_GET['full'] ?? $_POST['full'] ?? '') === '1';
|
|
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
|
if (!empty($input['full'])) $fullSync = true;
|
|
|
|
try {
|
|
$cfg = $integ['config'];
|
|
$z = new ZammadClient($cfg['url'], $cfg['token']);
|
|
$groupIds = $cfg['group_ids'] ?? [];
|
|
$syncedGroupNames = $cfg['group_names'] ?? [];
|
|
|
|
// Hae viimeisin synkkausaika — inkrementaalinen haku
|
|
$lastSync = null;
|
|
if (!$fullSync) {
|
|
$row = _dbFetchOne("SELECT MAX(updated) as last_updated FROM tickets WHERE company_id = ? AND source = 'zammad'", [$companyId]);
|
|
if ($row && $row['last_updated']) {
|
|
// Hae 5 min marginaalilla ettei menetä mitään
|
|
$lastSync = date('Y-m-d\TH:i:s', strtotime($row['last_updated']) - 300);
|
|
}
|
|
}
|
|
|
|
// Hae tikettejä Zammadista (inkrementaalinen: ohita suljetut nopeuttamiseksi)
|
|
$excludeClosed = !$fullSync;
|
|
$allTickets = [];
|
|
$page = 1;
|
|
$maxPages = $fullSync ? 10 : 3;
|
|
do {
|
|
$batch = $z->getTickets($groupIds, $page, 100, $lastSync, $excludeClosed);
|
|
if (empty($batch)) break;
|
|
$allTickets = array_merge($allTickets, $batch);
|
|
$page++;
|
|
} while (count($batch) >= 100 && $page <= $maxPages);
|
|
|
|
$created = 0;
|
|
$updated = 0;
|
|
$messagesAdded = 0;
|
|
$articlesFetched = 0;
|
|
$maxArticleFetches = 10; // Max tikettejä joille haetaan artikkelit per synk (loput on-demand)
|
|
|
|
// Zammad state → intran status
|
|
$stateMap = [
|
|
'new' => 'uusi', 'open' => 'kasittelyssa', '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',
|
|
];
|
|
|
|
// Vaihe 1: Synkkaa kaikki tiketit (nopea — ei artikkeleja)
|
|
$newTicketIds = []; // ticketId => zammadId — artikkelit haetaan toisessa vaiheessa
|
|
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, zammad_group, source, ticket_number, created, updated)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
[$ticketId, $companyId, $zt['title'] ?? '', $zt['customer'] ?? '', $zt['customer'] ?? '',
|
|
$status, $type, $priority, $zammadId, $group, 'zammad', (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++;
|
|
$newTicketIds[$ticketId] = $zammadId;
|
|
} else {
|
|
$ticketId = $existing['id'];
|
|
$zammadUpdated = date('Y-m-d H:i:s', strtotime($zt['updated_at'] ?? 'now'));
|
|
// Jos tiketti on suljettu tässä järjestelmässä, älä palauta uudeksi
|
|
// PAITSI jos Zammadissa status on oikeasti muuttunut (esim. uusi viesti avasi tiketin)
|
|
$localStatus = $existing['status'] ?? '';
|
|
$newStatus = $status;
|
|
// Tilat jotka on asetettu lokaalisti intrassa — Zammad ei saa ylikirjoittaa
|
|
$localOnlyStatuses = ['odottaa vastausta', 'kasittelyssa', 'odottaa'];
|
|
if (in_array($localStatus, $localOnlyStatuses)) {
|
|
// Säilytä lokaali tila — paitsi jos Zammadissa on suljettu
|
|
if ($status === 'suljettu') {
|
|
$newStatus = 'suljettu';
|
|
} else {
|
|
$newStatus = $localStatus;
|
|
}
|
|
} elseif ($localStatus === 'suljettu' && $status === 'uusi') {
|
|
// Tarkista onko Zammadissa oikeasti uutta toimintaa (updated_at muuttunut)
|
|
$localUpdated = $existing['updated'] ?? '';
|
|
if ($zammadUpdated > $localUpdated) {
|
|
$newStatus = 'uusi';
|
|
} else {
|
|
$newStatus = 'suljettu';
|
|
}
|
|
}
|
|
// Päivitä status/priority/group
|
|
_dbExecute(
|
|
"UPDATE tickets SET status = ?, type = ?, priority = ?, subject = ?, zammad_group = ?, updated = ? WHERE id = ?",
|
|
[$newStatus, $type, $priority, $zt['title'] ?? '', $group, $zammadUpdated, $ticketId]
|
|
);
|
|
$updated++;
|
|
}
|
|
}
|
|
|
|
// Vaihe 2: Hae artikkelit max N uusimmalle uudelle tiketille (loput on-demand)
|
|
$articleQueue = array_slice($newTicketIds, 0, $maxArticleFetches, true);
|
|
foreach ($articleQueue as $ticketId => $zammadId) {
|
|
try {
|
|
$articles = $z->getArticles($zammadId);
|
|
$toEmail = '';
|
|
$articlesFetched++;
|
|
foreach ($articles as $art) {
|
|
if (($art['internal'] ?? false)) continue;
|
|
// Poimi zammad_to_email ensimmäisestä asiakkaan viestistä
|
|
if (!$toEmail && ($art['sender'] ?? '') === 'Customer' && !empty($art['to'])) {
|
|
$toEmail = $art['to'];
|
|
}
|
|
$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'] ?? '';
|
|
if (($art['content_type'] ?? '') === 'text/html') {
|
|
$body = strip_tags($body, '<br><p><div><a><b><i><strong><em><ul><ol><li>');
|
|
}
|
|
|
|
// Liitteiden metadata
|
|
$attJson = '';
|
|
if (!empty($art['attachments'])) {
|
|
$atts = [];
|
|
foreach ($art['attachments'] as $att) {
|
|
$atts[] = [
|
|
'id' => $att['id'] ?? 0,
|
|
'filename' => $att['filename'] ?? '',
|
|
'size' => $att['size'] ?? 0,
|
|
'type' => $att['preferences']['Content-Type'] ?? ($att['content_type'] ?? 'application/octet-stream'),
|
|
];
|
|
}
|
|
$attJson = json_encode($atts);
|
|
}
|
|
|
|
_dbExecute(
|
|
"INSERT INTO ticket_messages (id, ticket_id, type, from_email, from_name, body, timestamp, message_id, zammad_article_id, attachments)
|
|
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,
|
|
$attJson]
|
|
);
|
|
$messagesAdded++;
|
|
}
|
|
// Tallenna zammad_to_email
|
|
if ($toEmail) {
|
|
_dbExecute("UPDATE tickets SET zammad_to_email = ? WHERE id = ?", [$toEmail, $ticketId]);
|
|
}
|
|
} catch (\Throwable $e) {
|
|
// Artikkelihaku epäonnistui — jatka silti
|
|
error_log("Zammad articles error for ticket $zammadId: " . $e->getMessage());
|
|
}
|
|
}
|
|
|
|
$skippedArticles = max(0, count($newTicketIds) - $maxArticleFetches);
|
|
echo json_encode([
|
|
'ok' => true,
|
|
'tickets_found' => count($allTickets),
|
|
'created' => $created,
|
|
'updated' => $updated,
|
|
'messages_added' => $messagesAdded,
|
|
'articles_fetched' => $articlesFetched,
|
|
'articles_deferred' => $skippedArticles, // Haetaan on-demand kun tiketti avataan
|
|
]);
|
|
} catch (\Throwable $e) {
|
|
http_response_code(500);
|
|
echo json_encode(['error' => $e->getMessage()]);
|
|
}
|
|
break;
|
|
|
|
case 'zammad_attachment':
|
|
requireAuth();
|
|
$companyId = requireCompanyOrParam();
|
|
$ticketId = $_GET['ticket_id'] ?? '';
|
|
$articleId = (int)($_GET['article_id'] ?? 0);
|
|
$attachmentId = (int)($_GET['attachment_id'] ?? 0);
|
|
if (!$ticketId || !$articleId || !$attachmentId) {
|
|
http_response_code(400);
|
|
echo json_encode(['error' => 'Puuttuvat parametrit (ticket_id, article_id, attachment_id)']);
|
|
break;
|
|
}
|
|
// Varmista että tiketti kuuluu yritykselle
|
|
$ticket = _dbFetchOne("SELECT * FROM tickets WHERE id = ? AND company_id = ?", [$ticketId, $companyId]);
|
|
if (!$ticket || empty($ticket['zammad_ticket_id'])) {
|
|
http_response_code(404);
|
|
echo json_encode(['error' => 'Tikettiä ei löydy']);
|
|
break;
|
|
}
|
|
try {
|
|
$integ = dbGetIntegration($companyId, 'zammad');
|
|
if (!$integ || !$integ['enabled']) {
|
|
http_response_code(400);
|
|
echo json_encode(['error' => 'Zammad-integraatio ei käytössä']);
|
|
break;
|
|
}
|
|
// Lataa liite Zammad API:sta (binary download)
|
|
$zUrl = rtrim($integ['config']['url'], '/');
|
|
if (!preg_match('#^https?://#i', $zUrl)) $zUrl = 'https://' . $zUrl;
|
|
$zToken = $integ['config']['token'];
|
|
$url = "{$zUrl}/api/v1/ticket_attachment/{$ticket['zammad_ticket_id']}/{$articleId}/{$attachmentId}";
|
|
$ch = curl_init($url);
|
|
curl_setopt_array($ch, [
|
|
CURLOPT_RETURNTRANSFER => true,
|
|
CURLOPT_TIMEOUT => 60,
|
|
CURLOPT_HTTPHEADER => [
|
|
'Authorization: Token token=' . $zToken,
|
|
],
|
|
]);
|
|
$fileData = curl_exec($ch);
|
|
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
|
$contentType = curl_getinfo($ch, CURLINFO_CONTENT_TYPE);
|
|
curl_close($ch);
|
|
if ($httpCode >= 400 || $fileData === false) {
|
|
http_response_code(502);
|
|
echo json_encode(['error' => 'Liitteen lataus Zammadista epäonnistui (HTTP ' . $httpCode . ')']);
|
|
break;
|
|
}
|
|
// Hae tiedostonimi metadata:sta
|
|
$filename = $_GET['filename'] ?? 'attachment';
|
|
header('Content-Type: ' . ($contentType ?: 'application/octet-stream'));
|
|
header('Content-Disposition: attachment; filename="' . addslashes($filename) . '"');
|
|
header('Content-Length: ' . strlen($fileData));
|
|
echo $fileData;
|
|
} catch (\Throwable $e) {
|
|
http_response_code(500);
|
|
echo json_encode(['error' => $e->getMessage()]);
|
|
}
|
|
break;
|
|
|
|
case 'zammad_reply':
|
|
$companyId = requireCompany();
|
|
$input = json_decode(file_get_contents('php://input'), true);
|
|
$ticketId = $input['ticket_id'] ?? '';
|
|
$body = $input['body'] ?? '';
|
|
if (!$ticketId || !$body) { http_response_code(400); echo json_encode(['error' => 'Tiketti ja viesti vaaditaan']); break; }
|
|
|
|
$ticket = _dbFetchOne("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']);
|
|
$to = !empty($input['to']) ? trim($input['to']) : ($ticket['from_email'] ?? '');
|
|
$cc = !empty($input['cc']) ? trim($input['cc']) : '';
|
|
$bcc = !empty($input['bcc']) ? trim($input['bcc']) : '';
|
|
|
|
// Muunna uusi viesti HTML:ksi (säilytä rivinvaihdot ja välilyönnit)
|
|
$escaped = htmlspecialchars($body, ENT_QUOTES, 'UTF-8');
|
|
$escaped = preg_replace('/ /', ' ', $escaped); // säilytä peräkkäiset välilyönnit
|
|
$newMsgHtml = nl2br($escaped);
|
|
|
|
// Rakenna viestiketju (quoted thread) vastaukseen
|
|
$messages = _dbFetchAll(
|
|
"SELECT from_name, from_email, timestamp, body, type FROM ticket_messages WHERE ticket_id = ? ORDER BY timestamp DESC",
|
|
[$ticketId]
|
|
);
|
|
$quotedThread = '';
|
|
foreach ($messages as $msg) {
|
|
// Älä sisällytä sisäisiä muistiinpanoja sähköpostiin
|
|
if ($msg['type'] === 'note') continue;
|
|
$sender = $msg['from_name'] ?: $msg['from_email'];
|
|
$date = date('d.m.Y H:i', strtotime($msg['timestamp']));
|
|
$msgBody = $msg['body'] ?: '';
|
|
// Jos viesti on plain text (ei HTML-tageja), muunna HTML:ksi
|
|
if ($msgBody !== '' && strip_tags($msgBody) === $msgBody) {
|
|
$esc = htmlspecialchars($msgBody, ENT_QUOTES, 'UTF-8');
|
|
$esc = preg_replace('/ /', ' ', $esc);
|
|
$msgBody = nl2br($esc);
|
|
}
|
|
$quotedThread .= '<br><div style="padding-left:0.5em;border-left:2px solid #ccc;color:#555;">'
|
|
. '<small><strong>' . htmlspecialchars($sender) . '</strong> — ' . $date . '</small><br>'
|
|
. '<div>' . $msgBody . '</div>'
|
|
. '</div>';
|
|
}
|
|
|
|
// Yhdistä: uusi viesti (HTML) + viestiketju
|
|
$fullBody = $newMsgHtml . $quotedThread;
|
|
|
|
// Liitetiedostot
|
|
$attachments = $input['attachments'] ?? [];
|
|
|
|
$result = $z->createArticle(
|
|
(int)$ticket['zammad_ticket_id'],
|
|
$fullBody,
|
|
$to,
|
|
$ticket['subject'] ?? '',
|
|
'email',
|
|
$cc,
|
|
$bcc,
|
|
$attachments
|
|
);
|
|
|
|
// Päivitä tila: uusi → käsittelyssä (sekä intra että Zammad)
|
|
if ($ticket['status'] === 'uusi') {
|
|
_dbExecute(
|
|
"UPDATE tickets SET status = 'kasittelyssa' WHERE id = ? AND company_id = ?",
|
|
[$ticketId, $companyId]
|
|
);
|
|
// Päivitä Zammadiin: state → open
|
|
try {
|
|
$z->updateTicket((int)$ticket['zammad_ticket_id'], ['state' => 'open']);
|
|
} catch (\Throwable $e) {
|
|
// Ei estä vastauksen tallennusta — logita virhe
|
|
error_log("Zammad state update failed: " . $e->getMessage());
|
|
}
|
|
}
|
|
|
|
// Tallenna To/CC/BCC tiketille pysyvästi
|
|
_dbExecute(
|
|
"UPDATE tickets SET to_email = ?, cc = ?, bcc = ? WHERE id = ? AND company_id = ?",
|
|
[$to, $cc, $bcc, $ticketId, $companyId]
|
|
);
|
|
|
|
// Tallenna myös paikalliseen tietokantaan (vain uusi viesti, ei ketjua)
|
|
$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;
|
|
|
|
// ==================== IREDMAIL HALLINTA ====================
|
|
|
|
case 'iredmail_test':
|
|
$companyId = requireCompany();
|
|
requireSuperAdmin();
|
|
if ($method !== 'POST') break;
|
|
try {
|
|
$client = getIRedMailClient($companyId);
|
|
$result = $client->testConnection();
|
|
echo json_encode($result);
|
|
} catch (\Throwable $e) {
|
|
http_response_code(400);
|
|
echo json_encode(['error' => $e->getMessage()]);
|
|
}
|
|
break;
|
|
|
|
case 'iredmail_domains':
|
|
$companyId = requireCompany();
|
|
requireSuperAdmin();
|
|
try {
|
|
$client = getIRedMailClient($companyId);
|
|
echo json_encode($client->getDomains());
|
|
} catch (\Throwable $e) {
|
|
http_response_code(500);
|
|
echo json_encode(['error' => $e->getMessage()]);
|
|
}
|
|
break;
|
|
|
|
case 'iredmail_domain_create':
|
|
$companyId = requireCompany();
|
|
requireSuperAdmin();
|
|
if ($method !== 'POST') break;
|
|
$input = json_decode(file_get_contents('php://input'), true);
|
|
$domain = trim($input['domain'] ?? '');
|
|
if (!$domain) { http_response_code(400); echo json_encode(['error' => 'Domain puuttuu']); break; }
|
|
try {
|
|
$client = getIRedMailClient($companyId);
|
|
$opts = [];
|
|
if (isset($input['quota'])) $opts['quota'] = intval($input['quota']);
|
|
$client->createDomain($domain, $opts);
|
|
echo json_encode(['ok' => true]);
|
|
} catch (\Throwable $e) {
|
|
http_response_code(500);
|
|
echo json_encode(['error' => $e->getMessage()]);
|
|
}
|
|
break;
|
|
|
|
case 'iredmail_domain_delete':
|
|
$companyId = requireCompany();
|
|
requireSuperAdmin();
|
|
if ($method !== 'POST') break;
|
|
$input = json_decode(file_get_contents('php://input'), true);
|
|
$domain = trim($input['domain'] ?? '');
|
|
if (!$domain) { http_response_code(400); echo json_encode(['error' => 'Domain puuttuu']); break; }
|
|
try {
|
|
$client = getIRedMailClient($companyId);
|
|
$client->deleteDomain($domain);
|
|
echo json_encode(['ok' => true]);
|
|
} catch (\Throwable $e) {
|
|
http_response_code(500);
|
|
echo json_encode(['error' => $e->getMessage()]);
|
|
}
|
|
break;
|
|
|
|
case 'iredmail_users':
|
|
$companyId = requireCompany();
|
|
requireSuperAdmin();
|
|
$domain = trim($_GET['domain'] ?? '');
|
|
if (!$domain) { http_response_code(400); echo json_encode(['error' => 'Domain puuttuu']); break; }
|
|
try {
|
|
$client = getIRedMailClient($companyId);
|
|
echo json_encode($client->getUsers($domain));
|
|
} catch (\Throwable $e) {
|
|
http_response_code(500);
|
|
echo json_encode(['error' => $e->getMessage()]);
|
|
}
|
|
break;
|
|
|
|
case 'iredmail_user_create':
|
|
$companyId = requireCompany();
|
|
requireSuperAdmin();
|
|
if ($method !== 'POST') break;
|
|
$input = json_decode(file_get_contents('php://input'), true);
|
|
$email = trim($input['email'] ?? '');
|
|
$password = $input['password'] ?? '';
|
|
if (!$email || !$password) { http_response_code(400); echo json_encode(['error' => 'Sähköposti ja salasana vaaditaan']); break; }
|
|
try {
|
|
$client = getIRedMailClient($companyId);
|
|
$opts = [];
|
|
if (!empty($input['cn'])) $opts['cn'] = $input['cn'];
|
|
if (isset($input['mailQuota'])) $opts['mailQuota'] = intval($input['mailQuota']);
|
|
$client->createUser($email, $password, $opts);
|
|
echo json_encode(['ok' => true]);
|
|
} catch (\Throwable $e) {
|
|
http_response_code(500);
|
|
echo json_encode(['error' => $e->getMessage()]);
|
|
}
|
|
break;
|
|
|
|
case 'iredmail_user_update':
|
|
$companyId = requireCompany();
|
|
requireSuperAdmin();
|
|
if ($method !== 'POST') break;
|
|
$input = json_decode(file_get_contents('php://input'), true);
|
|
$email = trim($input['email'] ?? '');
|
|
if (!$email) { http_response_code(400); echo json_encode(['error' => 'Sähköposti puuttuu']); break; }
|
|
try {
|
|
$client = getIRedMailClient($companyId);
|
|
$opts = [];
|
|
if (!empty($input['password'])) $opts['password'] = $input['password'];
|
|
if (!empty($input['cn'])) $opts['cn'] = $input['cn'];
|
|
if (isset($input['mailQuota'])) $opts['mailQuota'] = intval($input['mailQuota']);
|
|
if (isset($input['accountStatus'])) $opts['accountStatus'] = $input['accountStatus'];
|
|
$client->updateUser($email, $opts);
|
|
echo json_encode(['ok' => true]);
|
|
} catch (\Throwable $e) {
|
|
http_response_code(500);
|
|
echo json_encode(['error' => $e->getMessage()]);
|
|
}
|
|
break;
|
|
|
|
case 'iredmail_user_delete':
|
|
$companyId = requireCompany();
|
|
requireSuperAdmin();
|
|
if ($method !== 'POST') break;
|
|
$input = json_decode(file_get_contents('php://input'), true);
|
|
$email = trim($input['email'] ?? '');
|
|
if (!$email) { http_response_code(400); echo json_encode(['error' => 'Sähköposti puuttuu']); break; }
|
|
try {
|
|
$client = getIRedMailClient($companyId);
|
|
$client->deleteUser($email);
|
|
echo json_encode(['ok' => true]);
|
|
} catch (\Throwable $e) {
|
|
http_response_code(500);
|
|
echo json_encode(['error' => $e->getMessage()]);
|
|
}
|
|
break;
|
|
|
|
case 'iredmail_aliases':
|
|
$companyId = requireCompany();
|
|
requireSuperAdmin();
|
|
$domain = trim($_GET['domain'] ?? '');
|
|
if (!$domain) { http_response_code(400); echo json_encode(['error' => 'Domain puuttuu']); break; }
|
|
try {
|
|
$client = getIRedMailClient($companyId);
|
|
echo json_encode($client->getAliases($domain));
|
|
} catch (\Throwable $e) {
|
|
http_response_code(500);
|
|
echo json_encode(['error' => $e->getMessage()]);
|
|
}
|
|
break;
|
|
|
|
case 'iredmail_alias_create':
|
|
$companyId = requireCompany();
|
|
requireSuperAdmin();
|
|
if ($method !== 'POST') break;
|
|
$input = json_decode(file_get_contents('php://input'), true);
|
|
$alias = trim($input['alias'] ?? '');
|
|
$members = trim($input['members'] ?? '');
|
|
if (!$alias) { http_response_code(400); echo json_encode(['error' => 'Alias puuttuu']); break; }
|
|
try {
|
|
$client = getIRedMailClient($companyId);
|
|
$client->createAlias($alias, $members);
|
|
echo json_encode(['ok' => true]);
|
|
} catch (\Throwable $e) {
|
|
http_response_code(500);
|
|
echo json_encode(['error' => $e->getMessage()]);
|
|
}
|
|
break;
|
|
|
|
case 'iredmail_alias_delete':
|
|
$companyId = requireCompany();
|
|
requireSuperAdmin();
|
|
if ($method !== 'POST') break;
|
|
$input = json_decode(file_get_contents('php://input'), true);
|
|
$alias = trim($input['alias'] ?? '');
|
|
if (!$alias) { http_response_code(400); echo json_encode(['error' => 'Alias puuttuu']); break; }
|
|
try {
|
|
$client = getIRedMailClient($companyId);
|
|
$client->deleteAlias($alias);
|
|
echo json_encode(['ok' => true]);
|
|
} 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']);
|
|
break;
|
|
}
|