') * feedback_reports.tipo → tickets.kind (vedi $KIND_MAP) * feedback_reports.priorita → tickets.priority (alta→HIGH, media→MEDIUM, bassa→LOW) * feedback_reports.status → tickets.status (vedi $STATUS_MAP) * feedback_reports.descrizione → tickets.operative_summary (+ subject = first 200) * feedback_reports.user_email → tickets.caller_email * feedback_reports.user_role → tickets.reporter_role * feedback_reports.page_url → tickets.page_url * feedback_reports.ai_* → tickets.ai_* * feedback_reports.attachment → ticket_attachments.data (base64, LONGTEXT) * + sempre: requested_product='NIS2', requested_product_label='NIS2 Agile', * channel='FORM', tenant_id=$NEXUS_TENANT_ID * * Tabella `feedback_audit_log` (se presente) → ticket_messages role='SYSTEM' * Tabella `notifications`: NON migrata (decisione esplicita per non flooddare * la bell di AgileHub con notifiche storiche già lette). * * Uso: * php import-feedback-to-nexus.php # dry-run di default * php import-feedback-to-nexus.php --commit # esecuzione vera * php import-feedback-to-nexus.php --rollback # cancella tutto ciò che è stato importato (filtro external_ref LIKE 'nis2:%') * php import-feedback-to-nexus.php --limit=10 # solo prime 10 righe (utile per smoke test) * * Variabili d'ambiente: * SRC_DB_HOST default 172.25.0.1 * SRC_DB_USER default nis2_user * SRC_DB_PASS default Nis2Dev2026! * SRC_DB_NAME default nis2_agile_db * * NEXUS_DB_HOST default 172.25.0.1 * NEXUS_DB_USER default agile_services * NEXUS_DB_PASS *required* * NEXUS_DB_NAME default nexus_ticket_db * * NEXUS_TENANT_ID *required* (id numerico del tenant 'alltax' in agile_auth_db.tenants) * ============================================================================= */ declare(strict_types=1); // ----------------------------- CLI ARGS ----------------------------------- $args = array_slice($argv, 1); $dryRun = !in_array('--commit', $args, true); $rollback = in_array('--rollback', $args, true); $limit = null; foreach ($args as $a) { if (preg_match('/^--limit=(\d+)$/', $a, $m)) { $limit = (int)$m[1]; } } // ----------------------------- ENV ---------------------------------------- $SRC = [ 'host' => getenv('SRC_DB_HOST') ?: '172.25.0.1', 'user' => getenv('SRC_DB_USER') ?: 'nis2_user', 'pass' => getenv('SRC_DB_PASS') ?: 'Nis2Dev2026!', 'db' => getenv('SRC_DB_NAME') ?: 'nis2_agile_db', ]; $DST = [ 'host' => getenv('NEXUS_DB_HOST') ?: '172.25.0.1', 'user' => getenv('NEXUS_DB_USER') ?: 'agile_services', 'pass' => getenv('NEXUS_DB_PASS') ?: '', 'db' => getenv('NEXUS_DB_NAME') ?: 'nexus_ticket_db', ]; $NEXUS_TENANT_ID = (int) (getenv('NEXUS_TENANT_ID') ?: 0); if (!$rollback && $NEXUS_TENANT_ID <= 0) { fwrite(STDERR, "ERROR: env NEXUS_TENANT_ID mancante. Esegui prima seed-nexus-tenant.sql e annota l'id.\n"); exit(2); } if (!$rollback && empty($DST['pass'])) { fwrite(STDERR, "ERROR: env NEXUS_DB_PASS mancante.\n"); exit(2); } // ----------------------------- LOG ---------------------------------------- $logDir = __DIR__ . '/../tmp'; if (!is_dir($logDir)) { mkdir($logDir, 0775, true); } $logPath = $logDir . '/import-feedback-' . date('Ymd-His') . '.log'; $logFp = fopen($logPath, 'w'); function logLine(string $line): void { global $logFp; $stamp = date('H:i:s'); fwrite($logFp, "[$stamp] $line\n"); echo "[$stamp] $line\n"; } logLine('=== Import feedback NIS2 → Nexus ==='); logLine('Mode: ' . ($rollback ? 'ROLLBACK' : ($dryRun ? 'DRY-RUN' : 'COMMIT'))); logLine('Tenant id: ' . $NEXUS_TENANT_ID); logLine('Limit: ' . ($limit ?? 'none')); logLine('Source: ' . "{$SRC['user']}@{$SRC['host']}/{$SRC['db']}"); logLine('Target: ' . "{$DST['user']}@{$DST['host']}/{$DST['db']}"); // ----------------------------- PDO ---------------------------------------- $pdoOpts = [ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, PDO::ATTR_EMULATE_PREPARES => false, ]; try { $src = new PDO("mysql:host={$SRC['host']};dbname={$SRC['db']};charset=utf8mb4", $SRC['user'], $SRC['pass'], $pdoOpts); $dst = new PDO("mysql:host={$DST['host']};dbname={$DST['db']};charset=utf8mb4", $DST['user'], $DST['pass'], $pdoOpts); } catch (PDOException $e) { logLine('FATAL DB connect: ' . $e->getMessage()); exit(3); } // ----------------------------- ROLLBACK ----------------------------------- if ($rollback) { logLine('Rollback: cancello tutti i ticket con external_ref LIKE "nis2:%"'); if ($dryRun) { $n = (int) $dst->query( "SELECT COUNT(*) FROM tickets WHERE external_ref LIKE 'nis2:%'" )->fetchColumn(); logLine("[DRY-RUN] eliminerebbe $n ticket (e relativi attachments / messages via FK CASCADE)"); logLine("Per eseguire davvero: aggiungi --commit"); exit(0); } $dst->beginTransaction(); $deleted = $dst->exec("DELETE FROM tickets WHERE external_ref LIKE 'nis2:%'"); $dst->commit(); logLine("Rollback: $deleted ticket eliminati"); exit(0); } // ----------------------------- DETECT OPTIONAL COLUMNS -------------------- function tableHasColumn(PDO $pdo, string $table, string $col): bool { $stmt = $pdo->prepare( "SELECT 1 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?" ); $stmt->execute([$table, $col]); return (bool) $stmt->fetchColumn(); } function tableExists(PDO $pdo, string $table): bool { $stmt = $pdo->prepare( "SELECT 1 FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?" ); $stmt->execute([$table]); return (bool) $stmt->fetchColumn(); } $hasAuditLog = tableExists($src, 'feedback_audit_log'); $hasRiskScore = tableHasColumn($src, 'feedback_reports', 'risk_score'); $hasNotaAdmin = tableHasColumn($src, 'feedback_reports', 'nota_admin'); $hasStatusVerified = false; try { $row = $src->query( "SELECT COLUMN_TYPE FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'feedback_reports' AND COLUMN_NAME = 'status'" )->fetch(); $hasStatusVerified = $row && stripos($row['COLUMN_TYPE'], "'verificato'") !== false; } catch (PDOException $e) {} logLine('Source schema: feedback_audit_log=' . ($hasAuditLog ? 'yes' : 'no') . ', risk_score=' . ($hasRiskScore ? 'yes' : 'no') . ', nota_admin=' . ($hasNotaAdmin ? 'yes' : 'no') . ', status verificato=' . ($hasStatusVerified ? 'yes' : 'no')); // ----------------------------- MAPPING TABLES ------------------------------ $KIND_MAP = [ 'bug' => 'COMPLAINT', 'ux' => 'SUPPORT_REQUEST', 'funzionalita' => 'INFO_REQUEST', 'domanda' => 'INFO_REQUEST', 'altro' => 'OTHER', ]; $PRIORITY_MAP = [ 'alta' => 'HIGH', 'media' => 'MEDIUM', 'bassa' => 'LOW', ]; $STATUS_MAP = [ 'aperto' => 'OPEN', 'in_lavorazione' => 'IN_PROGRESS', 'risolto' => 'RESOLVED', 'chiuso' => 'CLOSED', 'verificato' => 'CLOSED', ]; // ----------------------------- READ SOURCE -------------------------------- $sql = "SELECT * FROM feedback_reports ORDER BY id ASC"; if ($limit) { $sql .= " LIMIT " . (int)$limit; } $rows = $src->query($sql)->fetchAll(); logLine('Source rows fetched: ' . count($rows)); if (empty($rows)) { logLine('Niente da migrare. Esco.'); exit(0); } // Pre-load existing external_refs in target (single roundtrip) $existing = []; foreach ($dst->query( "SELECT external_ref FROM tickets WHERE external_ref LIKE 'nis2:%'" )->fetchAll(PDO::FETCH_COLUMN) as $ref) { $existing[$ref] = true; } logLine('Already imported in target: ' . count($existing)); // ----------------------------- INSERT PREP -------------------------------- $insTicket = $dst->prepare(" INSERT INTO tickets (tenant_id, caller_phone, caller_name, caller_email, requested_product, requested_product_label, subject, operative_summary, status, queue, priority, channel, kind, external_ref, page_url, reporter_role, ai_categoria, ai_priorita, ai_suggerimento, ai_response, ai_processed, lang, created_at, updated_at) VALUES (:tenant_id, :caller_phone, :caller_name, :caller_email, :requested_product, :requested_product_label, :subject, :operative_summary, :status, :queue, :priority, :channel, :kind, :external_ref, :page_url, :reporter_role, :ai_categoria, :ai_priorita, :ai_suggerimento, :ai_response, :ai_processed, :lang, :created_at, :updated_at) "); $insAttachment = $dst->prepare(" INSERT INTO ticket_attachments (ticket_id, tenant_id, filename, mime_type, size, data, created_at) VALUES (:ticket_id, :tenant_id, :filename, :mime_type, :size, :data, :created_at) "); $insMessage = $dst->prepare(" INSERT INTO ticket_messages (ticket_id, tenant_id, role, content, metadata, created_at) VALUES (:ticket_id, :tenant_id, 'SYSTEM', :content, :metadata, :created_at) "); // ----------------------------- LOOP --------------------------------------- $counters = ['ok' => 0, 'skip' => 0, 'err' => 0, 'attach' => 0, 'audit' => 0]; if (!$dryRun) { $dst->beginTransaction(); } foreach ($rows as $r) { $extRef = 'nis2:' . (int)$r['id']; if (isset($existing[$extRef])) { $counters['skip']++; logLine("[SKIP duplicate] $extRef"); continue; } $tipo = $r['tipo'] ?? 'altro'; $priorita = $r['priorita'] ?? 'media'; $status = $r['status'] ?? 'aperto'; $kind = $KIND_MAP[$tipo] ?? 'OTHER'; $priority = $PRIORITY_MAP[$priorita] ?? 'MEDIUM'; $statusN = $STATUS_MAP[$status] ?? 'OPEN'; $descr = (string) ($r['descrizione'] ?? ''); $subject = mb_substr(trim(preg_replace('/\s+/', ' ', $descr)), 0, 200); if ($subject === '') { $subject = "[NIS2 legacy #{$r['id']}]"; } // Append nota_admin alla operative_summary se presente $opSummary = $descr; if ($hasNotaAdmin && !empty($r['nota_admin'])) { $opSummary .= "\n\n--- Nota admin originale ---\n" . $r['nota_admin']; } if ($hasRiskScore && isset($r['risk_score']) && $r['risk_score'] !== null) { $opSummary .= "\n\n[risk_score originale: " . $r['risk_score'] . "]"; } // Map ai_priorita italiano → ENUM Nexus $aiPrio = null; if (!empty($r['ai_priorita'])) { $aiPrio = $PRIORITY_MAP[$r['ai_priorita']] ?? null; } $params = [ ':tenant_id' => $NEXUS_TENANT_ID, ':caller_phone' => '', // non disponibile in feedback_reports ':caller_name' => $r['user_email'] ?? null, // fallback: nessun caller_name in source ':caller_email' => $r['user_email'] ?? null, ':requested_product' => 'NIS2', ':requested_product_label' => 'NIS2 Agile', ':subject' => $subject, ':operative_summary' => mb_substr($opSummary, 0, 1990), ':status' => $statusN, ':queue' => 'SUPPORT', ':priority' => $priority, ':channel' => 'FORM', ':kind' => $kind, ':external_ref' => $extRef, ':page_url' => $r['page_url'] ?? null, ':reporter_role' => $r['user_role'] ?? null, ':ai_categoria' => $r['ai_categoria'] ?? null, ':ai_priorita' => $aiPrio, ':ai_suggerimento' => $r['ai_suggerimento'] ?? null, ':ai_response' => $r['ai_risposta'] ?? null, ':ai_processed' => isset($r['ai_processed']) ? (int)$r['ai_processed'] : 0, ':lang' => 'it', ':created_at' => $r['created_at'] ?? date('Y-m-d H:i:s'), ':updated_at' => $r['updated_at'] ?? ($r['created_at'] ?? date('Y-m-d H:i:s')), ]; if ($dryRun) { $counters['ok']++; logLine("[DRY-RUN OK] $extRef kind=$kind status=$statusN created={$params[':created_at']}"); continue; } try { $insTicket->execute($params); $newId = (int) $dst->lastInsertId(); $counters['ok']++; logLine("[OK] $extRef → ticket #$newId"); // Attachment inline base64 if (!empty($r['attachment'])) { $att = (string) $r['attachment']; $mime = 'image/png'; $rawB64 = $att; if (preg_match('/^data:([^;]+);base64,(.*)$/', $att, $m)) { $mime = $m[1]; $rawB64 = $m[2]; } $size = (int) (strlen($rawB64) * 3 / 4); $insAttachment->execute([ ':ticket_id' => $newId, ':tenant_id' => $NEXUS_TENANT_ID, ':filename' => 'legacy_' . (int)$r['id'] . '.png', ':mime_type' => $mime, ':size' => $size, ':data' => $att, ':created_at' => $params[':created_at'], ]); $counters['attach']++; } // feedback_audit_log → ticket_messages role=SYSTEM if ($hasAuditLog) { $stmt = $src->prepare( "SELECT * FROM feedback_audit_log WHERE report_id = ? ORDER BY id ASC" ); $stmt->execute([(int)$r['id']]); foreach ($stmt->fetchAll() as $ev) { $content = sprintf( "[legacy audit] action=%s to_status=%s actor=%s", $ev['action'] ?? '', $ev['to_status'] ?? '', $ev['actor_type']?? '' ); $insMessage->execute([ ':ticket_id' => $newId, ':tenant_id' => $NEXUS_TENANT_ID, ':content' => $content, ':metadata' => $ev['details'] ?? '{}', ':created_at' => $ev['created_at'] ?? $params[':created_at'], ]); $counters['audit']++; } } } catch (PDOException $e) { $counters['err']++; logLine("[ERR] $extRef → " . $e->getMessage()); // continua: gli altri possono passare. Fail-safe: log e via. } } if (!$dryRun) { if ($counters['err'] > 0) { $dst->rollBack(); logLine('Errori rilevati: ROLLBACK transazione, nessuna riga scritta.'); logLine("Counters: " . json_encode($counters)); exit(1); } $dst->commit(); } logLine('=== Riepilogo ==='); logLine("OK: {$counters['ok']}"); logLine("SKIP: {$counters['skip']}"); logLine("ERR: {$counters['err']}"); logLine("Attachments: {$counters['attach']}"); logLine("Audit msgs: {$counters['audit']}"); logLine("Log file: $logPath"); exit($counters['err'] > 0 ? 1 : 0);