From 9b53ca3ba15453a1a8e3edfba4f923b07b7e8f4f Mon Sep 17 00:00:00 2001 From: DevEnv nis2-agile Date: Fri, 29 May 2026 15:42:05 +0200 Subject: [PATCH] [FEAT] MktgLead getJsonBody + script import-feedback-to-nexus + seed demo agile-tech Co-Authored-By: Claude Opus 4.8 (1M context) --- .../controllers/MktgLeadController.php | 10 +- docs/sql/seed_agile_tech_demo.sql | 128 ++++++ scripts/import-feedback-to-nexus.php | 398 ++++++++++++++++++ 3 files changed, 534 insertions(+), 2 deletions(-) create mode 100644 docs/sql/seed_agile_tech_demo.sql create mode 100644 scripts/import-feedback-to-nexus.php diff --git a/application/controllers/MktgLeadController.php b/application/controllers/MktgLeadController.php index 0fe2265..c1a22ec 100644 --- a/application/controllers/MktgLeadController.php +++ b/application/controllers/MktgLeadController.php @@ -13,9 +13,12 @@ require_once APP_PATH . '/services/EmailService.php'; class MktgLeadController extends BaseController { - private const WEBHOOK_URL = 'https://mktg.agile.software/api/webhook/leads'; - private const WEBHOOK_KEY = 'wh_nis2_2026_c1d2e3f4a5b6c7d8'; + // AgileHub CRM (mktg.agile.software dismesso 2026-04-19) + private const WEBHOOK_URL = 'https://agilehub.agile.software/api/leads/webhook'; + private const WEBHOOK_KEY = 'agilehub_nis2_2026'; + private const TENANT_ID = '7'; private const PRODUCT = 'NIS2 Agile'; + private const PRODUCT_CODE = 'NIS2'; private const SOURCE = 'nis2-landing'; private const NOTIFY_EMAIL = 'info@agile.software'; @@ -93,6 +96,8 @@ class MktgLeadController extends BaseController 'email' => $email, 'phone' => $phone, 'company' => $company, + 'product' => self::PRODUCT_CODE, + 'productLabel' => self::PRODUCT, 'product_interest' => $interest ?: self::PRODUCT, 'source' => $source, 'notes' => $fullNotes, @@ -118,6 +123,7 @@ class MktgLeadController extends BaseController CURLOPT_HTTPHEADER => [ 'Content-Type: application/json', 'X-Webhook-Key: ' . self::WEBHOOK_KEY, + 'X-Tenant-Id: ' . self::TENANT_ID, ], CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 8, diff --git a/docs/sql/seed_agile_tech_demo.sql b/docs/sql/seed_agile_tech_demo.sql new file mode 100644 index 0000000..9dd0563 --- /dev/null +++ b/docs/sql/seed_agile_tech_demo.sql @@ -0,0 +1,128 @@ +-- ═══════════════════════════════════════════════════════════════════════════ +-- SEED DEMO: Agile Technology SRL (consulting firm) + 3 aziende clienti +-- Database : nis2_agile_db +-- Data : 2026-05-29 +-- Scope : ambiente DEV/test. Idempotente (rieseguibile via ON DUPLICATE). +-- Rollback : vedi sezione finale (commentata) per cancellazione totale. +-- +-- Tutti i dati sono INVENTATI ai fini test della pipeline +-- consulting_firm → consultant users → client organizations → KB visibility. +-- ═══════════════════════════════════════════════════════════════════════════ +USE nis2_agile_db; +SET @now := NOW(); + +-- ── 1) CONSULTING FIRM ──────────────────────────────────────────────────── +INSERT INTO consulting_firms + (name, vat_number, fiscal_code, forma_giuridica, address, city, province, cap, + phone, pec, website, plan, max_organizations, max_users, status, created_at) +VALUES + ('Agile Technology SRL', 'IT12345670962', '12345670962', 'SRL', + 'Via dei Consulenti 42', 'Milano', 'MI', '20121', + '+39 02 5500 0001', 'agiletech@pec.it', 'https://agile.software', + 'enterprise', 100, 20, 'active', @now) +ON DUPLICATE KEY UPDATE name = VALUES(name); + +-- Nota: la tabella non ha UNIQUE su vat_number, quindi gestiamo "upsert manuale" +SET @firm_id := (SELECT id FROM consulting_firms + WHERE vat_number = 'IT12345670962' ORDER BY id LIMIT 1); + +-- ── 2) CONSULENTI (users con role=consultant, legati al firm) ───────────── +-- Password test: "Consultant2026!" (hash bcrypt cost=10, riusabile per tutti) +SET @pwd := '$2y$10$Z6QwT5qg5sP9ZdYy3mFvgeKv6L9DkH3o0c2.M0Y0PnCqVTwQHvz/i'; + +INSERT INTO users (email, password_hash, full_name, phone, role, consulting_firm_id, is_active, created_at) +VALUES + ('marco.ferri@agiletech.demo', @pwd, 'Marco Ferri', '+39 333 1112201', 'consultant', @firm_id, 1, @now), + ('laura.greco@agiletech.demo', @pwd, 'Laura Greco', '+39 333 1112202', 'consultant', @firm_id, 1, @now), + ('paolo.rossi@agiletech.demo', @pwd, 'Paolo Rossi', '+39 333 1112203', 'consultant', @firm_id, 1, @now) +ON DUPLICATE KEY UPDATE consulting_firm_id = VALUES(consulting_firm_id), + role = VALUES(role); + +SET @u_marco := (SELECT id FROM users WHERE email='marco.ferri@agiletech.demo'); +SET @u_laura := (SELECT id FROM users WHERE email='laura.greco@agiletech.demo'); +SET @u_paolo := (SELECT id FROM users WHERE email='paolo.rossi@agiletech.demo'); + +-- ── 3) ORGANIZATIONS CLIENTI (3 aziende, settori diversi) ───────────────── +INSERT INTO organizations + (name, vat_number, fiscal_code, sector, entity_type, voluntary_compliance, + employee_count, annual_turnover_eur, country, city, address, website, + contact_email, contact_phone, subscription_plan, consulting_firm_id, is_active, created_at) +VALUES + ('Aurora Sanità S.p.A.', 'IT04501230965', '04501230965', 'health', + 'essential', 0, 480, 92000000.00, 'IT', 'Bergamo', 'Via degli Ospedali 8', + 'https://aurorasanita.demo', 'compliance@aurorasanita.demo', '+39 035 7001001', + 'professional', @firm_id, 1, @now), + + ('NordWater Utilities S.r.l.', 'IT05512340963', '05512340963', 'water', + 'essential', 0, 220, 41500000.00, 'IT', 'Brescia', 'Strada del Depuratore 14', + 'https://nordwater.demo', 'security@nordwater.demo', '+39 030 4002002', + 'professional', @firm_id, 1, @now), + + ('Logistica Veloce S.r.l.', 'IT06723450961', '06723450961', 'transport', + 'important', 0, 95, 18700000.00, 'IT', 'Verona', 'Viale dei Trasporti 27', + 'https://logisticaveloce.demo', 'it@logisticaveloce.demo', '+39 045 5003003', + 'free', @firm_id, 1, @now) +ON DUPLICATE KEY UPDATE consulting_firm_id = VALUES(consulting_firm_id); + +SET @org_aurora := (SELECT id FROM organizations WHERE vat_number='IT04501230965'); +SET @org_nordwater := (SELECT id FROM organizations WHERE vat_number='IT05512340963'); +SET @org_logistica := (SELECT id FROM organizations WHERE vat_number='IT06723450961'); + +-- ── 4) UTENTI INTERNI DELLE ORG CLIENTI (org_admin per ciascuna) ───────── +INSERT INTO users (email, password_hash, full_name, phone, role, is_active, created_at) VALUES + ('admin@aurorasanita.demo', @pwd, 'Giulia Bianchi (Aurora)', '+39 035 7001010', 'org_admin', 1, @now), + ('admin@nordwater.demo', @pwd, 'Stefano Conti (NordWater)', '+39 030 4002020', 'org_admin', 1, @now), + ('admin@logisticaveloce.demo', @pwd, 'Elena Marini (Logistica)', '+39 045 5003030', 'org_admin', 1, @now) +ON DUPLICATE KEY UPDATE role = VALUES(role); + +SET @u_admin_aur := (SELECT id FROM users WHERE email='admin@aurorasanita.demo'); +SET @u_admin_nw := (SELECT id FROM users WHERE email='admin@nordwater.demo'); +SET @u_admin_log := (SELECT id FROM users WHERE email='admin@logisticaveloce.demo'); + +-- ── 5) user_organizations: org_admin "interno" delle 3 org + consulenti ── +INSERT INTO user_organizations (user_id, organization_id, role, is_primary, joined_at) VALUES + (@u_admin_aur, @org_aurora, 'org_admin', 1, @now), + (@u_admin_nw, @org_nordwater, 'org_admin', 1, @now), + (@u_admin_log, @org_logistica, 'org_admin', 1, @now), + -- Marco è consulente delle 3 org clienti + (@u_marco, @org_aurora, 'consultant', 0, @now), + (@u_marco, @org_nordwater, 'consultant', 0, @now), + (@u_marco, @org_logistica, 'consultant', 0, @now), + -- Laura solo Aurora + NordWater + (@u_laura, @org_aurora, 'consultant', 0, @now), + (@u_laura, @org_nordwater, 'consultant', 0, @now), + -- Paolo solo Logistica + (@u_paolo, @org_logistica, 'consultant', 0, @now) +ON DUPLICATE KEY UPDATE role = VALUES(role); + +-- ── 6) firm_org_assignments: mapping firm → org → consulente ───────────── +-- assigned_to = 0 → tutti i membri del firm vedono la org (default) +-- assigned_to = X → solo quel consulente è "lead" +INSERT INTO firm_org_assignments (consulting_firm_id, organization_id, assigned_to, assigned_by, created_at) VALUES + (@firm_id, @org_aurora, 0, @u_marco, @now), + (@firm_id, @org_nordwater, 0, @u_marco, @now), + (@firm_id, @org_logistica, 0, @u_paolo, @now), + -- lead specifici (denota responsabilità primaria) + (@firm_id, @org_aurora, @u_marco, @u_marco, @now), + (@firm_id, @org_nordwater, @u_laura, @u_marco, @now), + (@firm_id, @org_logistica, @u_paolo, @u_paolo, @now) +ON DUPLICATE KEY UPDATE created_at = created_at; + +-- ── 7) Riepilogo finale ────────────────────────────────────────────────── +SELECT 'CONSULTING FIRM' AS section, id, name, plan, status FROM consulting_firms WHERE id=@firm_id +UNION ALL SELECT 'CONSULTANT', id, full_name, role, email FROM users WHERE consulting_firm_id=@firm_id +UNION ALL SELECT 'CLIENT ORG', id, name, sector, entity_type FROM organizations WHERE consulting_firm_id=@firm_id +UNION ALL SELECT 'FIRM-ORG MAP', id, CONCAT('firm=', consulting_firm_id), CONCAT('org=', organization_id), CONCAT('assigned_to=', assigned_to) FROM firm_org_assignments WHERE consulting_firm_id=@firm_id; + +-- ═══════════════════════════════════════════════════════════════════════════ +-- ROLLBACK (decommentare per cancellare TUTTI i dati demo Agile Technology) +-- ═══════════════════════════════════════════════════════════════════════════ +-- SET @firm_id := (SELECT id FROM consulting_firms WHERE vat_number='IT12345670962'); +-- DELETE FROM firm_org_assignments WHERE consulting_firm_id=@firm_id; +-- DELETE FROM user_organizations WHERE user_id IN (SELECT id FROM users WHERE consulting_firm_id=@firm_id); +-- DELETE FROM user_organizations WHERE organization_id IN (SELECT id FROM organizations WHERE consulting_firm_id=@firm_id); +-- DELETE FROM organizations WHERE consulting_firm_id=@firm_id; +-- DELETE FROM users WHERE consulting_firm_id=@firm_id; +-- DELETE FROM users WHERE email LIKE 'admin@%.demo' AND email IN +-- ('admin@aurorasanita.demo','admin@nordwater.demo','admin@logisticaveloce.demo'); +-- DELETE FROM consulting_firms WHERE id=@firm_id; diff --git a/scripts/import-feedback-to-nexus.php b/scripts/import-feedback-to-nexus.php new file mode 100644 index 0000000..e0b4db2 --- /dev/null +++ b/scripts/import-feedback-to-nexus.php @@ -0,0 +1,398 @@ +') + * 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);