feat(security+ai): security hardening + FluxAI conversation analytics

Security (critical):
- SESSION_SECRET fail-fast: refuse to boot without a 32+ char secret
  (src/lib/session.ts, src/app/actions/clientAuth.ts)
- Rate limit with pluggable backend: in-memory by default, auto-promotes
  to Upstash Redis when REDIS_URL is set (src/lib/rateLimit.ts)
- CSRF (double-submit HMAC) + Zod validation on /api/consultation;
  new /api/csrf endpoint mints tokens (src/lib/csrf.ts)
- escapeHtml + safeMailto helpers; consultation email template now
  fully escapes user-controlled fields (src/lib/escapeHtml.ts)
- Magic-byte validation for /api/public-upload — rejects HTML/JS
  payloads renamed to .png/.mp4 (src/lib/fileType.ts)
- Nginx: CSP, X-Frame-Options, X-Content-Type-Options, Referrer-Policy,
  Permissions-Policy + 5r/m upload zone for /api/public-upload and
  /api/assets (nginx/conf.d/flux.conf)

Quality:
- Delete GlobalOperations_old.tsx dead code (310 LOC)
- NavBar: replace 2s session polling with CustomEvent("flux:session-
  changed") + visibilitychange listener (no more interval leaks)
- Type-safe CMS shapes via src/types/cms.ts (replaces any[] in
  ApplicationsDashboard + GlobalOperations)
- /api/health now pings Postgres; docker-compose healthcheck added
- Structured JSON logger (src/lib/logger.ts) — drop-in replacement
  for console.error across API routes
- Prisma indices on isActive/category/nodeType filters

FluxAI persistence + analytics:
- New models AiConversation + AiEvent with funnel stage detection
  (DISCOVERY -> QUALIFY -> RECOMMEND -> HANDOFF) and OperationsSignal
  back-ref so converted chats link to their consultation ticket
- /api/chat persists every user msg, ai msg, tool call, tool result;
  IP is sha256-hashed with SESSION_SECRET salt; promptCacheKey wired
  for when @ai-sdk/openai lands the feature
- New HQ dashboard at /hq-command/dashboard/conversations: 4 KPIs
  (total, conversion rate, avg messages, avg tools), funnel + industry
  breakdowns, last-50 table, per-id transcript with tool timeline
- SilentObserver sends sessionId/locale/pageUrl in transport body so
  the route can stitch messages into the same conversation
- src/lib/aiSessionId.ts: localStorage UUID with sessionStorage +
  in-memory fallbacks for privacy mode
- Golden tests via node --test (13 cases, no new deps);
  npm run test:ai

Migration:
- prisma/migrations/20260526180000_add_indexes_and_ai_telemetry —
  additive only, IF NOT EXISTS guards, safe for migrate deploy

env template hardened: SESSION_SECRET documented as required + how
to generate; REDIS_URL/REDIS_TOKEN documented as opt-in for multi-
instance deploys.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-27 08:10:19 -05:00
parent 792dd6794b
commit 3a94e7c003
31 changed files with 1813 additions and 455 deletions
@@ -0,0 +1,86 @@
-- ─────────────────────────────────────────────────────────────────────────
-- ADDITIVE MIGRATION — adds analytics tables + indices on hot filter columns.
-- Nothing in this file modifies or drops existing data. Safe to `migrate
-- deploy` in production. Idempotent: every CREATE uses IF NOT EXISTS.
-- ─────────────────────────────────────────────────────────────────────────
-- ── Indices on existing tables (speed up isActive/category filters) ──────
CREATE INDEX IF NOT EXISTS "GlobalNode_isActive_idx" ON "GlobalNode" ("isActive");
CREATE INDEX IF NOT EXISTS "GlobalNode_nodeType_idx" ON "GlobalNode" ("nodeType");
CREATE INDEX IF NOT EXISTS "GlobalNode_nodeType_isActive_idx" ON "GlobalNode" ("nodeType", "isActive");
CREATE INDEX IF NOT EXISTS "Application_isActive_idx" ON "Application" ("isActive");
CREATE INDEX IF NOT EXISTS "Application_category_idx" ON "Application" ("category");
CREATE INDEX IF NOT EXISTS "NewsArticle_isActive_idx" ON "NewsArticle" ("isActive");
CREATE INDEX IF NOT EXISTS "NewsArticle_isActive_publishedAt_idx" ON "NewsArticle" ("isActive", "publishedAt" DESC);
CREATE INDEX IF NOT EXISTS "SparePart_isActive_idx" ON "SparePart" ("isActive");
-- ── FluxAI telemetry ──────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS "AiConversation" (
"id" TEXT NOT NULL,
"sessionId" TEXT NOT NULL,
"visitorIp" TEXT,
"userAgent" TEXT,
"locale" TEXT,
"pageUrl" TEXT,
"industryLabel" TEXT,
"funnelStage" TEXT NOT NULL DEFAULT 'DISCOVERY',
"outcome" TEXT NOT NULL DEFAULT 'OPEN',
"messageCount" INTEGER NOT NULL DEFAULT 0,
"toolCallCount" INTEGER NOT NULL DEFAULT 0,
"estimatedSavingsPercent" DOUBLE PRECISION,
"productionVolume" TEXT,
"signalId" TEXT,
"startedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"lastMessageAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"closedAt" TIMESTAMP(3),
CONSTRAINT "AiConversation_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX IF NOT EXISTS "AiConversation_sessionId_key" ON "AiConversation" ("sessionId");
CREATE INDEX IF NOT EXISTS "AiConversation_funnelStage_idx" ON "AiConversation" ("funnelStage");
CREATE INDEX IF NOT EXISTS "AiConversation_outcome_idx" ON "AiConversation" ("outcome");
CREATE INDEX IF NOT EXISTS "AiConversation_startedAt_idx" ON "AiConversation" ("startedAt" DESC);
CREATE INDEX IF NOT EXISTS "AiConversation_industryLabel_idx" ON "AiConversation" ("industryLabel");
CREATE TABLE IF NOT EXISTS "AiEvent" (
"id" TEXT NOT NULL,
"conversationId" TEXT NOT NULL,
"type" TEXT NOT NULL,
"payloadJson" TEXT NOT NULL,
"toolName" TEXT,
"latencyMs" INTEGER,
"tokensIn" INTEGER,
"tokensOut" INTEGER,
"cachedTokens" INTEGER,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AiEvent_pkey" PRIMARY KEY ("id")
);
CREATE INDEX IF NOT EXISTS "AiEvent_conversationId_createdAt_idx" ON "AiEvent" ("conversationId", "createdAt");
CREATE INDEX IF NOT EXISTS "AiEvent_type_idx" ON "AiEvent" ("type");
CREATE INDEX IF NOT EXISTS "AiEvent_toolName_idx" ON "AiEvent" ("toolName");
-- ── Foreign keys (added separately so missing references don't break load) ──
DO $$ BEGIN
ALTER TABLE "AiConversation"
ADD CONSTRAINT "AiConversation_signalId_fkey"
FOREIGN KEY ("signalId") REFERENCES "OperationsSignal"("id")
ON DELETE SET NULL ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
ALTER TABLE "AiEvent"
ADD CONSTRAINT "AiEvent_conversationId_fkey"
FOREIGN KEY ("conversationId") REFERENCES "AiConversation"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
+71 -4
View File
@@ -55,11 +55,15 @@ model GlobalNode {
rendersJson String? @default("[]") // Renders 3D fotorrealistas
model3DDimsJson String? // Dimensiones físicas AR: { w, h, d, unit, weight }
// 🌍 MOTOR DE TRADUCCIONES
// 🌍 MOTOR DE TRADUCCIONES
translationsJson String? @default("{}")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([isActive])
@@index([nodeType])
@@index([nodeType, isActive])
}
// ------------------------------------------------------
@@ -85,11 +89,14 @@ model Application {
dashboardMetricsJson String? @default("[]")
isActive Boolean @default(true) // 🔥 NUEVO: Para poder ocultarlas
// 🌍 MOTOR DE TRADUCCIONES
// 🌍 MOTOR DE TRADUCCIONES
translationsJson String? @default("{}")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([isActive])
@@index([category])
}
// ------------------------------------------------------
@@ -129,11 +136,14 @@ model NewsArticle {
galleryJson String? @default("[]") // Galería de imágenes extra
linkedinUrl String? // Enlace oficial para LinkedIn
// 🌍 MOTOR DE TRADUCCIONES
// 🌍 MOTOR DE TRADUCCIONES
translationsJson String? @default("{}")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([isActive])
@@index([isActive, publishedAt(sort: Desc)])
}
// ------------------------------------------------------
@@ -177,6 +187,8 @@ model SparePart {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([isActive])
}
// ------------------------------------------------------
@@ -209,7 +221,10 @@ model OperationsSignal {
// 🔥 NUEVO: Relación opcional con el Cliente Registrado (Para el futuro CRM)
clientId String?
client ClientUser? @relation(fields: [clientId], references: [id])
// FluxAI telemetry back-ref: which AI conversations converted into this ticket.
conversations AiConversation[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -293,6 +308,58 @@ model SiteSetting {
updatedAt DateTime @updatedAt
}
// ------------------------------------------------------
// 13b. FLUXAI TELEMETRY (Conversaciones + eventos del chat IA)
// ------------------------------------------------------
// Persiste cada conversación con FluxAI para análisis de funnel B2B.
// Una conversación se identifica por sessionId (UUID generado en cliente,
// persistido en localStorage). Los eventos individuales (mensajes,
// tool calls, errores) viven en AiEvent.
model AiConversation {
id String @id @default(cuid())
sessionId String @unique
visitorIp String? // sha256(ip + SESSION_SECRET) — pseudonymous
userAgent String?
locale String? // "it","en","es","fr","de"
pageUrl String? // entry page
industryLabel String? // SPIN-detected: "textile","food", etc.
funnelStage String @default("DISCOVERY") // DISCOVERY|QUALIFY|RECOMMEND|HANDOFF
outcome String @default("OPEN") // OPEN|CONSULTATION|ABANDONED
messageCount Int @default(0)
toolCallCount Int @default(0)
estimatedSavingsPercent Float?
productionVolume String?
signalId String?
signal OperationsSignal? @relation(fields: [signalId], references: [id])
startedAt DateTime @default(now())
lastMessageAt DateTime @default(now())
closedAt DateTime?
events AiEvent[]
@@index([funnelStage])
@@index([outcome])
@@index([startedAt(sort: Desc)])
@@index([industryLabel])
}
model AiEvent {
id String @id @default(cuid())
conversationId String
conversation AiConversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)
type String // "user_msg" | "ai_msg" | "tool_call" | "tool_result" | "error"
payloadJson String // truncated to 8KB at write time
toolName String?
latencyMs Int?
tokensIn Int?
tokensOut Int?
cachedTokens Int? // populated when OpenAI returns cached_tokens
createdAt DateTime @default(now())
@@index([conversationId, createdAt])
@@index([type])
@@index([toolName])
}
// ------------------------------------------------------
// 13. CLIENT PORTAL (Usuarios B2B Aprobados)
// ------------------------------------------------------