[DEMO] Avatar di prodotto Fase 1 — fondazione backend (5 endpoint /api/demo/*)
product-demo-protocol v1.0.1, Modalita A. Additivo (zero impatto endpoint esistenti). - DemoController: session-start, manifest, event, credentials, reset-dataset * demo_jwt scope=demo:read-only, org_id 996001 (range demo 996000-996999), TTL 30m * hmac_key_seed base64 44ch, rate-limit 5/min+50/h per IP, eventi idempotenti - Migration 040: tabelle demo_sessions + demo_events - Rotte 'demo' in index.php (chiavi camelCase: router camelCasa il segmento URL) - docs/DEMO_AVATAR_NIS2.md: piano Fase1/Fase2, decisioni, punti integrazione AgileHub, gap protocollo Smoke prod OK: session-start 201, manifest 200, credentials 200, event 204, reset 401(no key). TODO Fase 1: read-only guard in requireAuth, dataset demo 996001, frontend demo-mode, manifest+mappe. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
2b197440f6
commit
e1ab0b3ea0
@@ -0,0 +1,244 @@
|
||||
<?php
|
||||
/**
|
||||
* NIS2 Agile - Demo Controller (Avatar di prodotto, product-demo-protocol v1.0.1)
|
||||
*
|
||||
* 5 endpoint per la modalità demo guidata dall'avatar (Modalità A, prodotto live).
|
||||
* Doc: docs/DEMO_AVATAR_NIS2.md | Standard: agile-services/docs/STANDARD_PRODUCT_DEMO_PROTOCOL.md
|
||||
*
|
||||
* NB: questi endpoint NON usano requireAuth() — auth dedicata demo (JWT scope=demo:read-only
|
||||
* / X-Internal-Key). Il riconoscimento del demo_jwt sulle API app è in BaseController (step a parte).
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/BaseController.php';
|
||||
|
||||
class DemoController extends BaseController
|
||||
{
|
||||
/** Range org riservato demo NIS2 (standard §7.1) */
|
||||
private const DEMO_ORG_ID = 996001;
|
||||
private const DEMO_RANGE = [996000, 996999];
|
||||
private const JWT_TTL_SEC = 1800; // 30 min
|
||||
private const DEMO_BASE = 'https://dimostrazione.agile.software';
|
||||
private const RL_DIR = '/tmp/nis2_demo_rl';
|
||||
|
||||
// ── 1) POST /api/demo/session-start ──────────────────────────────────────
|
||||
public function sessionStart(): void
|
||||
{
|
||||
$ip = $this->clientIp();
|
||||
if (!$this->rateLimit($ip)) {
|
||||
header('Retry-After: 60');
|
||||
$this->jsonError('Troppe richieste demo, riprova tra poco', 429, 'RATE_LIMIT_EXCEEDED');
|
||||
}
|
||||
|
||||
$language = in_array($this->getParam('language'), ['it', 'en'], true) ? $this->getParam('language') : 'it';
|
||||
$variant = in_array($this->getParam('tour_variant'), ['full', 'express'], true) ? $this->getParam('tour_variant') : 'full';
|
||||
|
||||
$sessionId = 'demo-' . $this->uuid4();
|
||||
$hmacKeySeed = base64_encode(random_bytes(32)); // 44 char base64 standard
|
||||
$orgId = self::DEMO_ORG_ID;
|
||||
$expiresTs = time() + self::JWT_TTL_SEC;
|
||||
|
||||
$demoJwt = $this->generateJWT(0, [
|
||||
'scope' => 'demo:read-only',
|
||||
'org_id' => $orgId,
|
||||
'demo_session_id' => $sessionId,
|
||||
'exp' => $expiresTs,
|
||||
]);
|
||||
|
||||
// ip_hash server-side (anti-spoof): sha256(ip + daily salt)
|
||||
$ipHash = hash('sha256', $ip . '|' . date('Y-m-d') . '|' . JWT_SECRET);
|
||||
|
||||
Database::insert('demo_sessions', [
|
||||
'session_id' => $sessionId,
|
||||
'organization_id' => $orgId,
|
||||
'hmac_key_seed' => $hmacKeySeed,
|
||||
'language' => $language,
|
||||
'tour_variant' => $variant,
|
||||
'presentation_mode' => 'realtime',
|
||||
'referrer_url' => mb_substr((string) $this->getParam('referrer_url', ''), 0, 512) ?: null,
|
||||
'user_agent' => mb_substr((string) ($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 512) ?: null,
|
||||
'ip_hash' => $ipHash,
|
||||
'expires_at' => date('Y-m-d H:i:s', $expiresTs),
|
||||
]);
|
||||
|
||||
$this->jsonSuccess([
|
||||
'session_id' => $sessionId,
|
||||
'demo_jwt' => $demoJwt,
|
||||
'company_id' => $orgId, // alias di compatibilità standard
|
||||
'organization_id' => $orgId,
|
||||
'spa_iframe_url' => self::DEMO_BASE . '/?demo=' . $sessionId,
|
||||
'manifest_url' => self::DEMO_BASE . '/api/demo/manifest?session_id=' . $sessionId,
|
||||
'credentials_url' => self::DEMO_BASE . '/api/demo/credentials?session_id=' . $sessionId,
|
||||
'expires_at' => gmdate('Y-m-d\TH:i:s\Z', $expiresTs),
|
||||
'hmac_key_seed' => $hmacKeySeed,
|
||||
'presentation_mode' => 'realtime',
|
||||
], 'Sessione demo creata', 201);
|
||||
}
|
||||
|
||||
// ── 2) GET /api/demo/manifest?session_id= ────────────────────────────────
|
||||
public function manifest(): void
|
||||
{
|
||||
$session = $this->resolveSession();
|
||||
if (!$session) {
|
||||
$this->jsonError('Sessione demo non valida o scaduta', 401, 'DEMO_SESSION_INVALID');
|
||||
}
|
||||
|
||||
$lang = in_array($this->getParam('language'), ['it', 'en'], true)
|
||||
? $this->getParam('language') : ($session['language'] ?? 'it');
|
||||
|
||||
$file = PUBLIC_PATH . '/demo/nis2-tour-2026.json';
|
||||
if (is_file($file)) {
|
||||
$manifest = json_decode((string) file_get_contents($file), true) ?: [];
|
||||
$manifest['session_id'] = $session['session_id'];
|
||||
$manifest['language'] = $lang;
|
||||
$this->jsonSuccess($manifest);
|
||||
}
|
||||
|
||||
// Placeholder finché il manifest reale non è pubblicato (Fase 1, step successivo)
|
||||
$this->jsonSuccess([
|
||||
'tour_id' => 'demo:nis2-tour-2026',
|
||||
'tour_version' => '0.1',
|
||||
'session_id' => $session['session_id'],
|
||||
'language' => $lang,
|
||||
'tour_variant' => $session['tour_variant'] ?? 'full',
|
||||
'presentation_mode' => 'realtime',
|
||||
'persona_key' => 'ARIA_SUPPORT_NIS2',
|
||||
'control_protocol' => 'agilehub.product-demo.v1',
|
||||
'iframe_origin' => self::DEMO_BASE,
|
||||
'steps' => [],
|
||||
'fallback_static' => ['image_url' => null, 'audio_url_it' => null, 'audio_url_en' => null],
|
||||
'_note' => 'manifest placeholder — steps in arrivo (Fase 1)',
|
||||
]);
|
||||
}
|
||||
|
||||
// ── 3) POST /api/demo/event ──────────────────────────────────────────────
|
||||
public function event(): void
|
||||
{
|
||||
$session = $this->resolveSession();
|
||||
if (!$session) {
|
||||
$this->jsonError('Sessione demo non valida o scaduta', 401, 'DEMO_SESSION_INVALID');
|
||||
}
|
||||
|
||||
$eventType = (string) $this->getParam('event_type', '');
|
||||
$allowed = ['step_completed','step_skipped','tour_paused','tour_aborted','free_question_asked','cta_clicked','presentation_mode_chosen','error_encountered'];
|
||||
if (!in_array($eventType, $allowed, true)) {
|
||||
$this->jsonError('event_type non valido', 400, 'INVALID_EVENT_TYPE');
|
||||
}
|
||||
|
||||
$stepId = $this->getParam('step_id');
|
||||
$tsIso = mb_substr((string) $this->getParam('client_timestamp_iso', gmdate('Y-m-d\TH:i:s\Z')), 0, 40);
|
||||
$meta = $this->getParam('metadata');
|
||||
|
||||
// Idempotente: UNIQUE (session_id, event_type, step_id, client_timestamp_iso)
|
||||
try {
|
||||
Database::insert('demo_events', [
|
||||
'session_id' => $session['session_id'],
|
||||
'event_type' => $eventType,
|
||||
'step_id' => $stepId !== null ? (int) $stepId : null,
|
||||
'metadata' => $meta !== null ? json_encode($meta, JSON_UNESCAPED_UNICODE) : null,
|
||||
'client_timestamp_iso' => $tsIso,
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
// duplicato (replay idempotente) → ignora silenziosamente
|
||||
}
|
||||
|
||||
http_response_code(204);
|
||||
exit;
|
||||
}
|
||||
|
||||
// ── 4) GET /api/demo/credentials?session_id= ─────────────────────────────
|
||||
public function credentials(): void
|
||||
{
|
||||
$sessionId = (string) $this->getParam('session_id', '');
|
||||
$session = $sessionId !== '' ? $this->loadSession($sessionId) : null;
|
||||
if (!$session) {
|
||||
$this->jsonError('Sessione demo non trovata o scaduta', 404, 'DEMO_SESSION_NOT_FOUND');
|
||||
}
|
||||
|
||||
$expiresTs = strtotime($session['expires_at']);
|
||||
$demoJwt = $this->generateJWT(0, [
|
||||
'scope' => 'demo:read-only',
|
||||
'org_id' => (int) $session['organization_id'],
|
||||
'demo_session_id' => $session['session_id'],
|
||||
'exp' => $expiresTs,
|
||||
]);
|
||||
|
||||
$this->jsonSuccess([
|
||||
'demo_jwt' => $demoJwt,
|
||||
'company_id' => (int) $session['organization_id'],
|
||||
'organization_id' => (int) $session['organization_id'],
|
||||
'session_id' => $session['session_id'],
|
||||
'expires_at' => gmdate('Y-m-d\TH:i:s\Z', $expiresTs),
|
||||
]);
|
||||
}
|
||||
|
||||
// ── 5) POST /api/demo/reset-dataset ──────────────────────────────────────
|
||||
public function resetDataset(): void
|
||||
{
|
||||
$key = $_SERVER['HTTP_X_INTERNAL_KEY'] ?? '';
|
||||
$expected = getenv('INTERNAL_DEMO_KEY') ?: (getenv('INTERNAL_EMAIL_KEY') ?: '');
|
||||
if ($expected === '' || !hash_equals($expected, $key)) {
|
||||
$this->jsonError('Non autorizzato', 401, 'UNAUTHORIZED');
|
||||
}
|
||||
|
||||
// TODO Fase 1 (increment dataset): DELETE + re-seed org demo 996001 da seed idempotente.
|
||||
$this->jsonSuccess([
|
||||
'reset' => 'pending_implementation',
|
||||
'company_ids' => [self::DEMO_ORG_ID],
|
||||
'note' => 'Logica di reset dataset demo in arrivo (seed isolato org 996001).',
|
||||
], 'Reset demo (stub)');
|
||||
}
|
||||
|
||||
// ═══ Helpers ═════════════════════════════════════════════════════════════
|
||||
|
||||
/** Risolve la sessione da Bearer demo_jwt (preferito) o ?session_id=. */
|
||||
private function resolveSession(): ?array
|
||||
{
|
||||
$token = $this->getBearerToken();
|
||||
if ($token) {
|
||||
$payload = $this->verifyJWT($token);
|
||||
if ($payload && ($payload['scope'] ?? '') === 'demo:read-only' && !empty($payload['demo_session_id'])) {
|
||||
return $this->loadSession((string) $payload['demo_session_id']);
|
||||
}
|
||||
}
|
||||
$sid = (string) $this->getParam('session_id', '');
|
||||
return $sid !== '' ? $this->loadSession($sid) : null;
|
||||
}
|
||||
|
||||
private function loadSession(string $sessionId): ?array
|
||||
{
|
||||
$s = Database::fetchOne('SELECT * FROM demo_sessions WHERE session_id = ?', [$sessionId]);
|
||||
if (!$s) return null;
|
||||
if (strtotime($s['expires_at']) < time()) return null; // scaduta
|
||||
return $s;
|
||||
}
|
||||
|
||||
/** Rate-limit file-based: 5/min + 50/h per IP. true = consentito. */
|
||||
private function rateLimit(string $ip): bool
|
||||
{
|
||||
if (!is_dir(self::RL_DIR)) @mkdir(self::RL_DIR, 0700, true);
|
||||
$f = self::RL_DIR . '/' . md5($ip) . '.json';
|
||||
$now = time();
|
||||
$hits = is_file($f) ? (json_decode((string) file_get_contents($f), true) ?: []) : [];
|
||||
$hits = array_values(array_filter($hits, fn($t) => $t > $now - 3600));
|
||||
$lastMin = count(array_filter($hits, fn($t) => $t > $now - 60));
|
||||
if ($lastMin >= 5 || count($hits) >= 50) return false;
|
||||
$hits[] = $now;
|
||||
@file_put_contents($f, json_encode($hits), LOCK_EX);
|
||||
return true;
|
||||
}
|
||||
|
||||
private function uuid4(): string
|
||||
{
|
||||
$b = random_bytes(16);
|
||||
$b[6] = chr((ord($b[6]) & 0x0f) | 0x40);
|
||||
$b[8] = chr((ord($b[8]) & 0x3f) | 0x80);
|
||||
return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($b), 4));
|
||||
}
|
||||
|
||||
private function clientIp(): string
|
||||
{
|
||||
$xff = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? '';
|
||||
if ($xff !== '') return trim(explode(',', $xff)[0]);
|
||||
return $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
# Avatar di prodotto NIS2 — Demo + Formazione (standard product-demo-complete)
|
||||
|
||||
> Stato: **Fase 1 (DEMO) in costruzione**. Owner prodotto: NIS2. Owner persona/voce/collaudo: AgileHub.
|
||||
> Persona già LIVE: `ARIA_SUPPORT_NIS2` (id=4). Voce ARIA riusata (niente consenso voce).
|
||||
> Modalità **A** (prodotto live: l'avatar pilota la SPA reale).
|
||||
|
||||
## Decisioni prese (2026-06-12)
|
||||
1. **Scopo**: ENTRAMBI, in 2 fasi → Fase 1 DEMO (vetrina prospect ~10 step) poi Fase 2 FORMAZIONE (riusa i mattoni + curriculum/quiz/attestato).
|
||||
2. **Completamento formazione**: **esame con soglia** (quiz valutato da LLM-giudice, attestato solo se ≥ soglia, es. 80%). Coerente col gate G7.
|
||||
3. **Dominio same-origin**: **dimostrazione.agile.software** (come ALLTAX/TRPG): landing serve la SPA NIS2 in iframe same-origin con proxy `/api/*` → backend NIS2.
|
||||
|
||||
## Range riservato demo (NIS2)
|
||||
`organization_id` **996000–996999** (per standard §7.1). Org demo isolata: **996001** (ente fittizio). Separata dalle org reali e dalle 2 golden-demo (#151/#152).
|
||||
|
||||
## Deliverable lato prodotto — DEMO (punti 1–5 del brief)
|
||||
- [x] **Migration** `docs/sql/040_demo_sessions.sql` — tabelle `demo_sessions` + `demo_events`.
|
||||
- [x] **DemoController** (`application/controllers/DemoController.php`) — 5 endpoint (vedi sotto).
|
||||
- [x] **Rotte** `demo` registrate in `public/index.php`.
|
||||
- [ ] **Guardia read-only + demo-JWT in `requireAuth`** (BaseController) — *step isolato, delicato: tocca l'auth di ogni endpoint*. Design sotto §Read-only.
|
||||
- [ ] **Dataset demo isolato** (org 996001 + dati finti) + reset.
|
||||
- [ ] **Frontend**: `public/js/demo-mode.js`, `public/js/demo-selectors.js`, `public/css/demo.css`, hook `?demo=` nell'entry-point.
|
||||
- [ ] **Manifest** `public/demo/nis2-tour-2026.json` (~10 step IT; EN dopo).
|
||||
- [ ] **Mappe** `docs/mappa-logica/*.md` (1 per vista) + `SELETTORI_DEMO.md` + anchor `data-demo-action` + id stabili nel DOM.
|
||||
|
||||
## Deliverable IN PIÙ — FORMAZIONE (punti 6–7, dopo la demo)
|
||||
- [ ] Mappa-logica **COMPLETA** (tutte le viste, non solo le 10 della demo).
|
||||
- [ ] **Tassonomia corso** (moduli → lezioni → argomenti, prerequisiti, durata). *Formato JSON da definire (lo standard non lo fissa).*
|
||||
- [ ] (opz.) banca esercizi/domande per modulo + sandbox scrivibile isolata.
|
||||
|
||||
## Endpoint (route shape scelta)
|
||||
Per non combattere il router NIS2 (path-param numerici), il `session_id` (stringa `demo-<uuid>`) viaggia in **query/body**, non in path. Le URL esatte che userà il widget sono quelle che ritorno io in `session-start` (`manifest_url`, `credentials_url`), quindi è conforme.
|
||||
|
||||
| Endpoint | Metodo | Auth | Note |
|
||||
|---|---|---|---|
|
||||
| `/api/demo/session-start` | POST | pubblico (rate-limit 5/min, 50/h per IP) | ritorna session_id, demo_jwt (scope=demo:read-only, org_id 996001, TTL 30m), hmac_key_seed (base64 44ch), spa_iframe_url, manifest_url, credentials_url, expires_at, presentation_mode |
|
||||
| `/api/demo/manifest?session_id=` | GET | Bearer demo_jwt o session_id | ritorna il manifest tour |
|
||||
| `/api/demo/event` | POST | Bearer demo_jwt | telemetria step (idempotente) |
|
||||
| `/api/demo/credentials?session_id=` | GET | session valida (same-origin/bearer) | ritorna demo_jwt+org+expires (per bootstrap SPA in iframe) |
|
||||
| `/api/demo/reset-dataset` | POST | `X-Internal-Key` | reset dataset demo (cron 0 3 * * *) |
|
||||
|
||||
## Read-only enforcement (design — da implementare con cura)
|
||||
Il demo_jwt è messo dalla SPA in `localStorage.jwt` e usato sulle API reali (dashboard, risks, ...). Quindi `BaseController::requireAuth()` deve **riconoscere** il demo_jwt:
|
||||
- se `payload.scope === 'demo:read-only'`: **bypassa** il check `jti`/`active_sessions`, imposta org corrente = `payload.org_id` (forzato nel range 996000–996999), e **blocca ogni verbo di scrittura** (POST/PUT/PATCH/DELETE) sulle API app → `403 DEMO_READ_ONLY` (eccetto gli endpoint `/api/demo/*`);
|
||||
- range guard: demo_jwt fuori range → `403 DEMO_JWT_CANNOT_ACCESS_PRODUCTION`; JWT di produzione su org in range demo → `403 PRODUCTION_JWT_CANNOT_ACCESS_DEMO`.
|
||||
- `getCurrentOrgId()` in demo ignora `X-Organization-Id` e usa l'org del JWT.
|
||||
|
||||
## Punti di integrazione DA CONFERMARE con AgileHub (gap dello standard)
|
||||
1. **Canonical HMAC del canale postMessage**: lo standard ha **2 versioni divergenti** di `signDemoMessage`/`verifyDemoMessage` (ordine canonical `{message_id,...}` vs `{message_id,session_id,...}`, channelKey CryptoKey vs raw). Il `demo-mode.js` di NIS2 deve combaciare **byte-per-byte** col widget AgileHub deployato → **confermare quale canonical usa il widget live** prima dello smoke.
|
||||
2. **Nome storage seed**: `bootstrapDemoAuth` legge `sessionStorage['demo_hmac_seed']` ma il campo API è `hmac_key_seed` → allineare chi scrive il seed in sessionStorage (widget) e chi lo legge (SPA).
|
||||
3. **Same-origin / proxy** su `dimostrazione.agile.software` → `/api/*` verso backend NIS2 (config gateway lato AgileHub) + CSP che permetta l'iframe.
|
||||
4. **Catalog/binding tour**: lato AgileHub servono `provider_resources_catalog (demo_tour)` + `agent_resource_bindings (demo_tour)` o il bootstrap meet-page aborta "no matching tour".
|
||||
5. **Allineamento 3-way** DOM ↔ `demo-selectors.js` ↔ `SELETTORI_DEMO.md`: ogni cambio selettore → avvisare AgileHub per re-ingest RAG (dedup per content_hash → erasure-prima).
|
||||
|
||||
## Collaudo (definizione di "fatto")
|
||||
DEMO: G1 selezione ≥95% PASS · **G2 parlato ≥8/10 e 0 leak** · G3 vista ≤5% falsi positivi · G4 sicurezza/GDPR verde.
|
||||
FORMAZIONE (+): G5 copertura (nessun buco) · G6 accuratezza didattica · G7 validità assessment.
|
||||
> "Fatto" solo quando passa il collaudo (mostra la cosa giusta, dice cose vere) — non quando l'avatar parla.
|
||||
|
||||
## Tour proposto (~10 step, sulla V2 reale)
|
||||
1 Dashboard (score+scadenze) · 2 Assessment (gap Art.21) · 3 Rischi (matrice+trattamenti) · 4 Incidenti Art.23 (24h/72h) · 5 Policy · 6 Supply chain · 7 Asset (relevance GV.OC-04) · 8 Report · 9 Normative/ACN · 10 ARIA (assistente AI).
|
||||
|
||||
## Riferimenti
|
||||
- Standard protocollo: `agile-services/docs/STANDARD_PRODUCT_DEMO_PROTOCOL.md` (v1.0.1)
|
||||
- Standard metodo+collaudo: `agile-services/docs/STANDARD_PRODUCT_DEMO_COMPLETE.md`
|
||||
- Requisiti NIS2: `agile-services/docs/OUTGOING_TO_NIS2_2026_05_29_demo_avatar_requirements.md`
|
||||
- How-to mappe: `agile-services/docs/PLAYBOOK_DEMO_AVATAR_MAPPE.md`
|
||||
- Esempio dev: `agile-services/docs/SPEC_ALLTAX_DEMO_AVATAR_PER_DEV.md`
|
||||
- Formazione: `agile-services/docs/DESIGN_AVATAR_FORMAZIONE.md`
|
||||
@@ -0,0 +1,31 @@
|
||||
-- NIS2 Agile — Migration 040: Demo Avatar (product-demo-protocol v1.0.1)
|
||||
-- Tabelle additive per la modalità demo guidata dall'avatar di prodotto.
|
||||
-- Range org demo riservato: 996000-996999 (org demo isolata = 996001).
|
||||
-- Idempotente: CREATE TABLE IF NOT EXISTS.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS demo_sessions (
|
||||
session_id VARCHAR(64) NOT NULL PRIMARY KEY, -- 'demo-<uuid>'
|
||||
organization_id INT NOT NULL, -- org demo (996000-996999)
|
||||
hmac_key_seed VARCHAR(64) NOT NULL, -- base64 standard 44 char
|
||||
language VARCHAR(5) NOT NULL DEFAULT 'it',
|
||||
tour_variant VARCHAR(16) NOT NULL DEFAULT 'full',
|
||||
presentation_mode VARCHAR(16) NOT NULL DEFAULT 'realtime',
|
||||
referrer_url VARCHAR(512) NULL,
|
||||
user_agent VARCHAR(512) NULL,
|
||||
ip_hash VARCHAR(64) NULL, -- sha256(ip+DAILY_SALT), server-side
|
||||
expires_at DATETIME NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_demo_sessions_expires (expires_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS demo_events (
|
||||
id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
session_id VARCHAR(64) NOT NULL,
|
||||
event_type VARCHAR(40) NOT NULL, -- step_completed | step_skipped | ...
|
||||
step_id INT NULL,
|
||||
metadata JSON NULL,
|
||||
client_timestamp_iso VARCHAR(40) NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uk_demo_event_idem (session_id, event_type, step_id, client_timestamp_iso),
|
||||
INDEX idx_demo_events_session (session_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
@@ -113,6 +113,7 @@ $controllerMap = [
|
||||
'ai' => 'AiController', // Assistente AI ARIA (askWithRag + fonti certe)
|
||||
'branding' => 'BrandingController', // White-label firm (Fase 5 / G16)
|
||||
'supplier-portal' => 'SupplierPortalController', // Portale fornitore Fase 3 (auth OTP/magic-link separata)
|
||||
'demo' => 'DemoController', // Avatar di prodotto — demo guidata (product-demo-protocol v1.0.1)
|
||||
];
|
||||
|
||||
if (!isset($controllerMap[$controllerName])) {
|
||||
@@ -541,6 +542,17 @@ $actionMap = [
|
||||
'GET:current' => 'getCurrent',
|
||||
'PUT:index' => 'update',
|
||||
],
|
||||
|
||||
// ── DemoController — Avatar di prodotto (product-demo-protocol v1.0.1) ──
|
||||
// NB: il router camelCasa il segmento URL (session-start → sessionStart), quindi le chiavi
|
||||
// dei multi-parola vanno in camelCase. URL esterni: /api/demo/session-start, /reset-dataset.
|
||||
'demo' => [
|
||||
'POST:sessionStart' => 'sessionStart',
|
||||
'GET:manifest' => 'manifest',
|
||||
'POST:event' => 'event',
|
||||
'GET:credentials' => 'credentials',
|
||||
'POST:resetDataset' => 'resetDataset',
|
||||
],
|
||||
];
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
Reference in New Issue
Block a user