From c34b5a2c26027ee7a7c6c3e08ddadb1c0b166b6f Mon Sep 17 00:00:00 2001 From: Jukka Lampikoski Date: Tue, 10 Mar 2026 11:04:24 +0200 Subject: [PATCH] Add multi-tenant support with per-company data isolation Implement full multi-company architecture: - Per-company directory structure (data/companies/{id}/) - Automatic migration from single-tenant to multi-tenant - Company management admin tab (create, edit, delete companies) - Per-company IMAP mailbox configuration (multiple mailboxes per company) - User access control per company (companies array on users) - Company switcher in header (shown when user has access to >1 company) - Session-based company context with check_auth fallback for old sessions - Ticket list shows mailbox name instead of sender - IMAP settings moved from global config to company-specific config - All data endpoints protected with requireCompany() guard Co-Authored-By: Claude Opus 4.6 --- api.php | 834 ++++++++++++++++++++++------ data/companies.json | 8 + data/companies/cuitunet/config.json | 4 + index.html | 143 ++++- script.js | 297 +++++++++- style.css | 40 ++ 6 files changed, 1123 insertions(+), 203 deletions(-) create mode 100644 data/companies.json create mode 100644 data/companies/cuitunet/config.json diff --git a/api.php b/api.php index 3a842bc..b68f1ca 100644 --- a/api.php +++ b/api.php @@ -10,28 +10,27 @@ header('Content-Type: application/json'); header('X-Content-Type-Options: nosniff'); define('DATA_DIR', __DIR__ . '/data'); -define('DATA_FILE', DATA_DIR . '/customers.json'); define('USERS_FILE', DATA_DIR . '/users.json'); -define('CHANGELOG_FILE', DATA_DIR . '/changelog.json'); -define('ARCHIVE_FILE', DATA_DIR . '/archive.json'); -define('LEADS_FILE', DATA_DIR . '/leads.json'); define('TOKENS_FILE', DATA_DIR . '/reset_tokens.json'); define('RATE_FILE', DATA_DIR . '/login_attempts.json'); define('CONFIG_FILE', DATA_DIR . '/config.json'); -define('TICKETS_FILE', DATA_DIR . '/tickets.json'); +define('COMPANIES_FILE', DATA_DIR . '/companies.json'); define('SITE_URL', 'https://intra.cuitunet.fi'); -// Sähköpostiasetukset +// Sähköpostiasetukset (fallback) define('MAIL_FROM', 'sivusto@cuitunet.fi'); define('MAIL_FROM_NAME', 'CuituNet Intra'); -// Varmista data-kansio ja tiedostot +// Varmista data-kansio ja globaalit tiedostot if (!file_exists(DATA_DIR)) mkdir(DATA_DIR, 0755, true); -foreach ([DATA_FILE, USERS_FILE, CHANGELOG_FILE, ARCHIVE_FILE, LEADS_FILE, TOKENS_FILE, RATE_FILE, TICKETS_FILE] as $f) { +foreach ([USERS_FILE, TOKENS_FILE, RATE_FILE] as $f) { if (!file_exists($f)) file_put_contents($f, '[]'); } +if (!file_exists(CONFIG_FILE)) file_put_contents(CONFIG_FILE, '{}'); +if (!file_exists(COMPANIES_FILE)) file_put_contents(COMPANIES_FILE, '[]'); initUsers(); +runMigration(); $method = $_SERVER['REQUEST_METHOD']; $action = $_GET['action'] ?? ''; @@ -67,6 +66,151 @@ function generateToken(): string { return bin2hex(random_bytes(32)); } +// ==================== MULTI-COMPANY ==================== + +function loadCompanies(): array { + if (!file_exists(COMPANIES_FILE)) return []; + return json_decode(file_get_contents(COMPANIES_FILE), true) ?: []; +} + +function saveCompanies(array $companies): void { + file_put_contents(COMPANIES_FILE, json_encode($companies, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); +} + +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'] ?? []; + if (!in_array($companyId, $userCompanies)) { + http_response_code(403); + echo json_encode(['error' => 'Ei oikeutta tähän yritykseen']); + exit; + } + return $companyId; +} + +function companyFile(string $filename): string { + return getCompanyDir() . '/' . $filename; +} + +function loadCompanyConfig(): array { + $file = companyFile('config.json'); + if (!file_exists($file)) return ['mailboxes' => [], 'ticket_rules' => []]; + return json_decode(file_get_contents($file), true) ?: ['mailboxes' => [], 'ticket_rules' => []]; +} + +function saveCompanyConfig(array $config): void { + file_put_contents(companyFile('config.json'), json_encode($config, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); +} + +function runMigration(): void { + // Tarkista onko migraatio jo tehty + $companiesDir = DATA_DIR . '/companies'; + if (file_exists($companiesDir) && is_dir($companiesDir)) return; + + // Tarkista onko vanha data olemassa (pre-multitenant) + $oldCustomers = DATA_DIR . '/customers.json'; + if (!file_exists($oldCustomers)) return; + + // Luo yritykshakemisto + mkdir($companiesDir, 0755, true); + $cuitunetDir = $companiesDir . '/cuitunet'; + mkdir($cuitunetDir, 0755, true); + + // Luo companies.json + $companies = [[ + 'id' => 'cuitunet', + 'nimi' => 'CuituNet', + 'luotu' => date('Y-m-d H:i:s'), + 'aktiivinen' => true, + ]]; + file_put_contents(COMPANIES_FILE, json_encode($companies, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); + + // Siirrä datatiedostot yrityksen alle + $filesToMove = ['customers.json', 'leads.json', 'archive.json', 'tickets.json', 'changelog.json']; + foreach ($filesToMove as $f) { + $src = DATA_DIR . '/' . $f; + if (file_exists($src)) { + copy($src, $cuitunetDir . '/' . $f); + unlink($src); + } + } + + // Siirrä tiedostokansio + $oldFiles = DATA_DIR . '/files'; + if (is_dir($oldFiles)) { + rename($oldFiles, $cuitunetDir . '/files'); + } + + // Siirrä backups-kansio + $oldBackups = DATA_DIR . '/backups'; + if (is_dir($oldBackups)) { + rename($oldBackups, $cuitunetDir . '/backups'); + } + + // Luo yrityksen config IMAP-asetuksista + $globalConfig = json_decode(file_get_contents(CONFIG_FILE), true) ?: []; + $companyConfig = ['mailboxes' => [], 'ticket_rules' => $globalConfig['ticket_rules'] ?? []]; + + if (!empty($globalConfig['imap_host'])) { + $companyConfig['mailboxes'][] = [ + 'id' => generateId(), + 'nimi' => 'Cuitunet-asiakaspalvelu', + 'imap_host' => $globalConfig['imap_host'], + 'imap_port' => intval($globalConfig['imap_port'] ?? 993), + 'imap_user' => $globalConfig['imap_user'] ?? '', + 'imap_password' => $globalConfig['imap_password'] ?? '', + 'imap_encryption' => $globalConfig['imap_encryption'] ?? 'ssl', + 'smtp_from_email' => $globalConfig['imap_user'] ?? '', + 'smtp_from_name' => 'CuituNet Asiakaspalvelu', + 'aktiivinen' => true, + ]; + } + + file_put_contents($cuitunetDir . '/config.json', json_encode($companyConfig, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); + + // Päivitä tiketteihin mailbox_id + $ticketsFile = $cuitunetDir . '/tickets.json'; + if (file_exists($ticketsFile)) { + $tickets = json_decode(file_get_contents($ticketsFile), true) ?: []; + $mbId = !empty($companyConfig['mailboxes']) ? $companyConfig['mailboxes'][0]['id'] : ''; + foreach ($tickets as &$t) { + if (!isset($t['mailbox_id'])) $t['mailbox_id'] = $mbId; + } + unset($t); + file_put_contents($ticketsFile, json_encode($tickets, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); + } + + // Lisää companies-array kaikkiin käyttäjiin + $users = json_decode(file_get_contents(USERS_FILE), true) ?: []; + foreach ($users as &$u) { + if (!isset($u['companies'])) $u['companies'] = ['cuitunet']; + } + unset($u); + file_put_contents(USERS_FILE, json_encode($users, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); + + // Siivoa globaali config + unset($globalConfig['imap_host'], $globalConfig['imap_port'], $globalConfig['imap_user'], + $globalConfig['imap_password'], $globalConfig['imap_encryption'], $globalConfig['ticket_rules']); + file_put_contents(CONFIG_FILE, json_encode($globalConfig, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); +} + // ==================== RATE LIMITING ==================== function checkRateLimit(string $ip): bool { @@ -487,11 +631,13 @@ class ImapClient { // ==================== TICKETS ==================== function loadTickets(): array { - return json_decode(file_get_contents(TICKETS_FILE), true) ?: []; + $file = companyFile('tickets.json'); + if (!file_exists($file)) { file_put_contents($file, '[]'); return []; } + return json_decode(file_get_contents($file), true) ?: []; } function saveTickets(array $tickets): void { - file_put_contents(TICKETS_FILE, json_encode($tickets, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); + file_put_contents(companyFile('tickets.json'), json_encode($tickets, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); } function findTicketByMessageId(array $tickets, string $messageId): ?int { @@ -523,10 +669,9 @@ function findTicketByReferences(array $tickets, string $inReplyTo, string $refer return null; } -function sendTicketMail(string $to, string $subject, string $body, string $inReplyTo = '', string $references = ''): bool { - $config = loadConfig(); - $fromEmail = $config['imap_user'] ?? MAIL_FROM; - $fromName = 'CuituNet Asiakaspalvelu'; +function sendTicketMail(string $to, string $subject, string $body, string $inReplyTo = '', string $references = '', ?array $mailbox = null): bool { + $fromEmail = $mailbox['smtp_from_email'] ?? $mailbox['imap_user'] ?? MAIL_FROM; + $fromName = $mailbox['smtp_from_name'] ?? $mailbox['nimi'] ?? 'Asiakaspalvelu'; $headers = "MIME-Version: 1.0\r\n"; $headers .= "Content-Type: text/plain; charset=UTF-8\r\n"; @@ -551,6 +696,7 @@ function initUsers(): void { 'nimi' => 'Ylläpitäjä', 'email' => '', 'role' => 'admin', + 'companies' => [], 'luotu' => date('Y-m-d H:i:s'), ]; file_put_contents(USERS_FILE, json_encode($users, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); @@ -601,7 +747,11 @@ function removeToken(string $token): void { // ==================== CHANGELOG ==================== function addLog(string $action, string $customerId = '', string $customerName = '', string $details = ''): void { - $log = json_decode(file_get_contents(CHANGELOG_FILE), true) ?: []; + // Jos company-kontekstia ei ole (esim. globaalit asetukset), ohitetaan + if (empty($_SESSION['company_id'])) return; + $file = companyFile('changelog.json'); + if (!file_exists($file)) file_put_contents($file, '[]'); + $log = json_decode(file_get_contents($file), true) ?: []; array_unshift($log, [ 'id' => generateId(), 'timestamp' => date('Y-m-d H:i:s'), @@ -612,13 +762,15 @@ function addLog(string $action, string $customerId = '', string $customerName = 'details' => $details, ]); if (count($log) > 500) $log = array_slice($log, 0, 500); - file_put_contents(CHANGELOG_FILE, json_encode($log, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); + file_put_contents($file, json_encode($log, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); } // ==================== CUSTOMERS ==================== function loadCustomers(): array { - $data = file_get_contents(DATA_FILE); + $file = companyFile('customers.json'); + if (!file_exists($file)) { file_put_contents($file, '[]'); return []; } + $data = file_get_contents($file); $customers = json_decode($data, true) ?: []; $migrated = false; foreach ($customers as &$c) { @@ -638,31 +790,34 @@ function loadCustomers(): array { } unset($c); if ($migrated) { - file_put_contents(DATA_FILE, json_encode($customers, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); + file_put_contents($file, json_encode($customers, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); } return $customers; } function saveCustomers(array $customers): void { - if (file_exists(DATA_FILE) && filesize(DATA_FILE) > 2) { - $backupDir = DATA_DIR . '/backups'; + $file = companyFile('customers.json'); + if (file_exists($file) && filesize($file) > 2) { + $backupDir = getCompanyDir() . '/backups'; if (!file_exists($backupDir)) mkdir($backupDir, 0755, true); - copy(DATA_FILE, $backupDir . '/customers_' . date('Y-m-d_His') . '.json'); + copy($file, $backupDir . '/customers_' . date('Y-m-d_His') . '.json'); $backups = glob($backupDir . '/customers_*.json'); if (count($backups) > 30) { sort($backups); array_map('unlink', array_slice($backups, 0, count($backups) - 30)); } } - file_put_contents(DATA_FILE, json_encode($customers, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); + file_put_contents($file, json_encode($customers, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); } function loadArchive(): array { - return json_decode(file_get_contents(ARCHIVE_FILE), true) ?: []; + $file = companyFile('archive.json'); + if (!file_exists($file)) { file_put_contents($file, '[]'); return []; } + return json_decode(file_get_contents($file), true) ?: []; } function saveArchive(array $archive): void { - file_put_contents(ARCHIVE_FILE, json_encode($archive, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); + file_put_contents(companyFile('archive.json'), json_encode($archive, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); } function parseLiittymat(array $input): array { @@ -721,21 +876,25 @@ switch ($action) { break; } - $customers = loadCustomers(); + // Hae kaikista yrityksistä + $allCompanies = loadCompanies(); $found = false; - foreach ($customers as $c) { - foreach ($c['liittymat'] ?? [] as $l) { - $addr = normalizeAddress($l['asennusosoite'] ?? ''); - $zip = trim($l['postinumero'] ?? ''); - $city = strtolower(trim($l['kaupunki'] ?? '')); - - // Kaikki kolme pitää mätsätä: osoite, postinumero, kaupunki - if ($zip === $queryPostinumero && $city === $queryKaupunki) { - // Osoite-match: tarkka sisältö-match - if (!empty($addr) && !empty($queryOsoite)) { - if (strpos($addr, $queryOsoite) !== false || strpos($queryOsoite, $addr) !== false) { - $found = true; - break 2; + foreach ($allCompanies as $comp) { + $compDir = DATA_DIR . '/companies/' . $comp['id']; + $custFile = $compDir . '/customers.json'; + if (!file_exists($custFile)) continue; + $customers = json_decode(file_get_contents($custFile), true) ?: []; + 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 3; + } } } } @@ -762,12 +921,6 @@ switch ($action) { $origins = array_filter(array_map('trim', explode("\n", $input['cors_origins']))); $config['cors_origins'] = array_values($origins); } - // IMAP-asetukset - if (isset($input['imap_host'])) $config['imap_host'] = trim($input['imap_host']); - if (isset($input['imap_port'])) $config['imap_port'] = intval($input['imap_port']); - if (isset($input['imap_user'])) $config['imap_user'] = trim($input['imap_user']); - if (isset($input['imap_password'])) $config['imap_password'] = $input['imap_password']; - if (isset($input['imap_encryption'])) $config['imap_encryption'] = trim($input['imap_encryption']); saveConfig($config); addLog('config_update', '', '', 'Päivitti asetukset'); echo json_encode($config); @@ -822,7 +975,27 @@ switch ($action) { $_SESSION['username'] = $u['username']; $_SESSION['nimi'] = $u['nimi']; $_SESSION['role'] = $u['role']; - echo json_encode(['success' => true, 'username' => $u['username'], 'nimi' => $u['nimi'], 'role' => $u['role']]); + // Multi-company: aseta käyttäjän yritykset sessioon + $userCompanies = $u['companies'] ?? []; + $_SESSION['companies'] = $userCompanies; + // Valitse ensimmäinen yritys oletukseksi + $_SESSION['company_id'] = !empty($userCompanies) ? $userCompanies[0] : ''; + // Hae yritysten nimet + $allCompanies = loadCompanies(); + $companyList = []; + foreach ($allCompanies as $comp) { + if (in_array($comp['id'], $userCompanies)) { + $companyList[] = ['id' => $comp['id'], 'nimi' => $comp['nimi']]; + } + } + echo json_encode([ + 'success' => true, + 'username' => $u['username'], + 'nimi' => $u['nimi'], + 'role' => $u['role'], + 'companies' => $companyList, + 'company_id' => $_SESSION['company_id'], + ]); $found = true; break; } @@ -841,11 +1014,35 @@ switch ($action) { case 'check_auth': if (isset($_SESSION['user_id'])) { + // Fallback: jos session ei sisällä company-dataa (vanha sessio ennen migraatiota) + if (empty($_SESSION['companies'])) { + $users = loadUsers(); + foreach ($users as $u) { + if ($u['id'] === $_SESSION['user_id']) { + $_SESSION['companies'] = $u['companies'] ?? []; + if (!empty($u['companies'])) { + $_SESSION['company_id'] = $u['companies'][0]; + } + break; + } + } + } + // Hae yritysten nimet + $userCompanyIds = $_SESSION['companies'] ?? []; + $allCompanies = loadCompanies(); + $companyList = []; + foreach ($allCompanies as $comp) { + if (in_array($comp['id'], $userCompanyIds)) { + $companyList[] = ['id' => $comp['id'], 'nimi' => $comp['nimi']]; + } + } echo json_encode([ 'authenticated' => true, 'username' => $_SESSION['username'], 'nimi' => $_SESSION['nimi'], 'role' => $_SESSION['role'], + 'companies' => $companyList, + 'company_id' => $_SESSION['company_id'] ?? '', ]); } else { echo json_encode(['authenticated' => false]); @@ -959,6 +1156,11 @@ switch ($action) { break 2; } } + $companies = $input['companies'] ?? []; + // Validoi yritys-IDt + $allCompanies = loadCompanies(); + $validIds = array_column($allCompanies, 'id'); + $companies = array_values(array_filter($companies, fn($c) => in_array($c, $validIds))); $newUser = [ 'id' => generateId(), 'username' => $username, @@ -966,6 +1168,7 @@ switch ($action) { 'nimi' => $nimi ?: $username, 'email' => $email, 'role' => $role, + 'companies' => $companies, 'luotu' => date('Y-m-d H:i:s'), ]; $users[] = $newUser; @@ -987,6 +1190,11 @@ switch ($action) { if (isset($input['nimi'])) $u['nimi'] = trim($input['nimi']); if (isset($input['email'])) $u['email'] = trim($input['email']); if (isset($input['role'])) $u['role'] = $input['role'] === 'admin' ? 'admin' : 'user'; + if (isset($input['companies'])) { + $allCompanies = loadCompanies(); + $validIds = array_column($allCompanies, 'id'); + $u['companies'] = array_values(array_filter($input['companies'], fn($c) => in_array($c, $validIds))); + } if (!empty($input['password'])) { $u['password_hash'] = password_hash($input['password'], PASSWORD_DEFAULT); } @@ -1031,7 +1239,10 @@ switch ($action) { // ---------- CHANGELOG ---------- case 'changelog': requireAuth(); - $log = json_decode(file_get_contents(CHANGELOG_FILE), true) ?: []; + requireCompany(); + $logFile = companyFile('changelog.json'); + if (!file_exists($logFile)) file_put_contents($logFile, '[]'); + $log = json_decode(file_get_contents($logFile), true) ?: []; $limit = intval($_GET['limit'] ?? 100); echo json_encode(array_slice($log, 0, $limit)); break; @@ -1039,6 +1250,7 @@ switch ($action) { // ---------- CUSTOMERS ---------- case 'customers': requireAuth(); + requireCompany(); if ($method === 'GET') { echo json_encode(loadCustomers()); } @@ -1046,6 +1258,7 @@ switch ($action) { case 'customer': requireAuth(); + requireCompany(); if ($method === 'POST') { $input = json_decode(file_get_contents('php://input'), true); $customers = loadCustomers(); @@ -1080,6 +1293,7 @@ switch ($action) { case 'customer_update': requireAuth(); + requireCompany(); if ($method !== 'POST') break; $input = json_decode(file_get_contents('php://input'), true); $id = $input['id'] ?? ''; @@ -1119,6 +1333,7 @@ switch ($action) { case 'customer_delete': requireAuth(); + requireCompany(); if ($method !== 'POST') break; $input = json_decode(file_get_contents('php://input'), true); $id = $input['id'] ?? ''; @@ -1147,11 +1362,13 @@ switch ($action) { // ---------- ARCHIVE ---------- case 'archived_customers': requireAuth(); + requireCompany(); echo json_encode(loadArchive()); break; case 'customer_restore': requireAuth(); + requireCompany(); if ($method !== 'POST') break; $input = json_decode(file_get_contents('php://input'), true); $id = $input['id'] ?? ''; @@ -1178,6 +1395,7 @@ switch ($action) { case 'customer_permanent_delete': requireAdmin(); + requireCompany(); if ($method !== 'POST') break; $input = json_decode(file_get_contents('php://input'), true); $id = $input['id'] ?? ''; @@ -1188,7 +1406,7 @@ switch ($action) { } $archive = array_values(array_filter($archive, fn($c) => $c['id'] !== $id)); saveArchive($archive); - $filesDir = DATA_DIR . '/files/' . $id; + $filesDir = getCompanyDir() . '/files/' . $id; if (is_dir($filesDir)) { array_map('unlink', glob($filesDir . '/*')); rmdir($filesDir); @@ -1200,12 +1418,16 @@ switch ($action) { // ---------- LEADS ---------- case 'leads': requireAuth(); - $leads = json_decode(file_get_contents(LEADS_FILE), true) ?: []; + requireCompany(); + $leadsFile = companyFile('leads.json'); + if (!file_exists($leadsFile)) file_put_contents($leadsFile, '[]'); + $leads = json_decode(file_get_contents($leadsFile), true) ?: []; echo json_encode($leads); break; case 'lead_create': requireAuth(); + requireCompany(); if ($method !== 'POST') break; $input = json_decode(file_get_contents('php://input'), true); $lead = [ @@ -1226,19 +1448,24 @@ switch ($action) { echo json_encode(['error' => 'Yrityksen nimi vaaditaan']); break; } - $leads = json_decode(file_get_contents(LEADS_FILE), true) ?: []; + $leadsFile = companyFile('leads.json'); + if (!file_exists($leadsFile)) file_put_contents($leadsFile, '[]'); + $leads = json_decode(file_get_contents($leadsFile), true) ?: []; $leads[] = $lead; - file_put_contents(LEADS_FILE, json_encode($leads, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); + file_put_contents($leadsFile, json_encode($leads, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); addLog('lead_create', $lead['id'], $lead['yritys'], 'Lisäsi liidin'); echo json_encode($lead); break; case 'lead_update': requireAuth(); + requireCompany(); if ($method !== 'POST') break; $input = json_decode(file_get_contents('php://input'), true); $id = $input['id'] ?? ''; - $leads = json_decode(file_get_contents(LEADS_FILE), true) ?: []; + $leadsFile = companyFile('leads.json'); + if (!file_exists($leadsFile)) file_put_contents($leadsFile, '[]'); + $leads = json_decode(file_get_contents($leadsFile), true) ?: []; $found = false; foreach ($leads as &$l) { if ($l['id'] === $id) { @@ -1260,31 +1487,37 @@ switch ($action) { echo json_encode(['error' => 'Liidiä ei löydy']); break; } - file_put_contents(LEADS_FILE, json_encode($leads, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); + file_put_contents($leadsFile, json_encode($leads, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); break; case 'lead_delete': requireAuth(); + requireCompany(); if ($method !== 'POST') break; $input = json_decode(file_get_contents('php://input'), true); $id = $input['id'] ?? ''; - $leads = json_decode(file_get_contents(LEADS_FILE), true) ?: []; + $leadsFile = companyFile('leads.json'); + if (!file_exists($leadsFile)) file_put_contents($leadsFile, '[]'); + $leads = json_decode(file_get_contents($leadsFile), true) ?: []; $deleted = null; foreach ($leads as $l) { if ($l['id'] === $id) { $deleted = $l; break; } } $leads = array_values(array_filter($leads, fn($l) => $l['id'] !== $id)); - file_put_contents(LEADS_FILE, json_encode($leads, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); + file_put_contents($leadsFile, json_encode($leads, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); if ($deleted) addLog('lead_delete', $id, $deleted['yritys'] ?? '', 'Poisti liidin'); echo json_encode(['success' => true]); break; case 'lead_to_customer': requireAuth(); + requireCompany(); if ($method !== 'POST') break; $input = json_decode(file_get_contents('php://input'), true); $id = $input['id'] ?? ''; - $leads = json_decode(file_get_contents(LEADS_FILE), true) ?: []; + $leadsFile = companyFile('leads.json'); + if (!file_exists($leadsFile)) file_put_contents($leadsFile, '[]'); + $leads = json_decode(file_get_contents($leadsFile), true) ?: []; $lead = null; foreach ($leads as $l) { if ($l['id'] === $id) { $lead = $l; break; } @@ -1317,7 +1550,7 @@ switch ($action) { saveCustomers($customers); // Poista liidi $leads = array_values(array_filter($leads, fn($l) => $l['id'] !== $id)); - file_put_contents(LEADS_FILE, json_encode($leads, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); + file_put_contents($leadsFile, json_encode($leads, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); addLog('lead_to_customer', $customer['id'], $customer['yritys'], 'Muutti liidin asiakkaaksi'); echo json_encode($customer); break; @@ -1325,6 +1558,7 @@ switch ($action) { // ---------- FILES ---------- case 'file_upload': requireAuth(); + requireCompany(); if ($method !== 'POST') break; $customerId = $_POST['customer_id'] ?? ''; if (!$customerId || !preg_match('/^[a-f0-9]+$/', $customerId)) { @@ -1348,7 +1582,7 @@ switch ($action) { echo json_encode(['error' => 'Tiedosto on liian suuri (max 20 MB)']); break; } - $uploadDir = DATA_DIR . '/files/' . $customerId; + $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; @@ -1368,12 +1602,13 @@ switch ($action) { case 'file_list': requireAuth(); + requireCompany(); $customerId = $_GET['customer_id'] ?? ''; if (!$customerId || !preg_match('/^[a-f0-9]+$/', $customerId)) { echo json_encode([]); break; } - $dir = DATA_DIR . '/files/' . $customerId; + $dir = getCompanyDir() . '/files/' . $customerId; $files = []; if (is_dir($dir)) { foreach (scandir($dir) as $f) { @@ -1388,6 +1623,7 @@ switch ($action) { case 'file_download': requireAuth(); + requireCompany(); $customerId = $_GET['customer_id'] ?? ''; $filename = $_GET['filename'] ?? ''; if (!$customerId || !preg_match('/^[a-f0-9]+$/', $customerId) || !$filename) { @@ -1396,7 +1632,7 @@ switch ($action) { break; } $safeName = basename($filename); - $path = DATA_DIR . '/files/' . $customerId . '/' . $safeName; + $path = getCompanyDir() . '/files/' . $customerId . '/' . $safeName; if (!file_exists($path)) { http_response_code(404); echo json_encode(['error' => 'Tiedostoa ei löydy']); @@ -1410,6 +1646,7 @@ switch ($action) { case 'file_delete': requireAuth(); + requireCompany(); if ($method !== 'POST') break; $input = json_decode(file_get_contents('php://input'), true); $customerId = $input['customer_id'] ?? ''; @@ -1420,7 +1657,7 @@ switch ($action) { break; } $safeName = basename($filename); - $path = DATA_DIR . '/files/' . $customerId . '/' . $safeName; + $path = getCompanyDir() . '/files/' . $customerId . '/' . $safeName; if (file_exists($path)) unlink($path); echo json_encode(['success' => true]); break; @@ -1428,6 +1665,7 @@ switch ($action) { // ---------- TICKETS ---------- case 'tickets': requireAuth(); + requireCompany(); $tickets = loadTickets(); // Palauta ilman viestisisältöjä (lista-näkymä) // Auto-close tarkistus: sulje tiketit joiden auto_close_at on ohitettu @@ -1446,7 +1684,14 @@ switch ($action) { addLog('ticket_auto_close', '', '', "Automaattisulku: $autoCloseCount tikettiä"); } - $list = array_map(function($t) { + // Resolve mailbox names + $companyConf = loadCompanyConfig(); + $mailboxNames = []; + foreach ($companyConf['mailboxes'] ?? [] as $mb) { + $mailboxNames[$mb['id']] = $mb['nimi']; + } + + $list = array_map(function($t) use ($mailboxNames) { $msgCount = count($t['messages'] ?? []); $lastMsg = $msgCount > 0 ? $t['messages'][$msgCount - 1] : null; return [ @@ -1461,6 +1706,8 @@ switch ($action) { 'customer_name' => $t['customer_name'] ?? '', 'tags' => $t['tags'] ?? [], 'auto_close_at' => $t['auto_close_at'] ?? '', + 'mailbox_id' => $t['mailbox_id'] ?? '', + 'mailbox_name' => $mailboxNames[$t['mailbox_id'] ?? ''] ?? '', 'created' => $t['created'], 'updated' => $t['updated'], 'message_count' => $msgCount, @@ -1473,6 +1720,7 @@ switch ($action) { case 'ticket_detail': requireAuth(); + requireCompany(); $id = $_GET['id'] ?? ''; $tickets = loadTickets(); $ticket = null; @@ -1489,27 +1737,22 @@ switch ($action) { case 'ticket_fetch': requireAuth(); + requireCompany(); if ($method !== 'POST') break; - $config = loadConfig(); - if (empty($config['imap_host']) || empty($config['imap_user']) || empty($config['imap_password'])) { + + $companyConf = loadCompanyConfig(); + $mailboxes = array_filter($companyConf['mailboxes'] ?? [], fn($mb) => !empty($mb['aktiivinen'])); + + if (empty($mailboxes)) { http_response_code(400); - echo json_encode(['error' => 'IMAP-asetukset puuttuvat. Aseta ne API-välilehdellä.']); + echo json_encode(['error' => 'Postilaatikoita ei ole määritetty. Lisää ne Yritykset-välilehdellä.']); break; } - $imap = new ImapClient(); - if (!$imap->connect($config)) { - http_response_code(500); - echo json_encode(['error' => 'IMAP-yhteys epäonnistui: ' . $imap->lastError]); - break; - } - - $emails = $imap->fetchMessages(100); - $imap->disconnect(); - $tickets = loadTickets(); $newCount = 0; $threadedCount = 0; + $errors = []; // Collect all existing message IDs for duplicate detection $existingMsgIds = []; @@ -1520,106 +1763,126 @@ switch ($action) { } } - foreach ($emails as $email) { - // Skip duplicates - if (!empty($email['message_id']) && isset($existingMsgIds[$email['message_id']])) { + // 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; } - $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'], - ]; + $emails = $imap->fetchMessages(100); + $imap->disconnect(); - // Try to thread into existing ticket - $ticketIdx = findTicketByReferences($tickets, $email['in_reply_to'], $email['references']); + $rules = $companyConf['ticket_rules'] ?? []; - if ($ticketIdx !== null) { - $tickets[$ticketIdx]['messages'][] = $msg; - $tickets[$ticketIdx]['updated'] = $email['date']; - // If ticket was resolved/closed, reopen it - if (in_array($tickets[$ticketIdx]['status'], ['ratkaistu', 'suljettu'])) { - $tickets[$ticketIdx]['status'] = 'kasittelyssa'; + foreach ($emails as $email) { + if (!empty($email['message_id']) && isset($existingMsgIds[$email['message_id']])) { + continue; } - $threadedCount++; - } else { - // New ticket - $ticket = [ + + $msg = [ 'id' => generateId(), - 'subject' => $email['subject'] ?: '(Ei aihetta)', - 'from_email' => $email['from_email'], + 'type' => 'email_in', + 'from' => $email['from_email'], 'from_name' => $email['from_name'], - 'status' => 'uusi', - 'type' => 'muu', - 'assigned_to' => '', - 'customer_id' => '', - 'customer_name' => '', - 'tags' => [], - 'auto_close_at' => '', - 'created' => $email['date'], - 'updated' => $email['date'], + 'body' => $email['body'], + 'timestamp' => $email['date'], 'message_id' => $email['message_id'], - 'messages' => [$msg], ]; - // Apply auto-rules - $rules = $config['ticket_rules'] ?? []; - 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; - } + $ticketIdx = findTicketByReferences($tickets, $email['in_reply_to'], $email['references']); + + if ($ticketIdx !== null) { + $tickets[$ticketIdx]['messages'][] = $msg; + $tickets[$ticketIdx]['updated'] = $email['date']; + if (in_array($tickets[$ticketIdx]['status'], ['ratkaistu', 'suljettu'])) { + $tickets[$ticketIdx]['status'] = 'kasittelyssa'; } - if (!empty($rule['subject_contains'])) { - $needle = strtolower($rule['subject_contains']); - if (strpos(strtolower($email['subject'] ?? ''), $needle) === false) { - $match = false; - } - } - if ($match) { - if (!empty($rule['set_status'])) $ticket['status'] = $rule['set_status']; - if (!empty($rule['set_type'])) $ticket['type'] = $rule['set_type']; - 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")); + $threadedCount++; + } else { + $ticket = [ + 'id' => generateId(), + 'subject' => $email['subject'] ?: '(Ei aihetta)', + 'from_email' => $email['from_email'], + 'from_name' => $email['from_name'], + 'status' => 'uusi', + 'type' => 'muu', + 'assigned_to' => '', + 'customer_id' => '', + 'customer_name' => '', + 'tags' => [], + 'auto_close_at' => '', + 'mailbox_id' => $mailbox['id'], + 'created' => $email['date'], + 'updated' => $email['date'], + 'message_id' => $email['message_id'], + 'messages' => [$msg], + ]; + + // Apply auto-rules + 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; } } - break; // First matching rule wins + if (!empty($rule['subject_contains'])) { + $needle = strtolower($rule['subject_contains']); + if (strpos(strtolower($email['subject'] ?? ''), $needle) === false) { + $match = false; + } + } + if ($match) { + if (!empty($rule['set_status'])) $ticket['status'] = $rule['set_status']; + if (!empty($rule['set_type'])) $ticket['type'] = $rule['set_type']; + 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; + } } + + $tickets[] = $ticket; + $newCount++; } - $tickets[] = $ticket; - $newCount++; + if ($email['message_id']) $existingMsgIds[$email['message_id']] = true; } - - if ($email['message_id']) $existingMsgIds[$email['message_id']] = true; } - // Sort tickets by updated date (newest first) usort($tickets, function($a, $b) { return strcmp($b['updated'], $a['updated']); }); saveTickets($tickets); addLog('ticket_fetch', '', '', "Haettu sähköpostit: {$newCount} uutta tikettiä, {$threadedCount} ketjutettu"); - echo json_encode(['success' => true, 'new_tickets' => $newCount, 'threaded' => $threadedCount, 'total' => count($tickets)]); + $result = ['success' => true, 'new_tickets' => $newCount, 'threaded' => $threadedCount, 'total' => count($tickets)]; + if (!empty($errors)) $result['errors'] = $errors; + echo json_encode($result); break; case 'ticket_reply': requireAuth(); + requireCompany(); if ($method !== 'POST') break; $input = json_decode(file_get_contents('php://input'), true); $id = $input['id'] ?? ''; @@ -1643,9 +1906,19 @@ switch ($action) { } } - // Send email + // Send email — hae postilaatikon asetukset + $companyConf = loadCompanyConfig(); + $replyMailbox = null; + foreach ($companyConf['mailboxes'] ?? [] as $mb) { + if ($mb['id'] === ($t['mailbox_id'] ?? '')) { $replyMailbox = $mb; break; } + } + // Fallback: käytä ensimmäistä postilaatikkoa + if (!$replyMailbox && !empty($companyConf['mailboxes'])) { + $replyMailbox = $companyConf['mailboxes'][0]; + } + $subject = 'Re: ' . $t['subject']; - $sent = sendTicketMail($t['from_email'], $subject, $body, $lastMsgId, trim($allRefs)); + $sent = sendTicketMail($t['from_email'], $subject, $body, $lastMsgId, trim($allRefs), $replyMailbox); if (!$sent) { http_response_code(500); @@ -1684,6 +1957,7 @@ switch ($action) { case 'ticket_status': requireAuth(); + requireCompany(); if ($method !== 'POST') break; $input = json_decode(file_get_contents('php://input'), true); $id = $input['id'] ?? ''; @@ -1718,6 +1992,7 @@ switch ($action) { case 'ticket_type': requireAuth(); + requireCompany(); if ($method !== 'POST') break; $input = json_decode(file_get_contents('php://input'), true); $id = $input['id'] ?? ''; @@ -1752,6 +2027,7 @@ switch ($action) { case 'ticket_customer': requireAuth(); + requireCompany(); if ($method !== 'POST') break; $input = json_decode(file_get_contents('php://input'), true); $id = $input['id'] ?? ''; @@ -1781,6 +2057,7 @@ switch ($action) { case 'ticket_assign': requireAuth(); + requireCompany(); if ($method !== 'POST') break; $input = json_decode(file_get_contents('php://input'), true); $id = $input['id'] ?? ''; @@ -1808,6 +2085,7 @@ switch ($action) { case 'ticket_note': requireAuth(); + requireCompany(); if ($method !== 'POST') break; $input = json_decode(file_get_contents('php://input'), true); $id = $input['id'] ?? ''; @@ -1849,6 +2127,7 @@ switch ($action) { case 'ticket_delete': requireAuth(); + requireCompany(); if ($method !== 'POST') break; $input = json_decode(file_get_contents('php://input'), true); $id = $input['id'] ?? ''; @@ -1865,6 +2144,7 @@ switch ($action) { case 'ticket_tags': requireAuth(); + requireCompany(); if ($method !== 'POST') break; $input = json_decode(file_get_contents('php://input'), true); $id = $input['id'] ?? ''; @@ -1896,16 +2176,18 @@ switch ($action) { case 'ticket_rules': requireAuth(); - $config = loadConfig(); - echo json_encode($config['ticket_rules'] ?? []); + requireCompany(); + $companyConf = loadCompanyConfig(); + echo json_encode($companyConf['ticket_rules'] ?? []); break; case 'ticket_rule_save': requireAuth(); + requireCompany(); if ($method !== 'POST') break; $input = json_decode(file_get_contents('php://input'), true); - $config = loadConfig(); - $rules = $config['ticket_rules'] ?? []; + $companyConf = loadCompanyConfig(); + $rules = $companyConf['ticket_rules'] ?? []; $rule = [ 'id' => $input['id'] ?? generateId(), @@ -1925,7 +2207,6 @@ switch ($action) { break; } - // Update existing or add new $found = false; foreach ($rules as &$r) { if ($r['id'] === $rule['id']) { @@ -1937,14 +2218,15 @@ switch ($action) { unset($r); if (!$found) $rules[] = $rule; - $config['ticket_rules'] = $rules; - file_put_contents(DATA_DIR . '/config.json', json_encode($config, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); + $companyConf['ticket_rules'] = $rules; + saveCompanyConfig($companyConf); addLog('config_update', '', '', 'Tikettisääntö: ' . $rule['name']); echo json_encode($rule); break; case 'ticket_bulk_status': requireAuth(); + requireCompany(); if ($method !== 'POST') break; $input = json_decode(file_get_contents('php://input'), true); $ids = $input['ids'] ?? []; @@ -1955,7 +2237,7 @@ switch ($action) { echo json_encode(['error' => 'Virheellinen tila']); break; } - $tickets = json_decode(file_get_contents(TICKETS_FILE), true) ?: []; + $tickets = loadTickets(); $changed = 0; foreach ($tickets as &$t) { if (in_array($t['id'], $ids)) { @@ -1965,34 +2247,264 @@ switch ($action) { } } unset($t); - file_put_contents(TICKETS_FILE, json_encode($tickets, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); + saveTickets($tickets); addLog('ticket_status', '', '', "Massapäivitys: $changed tikettiä → $newStatus"); echo json_encode(['success' => true, 'changed' => $changed]); break; case 'ticket_bulk_delete': requireAuth(); + requireCompany(); if ($method !== 'POST') break; $input = json_decode(file_get_contents('php://input'), true); $ids = $input['ids'] ?? []; - $tickets = json_decode(file_get_contents(TICKETS_FILE), true) ?: []; + $tickets = loadTickets(); $before = count($tickets); $tickets = array_values(array_filter($tickets, fn($t) => !in_array($t['id'], $ids))); $deleted = $before - count($tickets); - file_put_contents(TICKETS_FILE, json_encode($tickets, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); + saveTickets($tickets); addLog('ticket_delete', '', '', "Massapoisto: $deleted tikettiä"); echo json_encode(['success' => true, 'deleted' => $deleted]); break; case 'ticket_rule_delete': requireAuth(); + requireCompany(); if ($method !== 'POST') break; $input = json_decode(file_get_contents('php://input'), true); $ruleId = $input['id'] ?? ''; - $config = loadConfig(); - $rules = $config['ticket_rules'] ?? []; - $config['ticket_rules'] = array_values(array_filter($rules, fn($r) => $r['id'] !== $ruleId)); - file_put_contents(DATA_DIR . '/config.json', json_encode($config, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); + $companyConf = loadCompanyConfig(); + $rules = $companyConf['ticket_rules'] ?? []; + $companyConf['ticket_rules'] = array_values(array_filter($rules, fn($r) => $r['id'] !== $ruleId)); + saveCompanyConfig($companyConf); + echo json_encode(['success' => true]); + break; + + // ---------- COMPANY MANAGEMENT ---------- + case 'companies': + requireAuth(); + $userCompanyIds = $_SESSION['companies'] ?? []; + $allCompanies = loadCompanies(); + $result = array_values(array_filter($allCompanies, fn($c) => in_array($c['id'], $userCompanyIds))); + echo json_encode($result); + break; + + case 'companies_all': + requireAdmin(); + echo json_encode(loadCompanies()); + break; + + case 'company_create': + requireAdmin(); + 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 = loadCompanies(); + 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; + } + } + $company = [ + 'id' => $id, + 'nimi' => $nimi, + 'luotu' => date('Y-m-d H:i:s'), + 'aktiivinen' => true, + ]; + $companies[] = $company; + saveCompanies($companies); + // Luo hakemisto ja tyhjät tiedostot + $compDir = DATA_DIR . '/companies/' . $id; + if (!file_exists($compDir)) mkdir($compDir, 0755, true); + file_put_contents($compDir . '/config.json', json_encode(['mailboxes' => [], 'ticket_rules' => []], JSON_PRETTY_PRINT)); + file_put_contents($compDir . '/customers.json', '[]'); + file_put_contents($compDir . '/leads.json', '[]'); + file_put_contents($compDir . '/archive.json', '[]'); + file_put_contents($compDir . '/tickets.json', '[]'); + file_put_contents($compDir . '/changelog.json', '[]'); + // Lisää luoja yrityksen käyttäjäksi + $users = loadUsers(); + foreach ($users as &$u) { + if ($u['id'] === $_SESSION['user_id']) { + $u['companies'] = array_unique(array_merge($u['companies'] ?? [], [$id])); + $_SESSION['companies'] = $u['companies']; + break; + } + } + unset($u); + saveUsers($users); + 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'] ?? ''; + $companies = loadCompanies(); + $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']; + $found = true; + echo json_encode($c); + break; + } + } + unset($c); + if (!$found) { + http_response_code(404); + echo json_encode(['error' => 'Yritystä ei löydy']); + break; + } + saveCompanies($companies); + break; + + case 'company_delete': + requireAdmin(); + 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; + } + $companies = loadCompanies(); + $companies = array_values(array_filter($companies, fn($c) => $c['id'] !== $id)); + saveCompanies($companies); + // Poista yritys käyttäjiltä + $users = loadUsers(); + foreach ($users as &$u) { + $u['companies'] = array_values(array_filter($u['companies'] ?? [], fn($c) => $c !== $id)); + } + unset($u); + saveUsers($users); + echo json_encode(['success' => true]); + 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'] ?? []; + if (!in_array($companyId, $userCompanies)) { + http_response_code(403); + echo json_encode(['error' => 'Ei oikeutta tähän yritykseen']); + break; + } + $_SESSION['company_id'] = $companyId; + echo json_encode(['success' => true, 'company_id' => $companyId]); + break; + + case 'company_config': + requireAdmin(); + requireCompany(); + echo json_encode(loadCompanyConfig()); + break; + + case 'company_config_update': + requireAdmin(); + requireCompany(); + if ($method !== 'POST') break; + $input = json_decode(file_get_contents('php://input'), true); + $companyConf = loadCompanyConfig(); + if (isset($input['mailboxes'])) $companyConf['mailboxes'] = $input['mailboxes']; + if (isset($input['ticket_rules'])) $companyConf['ticket_rules'] = $input['ticket_rules']; + saveCompanyConfig($companyConf); + echo json_encode($companyConf); + break; + + // ---------- MAILBOXES ---------- + case 'mailboxes': + requireAuth(); + requireCompany(); + $companyConf = loadCompanyConfig(); + // Palauta postilaatikot ilman salasanoja + $mbs = array_map(function($mb) { + $mb['imap_password'] = !empty($mb['imap_password']) ? '********' : ''; + return $mb; + }, $companyConf['mailboxes'] ?? []); + echo json_encode($mbs); + break; + + case 'mailbox_save': + requireAdmin(); + requireCompany(); + if ($method !== 'POST') break; + $input = json_decode(file_get_contents('php://input'), true); + $companyConf = loadCompanyConfig(); + $mailboxes = $companyConf['mailboxes'] ?? []; + + $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'] ?? ''), + 'aktiivinen' => $input['aktiivinen'] ?? true, + ]; + // Salasana: jos ******** → pidä vanha, muuten päivitä + if (isset($input['imap_password']) && $input['imap_password'] !== '********') { + $mb['imap_password'] = $input['imap_password']; + } else { + // Hae vanha salasana + foreach ($mailboxes as $existing) { + if ($existing['id'] === $mb['id']) { + $mb['imap_password'] = $existing['imap_password'] ?? ''; + break; + } + } + if (!isset($mb['imap_password'])) $mb['imap_password'] = ''; + } + + if (empty($mb['nimi'])) { + http_response_code(400); + echo json_encode(['error' => 'Postilaatikon nimi puuttuu']); + break; + } + + $found = false; + foreach ($mailboxes as &$existing) { + if ($existing['id'] === $mb['id']) { + $existing = $mb; + $found = true; + break; + } + } + unset($existing); + if (!$found) $mailboxes[] = $mb; + + $companyConf['mailboxes'] = $mailboxes; + saveCompanyConfig($companyConf); + addLog('mailbox_save', '', '', 'Postilaatikko: ' . $mb['nimi']); + // Palauta ilman salasanaa + $mb['imap_password'] = '********'; + echo json_encode($mb); + break; + + case 'mailbox_delete': + requireAdmin(); + requireCompany(); + if ($method !== 'POST') break; + $input = json_decode(file_get_contents('php://input'), true); + $mbId = $input['id'] ?? ''; + $companyConf = loadCompanyConfig(); + $companyConf['mailboxes'] = array_values(array_filter($companyConf['mailboxes'] ?? [], fn($m) => $m['id'] !== $mbId)); + saveCompanyConfig($companyConf); echo json_encode(['success' => true]); break; diff --git a/data/companies.json b/data/companies.json new file mode 100644 index 0000000..f2d2905 --- /dev/null +++ b/data/companies.json @@ -0,0 +1,8 @@ +[ + { + "id": "cuitunet", + "nimi": "CuituNet", + "luotu": "2026-03-10 08:58:43", + "aktiivinen": true + } +] \ No newline at end of file diff --git a/data/companies/cuitunet/config.json b/data/companies/cuitunet/config.json new file mode 100644 index 0000000..d44ded5 --- /dev/null +++ b/data/companies/cuitunet/config.json @@ -0,0 +1,4 @@ +{ + "mailboxes": [], + "ticket_rules": [] +} \ No newline at end of file diff --git a/index.html b/index.html index 183a253..2f0d6af 100644 --- a/index.html +++ b/index.html @@ -61,6 +61,7 @@ Kuituasiakkaiden hallinta +
@@ -77,6 +78,7 @@ +
@@ -291,7 +293,7 @@ Tila Tyyppi Aihe - Lähettäjä + Postilaatikko Asiakas Tagit Viestejä @@ -449,34 +451,7 @@ -

