[DOCS] Design modulo questionari fornitori + portale OTP + AI consulente normativo

Pacchetto di design completo (nessun codice applicato, nessuna migrazione eseguita):
- DESIGN aggiornato con 5 review agenti + 3 decisioni utente + pilastro AI consulente (sez. 12-14)
- docs/supplier-portal/template-nis2-base.questions.json: 26 domande GV.SC (Allegato 2 ACN) con nis2_ref/vuln_flag e fonti certe verbatim
- docs/supplier-portal/AI_CONSULENTE_NORMATIVO.md: corpus normativo aggiornato + persona consulente (modello TRPG)
- docs/supplier-portal/UX_MINI_SPEC.md: mini-spec portale fornitore (stati/copy/autosave/mobile/a11y/editor no-code)
- docs/sql/032-035: migrazioni idempotenti proposte (modulo, suppliers, portale auth, migrazione 027->campaigns)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
DevEnv nis2-agile
2026-05-31 09:53:07 +02:00
co-authored by Claude Opus 4.8
parent dce53a0d0c
commit aa2db4c6c2
8 changed files with 749 additions and 2 deletions
@@ -0,0 +1,209 @@
-- ============================================================================
-- Migration 032 - Modulo Questionari Fornitori (supply chain Art.21.2.d)
-- ----------------------------------------------------------------------------
-- PROPOSTA DI DESIGN (NON ancora applicata). Da eseguire su host MySQL.
--
-- SOSTITUISCE supplier_questionnaires (mig 027, da qui DEPRECATA/read-only) con
-- un modello completo a template versionati + campagne + domande + risposte:
--
-- supplier_categories = tassonomia fornitori (preset di sistema + per-org)
-- questionnaire_templates = modelli di questionario (per-org, opz. per categoria)
-- questionnaire_template_versions= snapshot versionato (questions_snapshot JSON, weight)
-- questionnaire_questions = domande "live" del template (ordinabili, pesate)
-- questionnaire_campaigns = invio di un template a un fornitore (sostituisce 027)
-- questionnaire_answers = risposte puntuali (answer_value JSON-ready)
--
-- Retrocompat: i token sq_ gia inviati (027) restano validi grazie alla
-- colonna access_token_hash (stesso SHA-256 di supplier_questionnaires.token_hash),
-- migrata da mig 035 (migrazione dati). 027 NON viene droppata: resta read-only.
--
-- IMPORTANTE (CLAUDE.md / memoria): MySQL 8 Ubuntu NON supporta
-- "ADD COLUMN IF NOT EXISTS" / "CREATE INDEX IF NOT EXISTS". Le ALTER usano
-- procedure idempotenti su information_schema. CREATE TABLE IF NOT EXISTS ok.
-- DELIMITER non funziona via pipe -> usare "source".
--
-- Eseguire con:
-- mysql -h localhost nis2_agile_db -e "source docs/sql/032_supplier_questionnaires_module.sql"
--
-- Ordine di applicazione: 032 -> 033 -> 034 -> 035 (vedi nota in 035).
-- ============================================================================
-- 1. Categorie fornitori (preset di sistema = organization_id 0)
CREATE TABLE IF NOT EXISTS supplier_categories (
id INT NOT NULL AUTO_INCREMENT,
organization_id INT NOT NULL DEFAULT 0 COMMENT '0 = preset di sistema (visibile a tutte le org)',
slug VARCHAR(64) NOT NULL COMMENT 'Identificativo stabile, es. cloud_provider',
name VARCHAR(255) NOT NULL,
description TEXT NULL,
is_system TINYINT(1) NOT NULL DEFAULT 0 COMMENT '1 per i preset di sistema',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uq_supcat_org_slug (organization_id, slug),
KEY idx_supcat_org (organization_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='Tassonomia fornitori: preset di sistema (org 0) + categorie per-org';
-- 2. Template questionario (per-org, opz. legato a una categoria)
CREATE TABLE IF NOT EXISTS questionnaire_templates (
id INT NOT NULL AUTO_INCREMENT,
organization_id INT NOT NULL,
category_id INT NULL COMMENT 'FK supplier_categories: template specifico per categoria',
name VARCHAR(255) NOT NULL,
description TEXT NULL,
current_version VARCHAR(20) NOT NULL DEFAULT '1.0' COMMENT 'Versione live (vedi questionnaire_template_versions)',
status ENUM('draft','active','archived') NOT NULL DEFAULT 'draft',
pass_threshold TINYINT UNSIGNED NULL COMMENT 'Soglia score 0-100 per esito positivo',
is_default TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'Template predefinito per la org/categoria',
created_by INT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
KEY idx_qt_org (organization_id),
KEY idx_qt_category (category_id),
KEY idx_qt_org_status (organization_id, status),
CONSTRAINT fk_qt_org FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE,
CONSTRAINT fk_qt_category FOREIGN KEY (category_id) REFERENCES supplier_categories(id) ON DELETE SET NULL,
CONSTRAINT fk_qt_creator FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='Modelli di questionario di sicurezza fornitori (per organizzazione)';
-- 3. Versioni template (pattern policy_versions, snapshot domande+weight)
CREATE TABLE IF NOT EXISTS questionnaire_template_versions (
id INT NOT NULL AUTO_INCREMENT,
template_id INT NOT NULL,
organization_id INT NOT NULL COMMENT 'Denormalizzato per filtri/authz',
version VARCHAR(20) NOT NULL,
questions_snapshot JSON NOT NULL COMMENT 'Snapshot completo domande incl. weight, opzioni, tipo',
change_note VARCHAR(255) NULL,
created_by INT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uq_qtv_template_version (template_id, version),
KEY idx_qtv_template (template_id),
KEY idx_qtv_org (organization_id),
CONSTRAINT fk_qtv_template FOREIGN KEY (template_id) REFERENCES questionnaire_templates(id) ON DELETE CASCADE,
CONSTRAINT fk_qtv_org FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE,
CONSTRAINT fk_qtv_creator FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='Storico versionato dei template questionario (snapshot domande+weight)';
-- 4. Domande "live" del template (ordinabili, pesate, multi-tipo)
CREATE TABLE IF NOT EXISTS questionnaire_questions (
id INT NOT NULL AUTO_INCREMENT,
template_id INT NOT NULL,
organization_id INT NOT NULL COMMENT 'Denormalizzato per filtri/authz',
question_code VARCHAR(50) NULL COMMENT 'Codice stabile opzionale, es. gvsc07_a_access_level',
question_text TEXT NOT NULL,
question_type ENUM('yes_no_partial','single_choice','multi_choice','scale_1_5','text','number','file') NOT NULL DEFAULT 'yes_no_partial',
options JSON NULL COMMENT 'Opzioni per single_choice/multi_choice/scale',
weight DECIMAL(6,2) NOT NULL DEFAULT 1.00 COMMENT 'Peso nello scoring',
is_required TINYINT(1) NOT NULL DEFAULT 1,
order_index INT NOT NULL DEFAULT 0 COMMENT 'Ordinamento di presentazione',
nis2_ref VARCHAR(40) NULL COMMENT 'Riferimento normativo es. GV.SC-07.a',
vuln_flag VARCHAR(64) NULL COMMENT 'Flag vulnerabilita acceso da risposta negativa',
high_criticality_only TINYINT(1) NOT NULL DEFAULT 0 COMMENT '1 = mostra solo a fornitori high/critical',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
KEY idx_qq_template_order (template_id, order_index),
KEY idx_qq_org (organization_id),
CONSTRAINT fk_qq_template FOREIGN KEY (template_id) REFERENCES questionnaire_templates(id) ON DELETE CASCADE,
CONSTRAINT fk_qq_org FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='Domande del template questionario fornitori (ordinabili e pesate)';
-- 5. Campagne (SOSTITUISCE supplier_questionnaires di mig 027)
CREATE TABLE IF NOT EXISTS questionnaire_campaigns (
id INT NOT NULL AUTO_INCREMENT,
organization_id INT NOT NULL,
supplier_id INT NOT NULL,
template_id INT NULL COMMENT 'Template usato (NULL se importata da 027 legacy)',
template_version VARCHAR(20) NULL COMMENT 'Versione template congelata all''invio',
access_token_hash CHAR(64) NOT NULL COMMENT 'SHA-256 del token sq_ inviato (retrocompat 027)',
status ENUM('draft','sent','in_progress','completed','expired','cancelled') NOT NULL DEFAULT 'sent',
score INT NULL COMMENT 'Punteggio 0-100 calcolato dalle risposte',
risk_level ENUM('low','medium','high','critical') NULL,
answers JSON NULL COMMENT 'Cache aggregata risposte (la verita e in questionnaire_answers)',
show_score_to_supplier TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'F23: default OFF',
language ENUM('it','en','auto') NOT NULL DEFAULT 'it' COMMENT 'Lingua portale/email',
sent_to_email VARCHAR(255) NULL,
sent_at DATETIME NULL,
due_at DATETIME NULL COMMENT 'Scadenza compilazione',
completed_at DATETIME NULL,
expires_at DATETIME NULL,
reminder_count INT NOT NULL DEFAULT 0,
last_reminder_at DATETIME NULL,
next_reminder_at DATETIME NULL COMMENT 'Prossimo invio reminder (cron)',
reminder_offsets JSON NULL COMMENT 'Offset reminder, es. [-14,-7,-1]',
is_recurring TINYINT(1) NOT NULL DEFAULT 0,
recurrence_months TINYINT UNSIGNED NULL COMMENT 'Periodicita in mesi, se ricorrente',
next_recurrence_at DATETIME NULL COMMENT 'Prossima riemissione (cron)',
created_by INT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uq_qc_token (access_token_hash),
KEY idx_qc_org (organization_id),
KEY idx_qc_supplier (supplier_id),
KEY idx_qc_template (template_id),
KEY idx_qc_status_reminder (status, next_reminder_at),
KEY idx_qc_status_due (status, due_at),
KEY idx_qc_status_recurrence (status, next_recurrence_at),
CONSTRAINT fk_qc_org FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE,
CONSTRAINT fk_qc_supplier FOREIGN KEY (supplier_id) REFERENCES suppliers(id) ON DELETE CASCADE,
CONSTRAINT fk_qc_template FOREIGN KEY (template_id) REFERENCES questionnaire_templates(id) ON DELETE SET NULL,
CONSTRAINT fk_qc_creator FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='Campagne questionario inviate ai fornitori (sostituisce supplier_questionnaires/027)';
-- 6. Risposte puntuali (answer_value JSON-ready per multi_choice/file)
CREATE TABLE IF NOT EXISTS questionnaire_answers (
id INT NOT NULL AUTO_INCREMENT,
campaign_id INT NOT NULL,
organization_id INT NOT NULL COMMENT 'Denormalizzato NOT NULL per filtri/authz',
question_id INT NULL COMMENT 'FK domanda live (NULL se domanda legacy/snapshot)',
question_code VARCHAR(50) NULL COMMENT 'Codice domanda dallo snapshot, per storicizzazione',
answer_value JSON NULL COMMENT 'Valore risposta JSON-ready (scalare/array per multi_choice/file)',
answer_text TEXT NULL COMMENT 'Testo libero',
file_ref VARCHAR(255) NULL COMMENT 'Riferimento allegato',
score_awarded DECIMAL(6,2) NULL COMMENT 'Punteggio attribuito alla risposta',
answered_by INT NULL COMMENT 'supplier_user_id che ha risposto (multi-referente)',
answered_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uq_qa_campaign_question (campaign_id, question_id),
KEY idx_qa_org (organization_id),
KEY idx_qa_question (question_id),
CONSTRAINT fk_qa_campaign FOREIGN KEY (campaign_id) REFERENCES questionnaire_campaigns(id) ON DELETE CASCADE,
CONSTRAINT fk_qa_org FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE,
CONSTRAINT fk_qa_question FOREIGN KEY (question_id) REFERENCES questionnaire_questions(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='Risposte puntuali alle campagne questionario (answer_value JSON-ready, append-only)';
-- 7. Seed idempotente preset di sistema (organization_id = 0) - 10 categorie
INSERT INTO supplier_categories (organization_id, slug, name, description, is_system)
VALUES
(0, 'cloud_provider', 'Cloud Provider (IaaS/PaaS/SaaS)', 'Fornitori di servizi cloud e hosting', 1),
(0, 'managed_security', 'Managed Security / MSSP', 'SOC, MDR, gestione sicurezza in outsourcing', 1),
(0, 'connectivity_telco', 'Connettivita / Telco', 'Operatori di rete, ISP, connettivita', 1),
(0, 'hardware_supplier', 'Fornitore Hardware', 'Apparati di rete, server, dispositivi', 1),
(0, 'software_vendor', 'Software Vendor / SaaS applicativo', 'Vendor di software applicativo e licenze', 1),
(0, 'professional_services', 'Servizi Professionali / Consulenza', 'Consulenza IT, legale, audit, compliance', 1),
(0, 'logistics', 'Logistica e Trasporti', 'Fornitori di logistica, magazzino, spedizioni', 1),
(0, 'facility_management', 'Facility Management', 'Gestione immobili, energia, impianti, sicurezza fisica',1),
(0, 'datacenter_colocation', 'Data center / Colocation', 'Data center, housing, colocation, infrastruttura fisica',1),
(0, 'tic_software_dev', 'TIC / Software Development', 'Sviluppo software su commessa, system integration, ICT', 1)
ON DUPLICATE KEY UPDATE
name = VALUES(name),
description = VALUES(description),
is_system = VALUES(is_system),
updated_at = CURRENT_TIMESTAMP;
-- ROLLBACK (manuale, ordine inverso per le FK):
-- DROP TABLE IF EXISTS questionnaire_answers;
-- DROP TABLE IF EXISTS questionnaire_campaigns;
-- DROP TABLE IF EXISTS questionnaire_questions;
-- DROP TABLE IF EXISTS questionnaire_template_versions;
-- DROP TABLE IF EXISTS questionnaire_templates;
-- DELETE FROM supplier_categories WHERE organization_id = 0 AND is_system = 1;
-- DROP TABLE IF EXISTS supplier_categories;
@@ -0,0 +1,79 @@
-- ============================================================================
-- Migration 033 - suppliers: categoria, provenienza import, soft-delete (guard)
-- ----------------------------------------------------------------------------
-- PROPOSTA DI DESIGN (NON ancora applicata). Da eseguire su host MySQL.
--
-- Estende suppliers per il nuovo modulo questionari/import:
-- - category_id : FK supplier_categories (classificazione fornitore)
-- - external_ref : ID nel sistema sorgente (dedup import API/CSV)
-- - source : ENUM('manual','csv','api') provenienza del record
-- - deleted_at : soft-delete -- GIA PRESENTE da mig 006; aggiunto solo se assente
-- + UNIQUE(organization_id, external_ref) per upsert idempotente.
--
-- NOTA REALE: suppliers.deleted_at e stato aggiunto dalla migration 006.
-- Questo script lo riaggiunge SOLO se mancante. Nessuna duplicazione.
--
-- IMPORTANTE: MySQL 8 Ubuntu NON supporta "ADD COLUMN IF NOT EXISTS".
-- Procedura idempotente su information_schema. DELIMITER -> usare "source".
-- mysql -h localhost nis2_agile_db -e "source docs/sql/033_suppliers_category_source.sql"
-- ============================================================================
DELIMITER //
DROP PROCEDURE IF EXISTS _mig033 //
CREATE PROCEDURE _mig033()
BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='suppliers' AND COLUMN_NAME='category_id') THEN
ALTER TABLE suppliers ADD COLUMN category_id INT NULL
COMMENT 'FK supplier_categories: classificazione fornitore' AFTER service_type;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='suppliers' AND COLUMN_NAME='external_ref') THEN
ALTER TABLE suppliers ADD COLUMN external_ref VARCHAR(190) NULL
COMMENT 'ID fornitore nel sistema sorgente (dedup import API/CSV)' AFTER vat_number;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='suppliers' AND COLUMN_NAME='source') THEN
ALTER TABLE suppliers ADD COLUMN source ENUM('manual','csv','api') NOT NULL DEFAULT 'manual'
COMMENT 'Provenienza del record fornitore' AFTER external_ref;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='suppliers' AND COLUMN_NAME='deleted_at') THEN
ALTER TABLE suppliers ADD COLUMN deleted_at TIMESTAMP NULL DEFAULT NULL AFTER updated_at;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.TABLE_CONSTRAINTS
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='suppliers'
AND CONSTRAINT_NAME='fk_supplier_category' AND CONSTRAINT_TYPE='FOREIGN KEY') THEN
ALTER TABLE suppliers ADD CONSTRAINT fk_supplier_category
FOREIGN KEY (category_id) REFERENCES supplier_categories(id) ON DELETE SET NULL;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='suppliers'
AND INDEX_NAME='uq_supplier_external_ref') THEN
ALTER TABLE suppliers ADD UNIQUE KEY uq_supplier_external_ref (organization_id, external_ref);
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='suppliers'
AND INDEX_NAME='idx_suppliers_not_deleted') THEN
ALTER TABLE suppliers ADD INDEX idx_suppliers_not_deleted (organization_id, deleted_at);
END IF;
END //
DELIMITER ;
CALL _mig033();
DROP PROCEDURE IF EXISTS _mig033;
-- ROLLBACK (manuale):
-- ALTER TABLE suppliers
-- DROP FOREIGN KEY fk_supplier_category,
-- DROP INDEX uq_supplier_external_ref,
-- DROP INDEX idx_suppliers_not_deleted,
-- DROP COLUMN category_id,
-- DROP COLUMN external_ref,
-- DROP COLUMN source;
-- -- NB: NON droppare deleted_at (di proprieta della mig 006).
+85
View File
@@ -0,0 +1,85 @@
-- ============================================================================
-- Migration 034 - Portale fornitore: utenti, OTP/magic-link, sessioni revocabili
-- ----------------------------------------------------------------------------
-- PROPOSTA DI DESIGN (NON ancora applicata). Da eseguire su host MySQL.
--
-- Login passwordless del fornitore per compilare le campagne questionario:
-- supplier_users = referenti del fornitore abilitati al portale
-- supplier_otp = codici OTP + magic token (hash), tentativi, scadenza
-- supplier_sessions = sessioni con jti, revocabili (revoked_at)
--
-- Tutto NO-PASSWORD: l'accesso avviene via OTP/magic-link consegnato all'email.
-- Sicurezza: lockout persistente su supplier_otp.attempts; sessione revocabile.
--
-- CREATE TABLE IF NOT EXISTS (idempotente). DELIMITER -> usare "source".
-- mysql -h localhost nis2_agile_db -e "source docs/sql/034_supplier_portal_auth.sql"
-- ============================================================================
-- 1. Utenti del fornitore (referenti abilitati al portale)
CREATE TABLE IF NOT EXISTS supplier_users (
id INT NOT NULL AUTO_INCREMENT,
supplier_id INT NOT NULL,
organization_id INT NOT NULL COMMENT 'Denormalizzato: org committente proprietaria del fornitore',
email VARCHAR(255) NOT NULL,
full_name VARCHAR(255) NULL,
role ENUM('contact','compliance','signer') NOT NULL DEFAULT 'contact',
is_active TINYINT(1) NOT NULL DEFAULT 1,
last_login_at DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uq_supuser_supplier_email (supplier_id, email),
KEY idx_supuser_org (organization_id),
KEY idx_supuser_email (email),
CONSTRAINT fk_supuser_supplier FOREIGN KEY (supplier_id) REFERENCES suppliers(id) ON DELETE CASCADE,
CONSTRAINT fk_supuser_org FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='Referenti del fornitore abilitati al portale (login passwordless)';
-- 2. OTP + magic-link (hash, tentativi, scadenza)
CREATE TABLE IF NOT EXISTS supplier_otp (
id INT NOT NULL AUTO_INCREMENT,
supplier_user_id INT NOT NULL,
otp_hash CHAR(64) NULL COMMENT 'SHA-256 del codice OTP numerico',
magic_token_hash CHAR(64) NULL COMMENT 'SHA-256 del magic-link token',
purpose ENUM('login','questionnaire') NOT NULL DEFAULT 'login',
attempts INT NOT NULL DEFAULT 0 COMMENT 'Tentativi di verifica falliti (lockout persistente)',
max_attempts INT NOT NULL DEFAULT 5,
locked_until DATETIME NULL COMMENT 'Lockout temporaneo dopo max_attempts',
consumed_at DATETIME NULL COMMENT 'Quando usato (single-use)',
expires_at DATETIME NOT NULL,
ip_created VARCHAR(45) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
KEY idx_sotp_user (supplier_user_id),
KEY idx_sotp_expires (expires_at),
KEY idx_sotp_magic (magic_token_hash),
CONSTRAINT fk_sotp_user FOREIGN KEY (supplier_user_id) REFERENCES supplier_users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='OTP e magic-link per autenticazione passwordless del fornitore';
-- 3. Sessioni revocabili (jti, scadenza, revoca)
CREATE TABLE IF NOT EXISTS supplier_sessions (
id INT NOT NULL AUTO_INCREMENT,
supplier_user_id INT NOT NULL,
organization_id INT NOT NULL COMMENT 'Denormalizzato per filtri/audit',
jti CHAR(36) NOT NULL COMMENT 'JWT ID (UUID) della sessione',
ip_address VARCHAR(45) NULL,
user_agent VARCHAR(500) NULL,
expires_at DATETIME NOT NULL,
revoked_at DATETIME NULL COMMENT 'NOT NULL => sessione revocata',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uq_ssess_jti (jti),
KEY idx_ssess_user (supplier_user_id),
KEY idx_ssess_org (organization_id),
KEY idx_ssess_expires (expires_at),
CONSTRAINT fk_ssess_user FOREIGN KEY (supplier_user_id) REFERENCES supplier_users(id) ON DELETE CASCADE,
CONSTRAINT fk_ssess_org FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='Sessioni portale fornitore, revocabili via revoked_at';
-- ROLLBACK (manuale, ordine inverso per le FK):
-- DROP TABLE IF EXISTS supplier_sessions;
-- DROP TABLE IF EXISTS supplier_otp;
-- DROP TABLE IF EXISTS supplier_users;
+69
View File
@@ -0,0 +1,69 @@
-- ============================================================================
-- Migration 035 - Migrazione dati supplier_questionnaires (027) -> campaigns
-- ----------------------------------------------------------------------------
-- PROPOSTA DI DESIGN (NON ancora applicata). Da eseguire PER ULTIMA su host MySQL.
--
-- Travasa le righe di supplier_questionnaires (mig 027) in
-- questionnaire_campaigns (mig 032), preservando i token sq_ gia inviati:
-- token_hash (027) -> access_token_hash (032) [stesso SHA-256]
-- status (027: sent|completed|expired) -> status (032 ENUM esteso)
-- answers/score/risk_level/email/date -> mappatura 1:1
--
-- Idempotente: INSERT ... SELECT con anti-join su access_token_hash (UNIQUE),
-- quindi rilanciabile senza duplicare. template_id resta NULL (legacy).
-- 027 NON viene droppata: resta read-only/deprecata per audit storico.
--
-- DIPENDE da: 032 (questionnaire_campaigns deve esistere) e 027.
-- mysql -h localhost nis2_agile_db -e "source docs/sql/035_migrate_027_to_campaigns.sql"
--
-- Ordine globale: 032 -> 033 -> 034 -> 035.
-- ============================================================================
DELIMITER //
DROP PROCEDURE IF EXISTS _mig035 //
CREATE PROCEDURE _mig035()
BEGIN
IF EXISTS (SELECT 1 FROM information_schema.TABLES
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='supplier_questionnaires')
AND EXISTS (SELECT 1 FROM information_schema.TABLES
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='questionnaire_campaigns')
THEN
INSERT INTO questionnaire_campaigns
(organization_id, supplier_id, template_id, template_version,
access_token_hash, status, score, risk_level, answers,
sent_to_email, sent_at, completed_at, expires_at, created_at)
SELECT
sq.organization_id,
sq.supplier_id,
NULL AS template_id,
NULL AS template_version,
sq.token_hash AS access_token_hash,
CASE sq.status
WHEN 'sent' THEN 'sent'
WHEN 'completed' THEN 'completed'
WHEN 'expired' THEN 'expired'
ELSE 'sent'
END AS status,
sq.score,
sq.risk_level,
sq.answers,
sq.sent_to_email,
sq.sent_at,
sq.completed_at,
sq.expires_at,
sq.created_at
FROM supplier_questionnaires sq
LEFT JOIN questionnaire_campaigns qc
ON qc.access_token_hash = sq.token_hash
WHERE qc.id IS NULL; -- anti-join: salta quelle gia migrate (idempotenza)
END IF;
END //
DELIMITER ;
CALL _mig035();
DROP PROCEDURE IF EXISTS _mig035;
-- ROLLBACK (manuale): cancella SOLO le campagne legacy importate (template_id NULL,
-- token corrispondente in supplier_questionnaires). NON tocca le campagne native.
-- DELETE qc FROM questionnaire_campaigns qc
-- JOIN supplier_questionnaires sq ON sq.token_hash = qc.access_token_hash
-- WHERE qc.template_id IS NULL;