nis2-agile/scripts/import-feedback-to-nexus.php
DevEnv nis2-agile 9b53ca3ba1 [FEAT] MktgLead getJsonBody + script import-feedback-to-nexus + seed demo agile-tech
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 15:42:05 +02:00

399 lines
16 KiB
PHP

<?php
/**
* =============================================================================
* Import storico segnalazioni NIS2 → AgileHub/Nexus
* =============================================================================
*
* Migra le righe di `nis2_agile_db.feedback_reports` (e relative
* attachments inline base64) verso `nexus_ticket_db.tickets` /
* `ticket_attachments`, preservando created_at originale e idempotente
* rispetto a re-run grazie a `tickets.external_ref`.
*
* Mapping:
* feedback_reports.id → tickets.external_ref ('nis2:<id>')
* 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);