Sähköposti (IMAP)

-

Asiakaspalvelu-sähköpostin IMAP-asetukset. Käytetään tikettien hakuun.

-
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
+

Sähköpostiasetukset (IMAP/postilaatikot) hallitaan Yritykset-välilehdellä.

API-ohjeet

Endpoint:
GET https://intra.cuitunet.fi/api.php?action=saatavuus
@@ -505,6 +480,112 @@
+ +
+
+ +
+
+

Yritykset

+ +
+
+ + + + + + + + + + + + +
IDNimiPostilaatikotLuotuTilaToiminnot
+
+
+ + +
+
+

CuituNet Intra — Asiakashallintajärjestelmä

@@ -645,6 +726,10 @@ +
+ +
+
diff --git a/script.js b/script.js index de0ad93..fc7cdec 100644 --- a/script.js +++ b/script.js @@ -4,6 +4,8 @@ let sortField = 'yritys'; let sortAsc = true; let currentDetailId = null; let currentUser = { username: '', nimi: '', role: '' }; +let currentCompany = null; // {id, nimi} +let availableCompanies = []; // [{id, nimi}, ...] // Elements const loginScreen = document.getElementById('login-screen'); @@ -129,6 +131,8 @@ async function checkAuth() { const data = await apiCall('check_auth'); if (data.authenticated) { currentUser = { username: data.username, nimi: data.nimi, role: data.role }; + availableCompanies = data.companies || []; + currentCompany = availableCompanies.find(c => c.id === data.company_id) || availableCompanies[0] || null; showDashboard(); } } catch (e) { /* not logged in */ } @@ -143,6 +147,8 @@ loginForm.addEventListener('submit', async (e) => { const data = await apiCall('login', 'POST', { username, password, captcha: parseInt(captcha) }); loginError.style.display = 'none'; currentUser = { username: data.username, nimi: data.nimi, role: data.role }; + availableCompanies = data.companies || []; + currentCompany = availableCompanies.find(c => c.id === data.company_id) || availableCompanies[0] || null; showDashboard(); } catch (err) { loginError.textContent = err.message; @@ -170,13 +176,38 @@ async function showDashboard() { // Näytä admin-toiminnot vain adminille document.getElementById('btn-users').style.display = currentUser.role === 'admin' ? '' : 'none'; document.getElementById('tab-settings').style.display = currentUser.role === 'admin' ? '' : 'none'; + document.getElementById('tab-companies').style.display = currentUser.role === 'admin' ? '' : 'none'; + // Yritysvalitsin + populateCompanySelector(); // Avaa oikea tabi URL-hashin perusteella (tai customers oletuks) const hash = window.location.hash.replace('#', ''); - const validTabs = ['customers', 'leads', 'archive', 'changelog', 'support', 'users', 'settings']; + const validTabs = ['customers', 'leads', 'archive', 'changelog', 'support', 'users', 'settings', 'companies']; const startTab = validTabs.includes(hash) ? hash : 'customers'; switchToTab(startTab); } +function populateCompanySelector() { + const sel = document.getElementById('company-selector'); + if (availableCompanies.length <= 1) { + sel.style.display = 'none'; + return; + } + sel.style.display = ''; + sel.innerHTML = availableCompanies.map(c => + `` + ).join(''); +} + +async function switchCompany(companyId) { + try { + await apiCall('company_switch', 'POST', { company_id: companyId }); + currentCompany = availableCompanies.find(c => c.id === companyId) || null; + // Lataa uudelleen aktiivinen tab + const hash = window.location.hash.replace('#', '') || 'customers'; + switchToTab(hash); + } catch (e) { alert(e.message); } +} + // ==================== TABS ==================== function switchToTab(target) { @@ -196,6 +227,7 @@ function switchToTab(target) { if (target === 'support') { loadTickets(); showTicketListView(); } if (target === 'users') loadUsers(); if (target === 'settings') loadSettings(); + if (target === 'companies') loadCompaniesTab(); } document.querySelectorAll('.tab').forEach(tab => { @@ -915,6 +947,26 @@ function openUserForm(user = null) { document.getElementById('user-form-password').value = ''; document.getElementById('user-pw-hint').textContent = user ? '(jätä tyhjäksi jos ei muuteta)' : '*'; document.getElementById('user-form-role').value = user ? user.role : 'user'; + // Yrityscheckboxit + const allComps = availableCompanies.length > 0 ? availableCompanies : []; + const userComps = user ? (user.companies || []) : []; + const container = document.getElementById('user-company-checkboxes'); + // Hae kaikki yritykset admin-näkymää varten + apiCall('companies_all').then(companies => { + container.innerHTML = companies.map(c => + `` + ).join(''); + }).catch(() => { + container.innerHTML = allComps.map(c => + `` + ).join(''); + }); userModal.style.display = 'flex'; } @@ -937,11 +989,13 @@ async function deleteUser(id, username) { document.getElementById('user-form').addEventListener('submit', async (e) => { e.preventDefault(); const id = document.getElementById('user-form-id').value; + const companies = [...document.querySelectorAll('.user-company-cb:checked')].map(cb => cb.value); const data = { username: document.getElementById('user-form-username').value, nimi: document.getElementById('user-form-nimi').value, email: document.getElementById('user-form-email').value, role: document.getElementById('user-form-role').value, + companies, }; const pw = document.getElementById('user-form-password').value; if (pw) data.password = pw; @@ -1037,7 +1091,7 @@ function renderTickets() { ${ticketStatusLabels[t.status] || t.status} ${typeLabel} ${esc(t.subject)} - ${esc(t.from_name || t.from_email)} + ${esc(t.mailbox_name || t.from_name || t.from_email)} ${t.customer_name ? esc(t.customer_name) : '-'} ${(t.tags || []).length > 0 ? (t.tags || []).map(tag => '#' + esc(tag) + '').join(' ') : '-'} ${lastType} ${t.message_count} @@ -1556,12 +1610,6 @@ async function loadSettings() { document.getElementById('settings-cors').value = (config.cors_origins || ['https://cuitunet.fi', 'https://www.cuitunet.fi']).join('\n'); const key = config.api_key || 'AVAIN'; document.getElementById('api-example-url').textContent = `api.php?action=saatavuus&key=${key}&osoite=Kauppakatu+5&postinumero=20100&kaupunki=Turku`; - // IMAP settings - document.getElementById('settings-imap-host').value = config.imap_host || ''; - document.getElementById('settings-imap-port').value = config.imap_port || 993; - document.getElementById('settings-imap-user').value = config.imap_user || ''; - document.getElementById('settings-imap-password').value = config.imap_password || ''; - document.getElementById('settings-imap-encryption').value = config.imap_encryption || 'ssl'; } catch (e) { console.error(e); } } @@ -1578,11 +1626,6 @@ document.getElementById('btn-save-settings').addEventListener('click', async () const config = await apiCall('config_update', 'POST', { api_key: document.getElementById('settings-api-key').value, cors_origins: document.getElementById('settings-cors').value, - imap_host: document.getElementById('settings-imap-host').value, - imap_port: document.getElementById('settings-imap-port').value, - imap_user: document.getElementById('settings-imap-user').value, - imap_password: document.getElementById('settings-imap-password').value, - imap_encryption: document.getElementById('settings-imap-encryption').value, }); alert('Asetukset tallennettu!'); } catch (e) { alert(e.message); } @@ -1622,6 +1665,234 @@ document.addEventListener('keydown', (e) => { } }); +// ==================== COMPANY SELECTOR ==================== + +document.getElementById('company-selector').addEventListener('change', function () { + switchCompany(this.value); +}); + +// ==================== YRITYKSET-TAB (admin) ==================== + +let companiesTabData = []; +let currentCompanyDetail = null; + +async function loadCompaniesTab() { + try { + companiesTabData = await apiCall('companies_all'); + renderCompaniesTable(); + } catch (e) { + console.error(e); + // Fallback: käytä availableCompanies + companiesTabData = availableCompanies; + renderCompaniesTable(); + } +} + +function renderCompaniesTable() { + const tbody = document.getElementById('companies-tbody'); + tbody.innerHTML = companiesTabData.map(c => ` + ${esc(c.id)} + ${esc(c.nimi)} + - + ${esc((c.luotu || '').substring(0, 10))} + ${c.aktiivinen !== false ? 'Aktiivinen' : 'Ei aktiivinen'} + + + + + `).join(''); + document.getElementById('companies-list-view').style.display = ''; + document.getElementById('company-detail-view').style.display = 'none'; +} + +document.getElementById('btn-add-company').addEventListener('click', () => { + const nimi = prompt('Yrityksen nimi:'); + if (!nimi) return; + const id = prompt('Yrityksen ID (pienillä kirjaimilla, a-z, 0-9, viiva sallittu):'); + if (!id) return; + apiCall('company_create', 'POST', { id, nimi }).then(() => { + loadCompaniesTab(); + // Päivitä myös company-selector + apiCall('check_auth').then(data => { + if (data.authenticated) { + availableCompanies = data.companies || []; + currentCompany = availableCompanies.find(c => c.id === data.company_id) || currentCompany; + populateCompanySelector(); + } + }); + }).catch(e => alert(e.message)); +}); + +async function deleteCompany(id, nimi) { + if (!confirm(`Poistetaanko yritys "${nimi}"? Tämä poistaa pääsyn yrityksen dataan.`)) return; + try { + await apiCall('company_delete', 'POST', { id }); + loadCompaniesTab(); + // Päivitä selector + availableCompanies = availableCompanies.filter(c => c.id !== id); + if (currentCompany && currentCompany.id === id) { + currentCompany = availableCompanies[0] || null; + if (currentCompany) switchCompany(currentCompany.id); + } + populateCompanySelector(); + } catch (e) { alert(e.message); } +} + +async function showCompanyDetail(id) { + currentCompanyDetail = id; + document.getElementById('companies-list-view').style.display = 'none'; + document.getElementById('company-detail-view').style.display = ''; + const comp = companiesTabData.find(c => c.id === id); + document.getElementById('company-detail-title').textContent = (comp ? comp.nimi : id) + ' — Asetukset'; + document.getElementById('company-edit-nimi').value = comp ? comp.nimi : ''; + + // Vaihda aktiivinen yritys jotta API-kutsut kohdistuvat oikein + await apiCall('company_switch', 'POST', { company_id: id }); + + // Lataa postilaatikot + loadMailboxes(); + // Lataa käyttäjäoikeudet + loadCompanyUsers(id); +} + +document.getElementById('btn-company-back').addEventListener('click', () => { + // Vaihda takaisin alkuperäiseen yritykseen + if (currentCompany) apiCall('company_switch', 'POST', { company_id: currentCompany.id }); + renderCompaniesTable(); +}); + +document.getElementById('btn-save-company-name').addEventListener('click', async () => { + const nimi = document.getElementById('company-edit-nimi').value.trim(); + if (!nimi) return; + try { + await apiCall('company_update', 'POST', { id: currentCompanyDetail, nimi }); + alert('Nimi tallennettu!'); + // Päivitä paikalliset tiedot + const comp = companiesTabData.find(c => c.id === currentCompanyDetail); + if (comp) comp.nimi = nimi; + const avail = availableCompanies.find(c => c.id === currentCompanyDetail); + if (avail) avail.nimi = nimi; + populateCompanySelector(); + } catch (e) { alert(e.message); } +}); + +// ==================== POSTILAATIKOT ==================== + +let mailboxesData = []; + +async function loadMailboxes() { + try { + mailboxesData = await apiCall('mailboxes'); + renderMailboxes(); + } catch (e) { console.error(e); } +} + +function renderMailboxes() { + const container = document.getElementById('mailboxes-list'); + if (mailboxesData.length === 0) { + container.innerHTML = '

Ei postilaatikoita. Lisää ensimmäinen postilaatikko.

'; + return; + } + container.innerHTML = mailboxesData.map(mb => `
+
+ ${esc(mb.nimi)} + ${esc(mb.imap_user)} + ${mb.aktiivinen !== false ? 'Aktiivinen' : 'Ei aktiivinen'} +
+
+ + +
+
`).join(''); +} + +document.getElementById('btn-add-mailbox').addEventListener('click', () => { + showMailboxForm(); +}); + +function showMailboxForm(mb = null) { + document.getElementById('mailbox-form-title').textContent = mb ? 'Muokkaa postilaatikkoa' : 'Uusi postilaatikko'; + document.getElementById('mailbox-form-id').value = mb ? mb.id : ''; + document.getElementById('mailbox-form-nimi').value = mb ? mb.nimi : ''; + document.getElementById('mailbox-form-host').value = mb ? mb.imap_host : ''; + document.getElementById('mailbox-form-port').value = mb ? mb.imap_port : 993; + document.getElementById('mailbox-form-user').value = mb ? mb.imap_user : ''; + document.getElementById('mailbox-form-password').value = mb ? mb.imap_password : ''; + document.getElementById('mailbox-form-encryption').value = mb ? (mb.imap_encryption || 'ssl') : 'ssl'; + document.getElementById('mailbox-form-smtp-email').value = mb ? (mb.smtp_from_email || '') : ''; + document.getElementById('mailbox-form-smtp-name').value = mb ? (mb.smtp_from_name || '') : ''; + document.getElementById('mailbox-form-container').style.display = ''; +} + +function editMailbox(id) { + const mb = mailboxesData.find(m => m.id === id); + if (mb) showMailboxForm(mb); +} + +async function deleteMailbox(id, nimi) { + if (!confirm(`Poistetaanko postilaatikko "${nimi}"?`)) return; + try { + await apiCall('mailbox_delete', 'POST', { id }); + loadMailboxes(); + } catch (e) { alert(e.message); } +} + +document.getElementById('btn-save-mailbox').addEventListener('click', async () => { + const data = { + id: document.getElementById('mailbox-form-id').value || undefined, + nimi: document.getElementById('mailbox-form-nimi').value, + imap_host: document.getElementById('mailbox-form-host').value, + imap_port: parseInt(document.getElementById('mailbox-form-port').value) || 993, + imap_user: document.getElementById('mailbox-form-user').value, + imap_password: document.getElementById('mailbox-form-password').value, + imap_encryption: document.getElementById('mailbox-form-encryption').value, + smtp_from_email: document.getElementById('mailbox-form-smtp-email').value, + smtp_from_name: document.getElementById('mailbox-form-smtp-name').value, + aktiivinen: true, + }; + try { + await apiCall('mailbox_save', 'POST', data); + document.getElementById('mailbox-form-container').style.display = 'none'; + loadMailboxes(); + } catch (e) { alert(e.message); } +}); + +document.getElementById('btn-cancel-mailbox').addEventListener('click', () => { + document.getElementById('mailbox-form-container').style.display = 'none'; +}); + +// ==================== YRITYKSEN KÄYTTÄJÄOIKEUDET ==================== + +async function loadCompanyUsers(companyId) { + try { + const users = await apiCall('users'); + const container = document.getElementById('company-users-list'); + container.innerHTML = users.map(u => { + const hasAccess = (u.companies || []).includes(companyId); + return ``; + }).join(''); + } catch (e) { console.error(e); } +} + +async function toggleCompanyUser(userId, companyId, add) { + try { + const users = await apiCall('users'); + const user = users.find(u => u.id === userId); + if (!user) return; + let companies = user.companies || []; + if (add && !companies.includes(companyId)) { + companies.push(companyId); + } else if (!add) { + companies = companies.filter(c => c !== companyId); + } + await apiCall('user_update', 'POST', { id: userId, companies }); + } catch (e) { alert(e.message); } +} + // Init loadCaptcha(); checkAuth(); diff --git a/style.css b/style.css index e1bcd44..e310db1 100644 --- a/style.css +++ b/style.css @@ -1321,3 +1321,43 @@ span.empty { color: #999; font-size: 0.85rem; } + +/* Company selector */ +.company-selector { + margin-left: 1.5rem; + padding: 6px 12px; + border: 2px solid #e0e0e0; + border-radius: 8px; + font-size: 0.88rem; + font-weight: 600; + color: #0f3460; + background: #f8f9fb; + cursor: pointer; + transition: border-color 0.2s; +} + +.company-selector:hover, +.company-selector:focus { + border-color: #0f3460; + outline: none; +} + +/* Mailbox items */ +.mailbox-item { + transition: border-color 0.2s; +} + +.mailbox-item:hover { + border-color: #0f3460 !important; +} + +/* Company badge */ +.company-badge { + display: inline-block; + padding: 2px 8px; + border-radius: 4px; + font-size: 0.75rem; + font-weight: 600; + background: #e8f0fe; + color: #1a56db; +}