Files
flux-srl/prisma/schema.prisma
T
davidherran a81ee50ed8
Deploy to VPS / deploy (push) Has been cancelled
feat(resilience): operational hardening (NEXT phase of the audit)
Acts on the audit's NEXT block — operational resilience.

Backups (N1):
- New `backup` compose service (postgres:16-alpine) runs scripts/backup-loop.sh:
  immediate pg_dump on start, then nightly, gzip, 14-day rotation into
  ./backups on the host. Configurable via BACKUP_RETENTION_DAYS /
  BACKUP_INTERVAL_SECONDS. (Offsite copy is the documented next step.)

Resource limits + healthchecks (N2):
- deploy.resources.limits.memory on postgres (2g), app (1500m), nginx (256m),
  backup (256m) so no container can starve the others (the Nginx outage was a
  reminder).
- Nginx now has a healthcheck hitting a new self-served `/nginx-health`
  endpoint on the default_server (no upstream dependency).

Chat resilience (N3):
- buildSystemPrompt() wraps its 4 Prisma queries in try/catch with safe
  defaults — if Postgres is down the assistant degrades instead of 500-ing.
- Result is cached for 60s (only on healthy builds) so we don't run 4 queries
  per message; CMS edits still appear within the TTL.
- POST fails fast with 503 if OPENAI_API_KEY is missing (instead of breaking
  mid-stream after headers are sent).
- streamText gets an onError handler that logs + persists an `error` AiEvent.

Idempotent submissions (N4):
- consultation/route.ts and operations.ts now wrap the email-tracking UPDATE
  in try/catch — the lead/signal is already saved, so a telemetry hiccup can't
  500 the request and trigger a duplicate retry. operations.ts also returns
  emailError.

Performance (N5):
- Index GlobalNode(application, isActive) — backs the case-study join on every
  application page. Migration 20260609130000_index_globalnode_application.

Verified: next build compiles (Docker parity, SESSION_SECRET unset),
TypeScript clean, prisma schema valid, golden tests 17/17,
`docker compose config` valid.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 23:07:38 -05:00

422 lines
16 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
//url = env("DATABASE_URL")
}
// ------------------------------------------------------
// 1. BÓVEDA DE SEGURIDAD (Usuarios del CMS)
// ------------------------------------------------------
model AdminUser {
id String @id @default(cuid())
username String @unique
email String? // 🔥 NUEVO CAMPO: Correo del administrador
passwordHash String
twoFactorSecret String?
is2FAEnabled Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// ------------------------------------------------------
// 2. EL GLOBO HOLOGRÁFICO (Nodos y Casos de Estudio Profundos)
// ------------------------------------------------------
model GlobalNode {
id String @id @default(cuid())
title String // Ej: "Toray Advanced Textiles"
location String
// Ej: "Tokyo, Japan"
lat Float // Ej: 35.6895
lon Float // Ej: 139.6917
// Taxonomía
nodeType String @default("installation") // "installation", "event", "hq"
application String // Ej: "textile-drying", "hq", "event"
stats String // Ej: "2,400 kg/h throughput"
isActive Boolean @default(true) // Permite ocultar un nodo sin borrarlo
// 📖 GEO-CHRONICLE (THE STORY)
projectOverview String? // El Artículo completo / Resumen del evento (Markdown)
energySavings String? // Métrica (Ej: "-45% vs Conventional" o "Stand 4B")
eventDate DateTime?// Fecha para controlar si el evento es pasado o futuro
// 🔥 NUEVOS CAMPOS FASE 1: MULTIMEDIA Y DATASHEET ESPECÍFICO 🔥
mediaFileName String? // Imagen de Portada principal
galleryJson String? @default("[]") // Array de imágenes extra
videosJson String? @default("[]") // Links a videos reales
specificDatasheetJson String? @default("[]") // Ficha técnica de ESTA máquina
model3DPath String? // Ruta al archivo GLB/USDZ
rendersJson String? @default("[]") // Renders 3D fotorrealistas
model3DDimsJson String? // Dimensiones físicas AR: { w, h, d, unit, weight }
// 🌍 MOTOR DE TRADUCCIONES
translationsJson String? @default("{}")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([isActive])
@@index([nodeType])
@@index([nodeType, isActive])
// Case studies on an application page filter by application slug + isActive
// (src/app/[locale]/applications/[slug]/page.tsx). Back this join with an index.
@@index([application, isActive])
}
// ------------------------------------------------------
// 3. LA BASE DE CONOCIMIENTO (Páginas de Aplicaciones)
// ------------------------------------------------------
model Application {
id String @id @default(cuid())
slug String @unique // Ej: "textile-drying" (Debe coincidir con la URL)
title String
subtitle String
category String
// 🔥 NUEVO: La descripción corta para las tarjetas de la página principal
shortDescription String @default("Learn more about this FLUX RF technology application.")
heroDescription String // Recibirá MARKDOWN para la teoría científica general
// JSONs para estructuras complejas
sectionsJson String
advantagesJson String
datasheetJson String
// Métricas Rápidas para el Dashboard
dashboardMetricsJson String? @default("[]")
isActive Boolean @default(true) // 🔥 NUEVO: Para poder ocultarlas
// 🔥 NUEVO: Orden manual para drag-to-reorder en el frontend (como HeroSlide)
order Int @default(0)
// 🌍 MOTOR DE TRADUCCIONES
translationsJson String? @default("{}")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([isActive])
@@index([category])
@@index([isActive, order])
}
// ------------------------------------------------------
// 4. NUESTRA HISTORIA (Línea de tiempo de la empresa)
// ------------------------------------------------------
model TimelineEvent {
id String @id @default(cuid())
year String // Ej: "1978" o "1990s"
title String
description String
order Int @default(0) // Para ordenar cronológicamente
isActive Boolean @default(true)
// 🌍 MOTOR DE TRADUCCIONES
translationsJson String? @default("{}")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// ------------------------------------------------------
// 5. INSIDE FLUX (Motor de Noticias y Detrás de Cámaras)
// ------------------------------------------------------
model NewsArticle {
id String @id @default(cuid())
slug String @unique
title String
excerpt String // Resumen corto para la tarjeta
content String // El artículo completo (Markdown)
coverImage String? // Ej: "team-meeting.jpg"
category String @default("News")
publishedAt DateTime @default(now())
isActive Boolean @default(true)
// Editor avanzado
order Int @default(0) // Para ordenar las noticias
galleryJson String? @default("[]") // Galería de imágenes extra
linkedinUrl String? // Enlace oficial para LinkedIn
// 🌍 MOTOR DE TRADUCCIONES
translationsJson String? @default("{}")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([isActive])
@@index([isActive, publishedAt(sort: Desc)])
}
// ------------------------------------------------------
// 6. OUR HERITAGE (La Historia Profunda de Patrizio)
// ------------------------------------------------------
model HeritageSection {
id String @id @default(cuid())
type String @default("text") // "text", "image", "video"
title String?
content String? // Párrafos de la historia
mediaUrl String? // Ej: "patrizio-1980.jpg" o enlace de YouTube
order Int @default(0) // Para ordenar cómo se lee la página
// 🌍 MOTOR DE TRADUCCIONES
translationsJson String? @default("{}")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// ------------------------------------------------------
// 7. COMPONENT MATRIX (Catálogo de Repuestos)
// ------------------------------------------------------
model SparePart {
id String @id @default(cuid())
sku String @unique // Identificador único / Referencia (Ej: "FLX-GEN-001")
title String // Nombre de la pieza en Inglés
description String // Descripción técnica / Función (Markdown)
// Multimedia & Ficha Técnica
mediaJson String? @default("[]") // Imágenes, videos, renders 3D
specsJson String? @default("[]") // Array de métricas [{label: "Voltage", value: "24V"}]
// Estrategia de Ventas
price Float? // Precio (Opcional)
showPrice Boolean @default(false) // Interruptor: true = mostrar precio, false = "Request Quote"
isActive Boolean @default(true) // Para ocultar repuestos descontinuados
// 🌍 MOTOR DE TRADUCCIONES (Integración con aiTranslator.ts)
translationsJson String? @default("{}")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([isActive])
}
// ------------------------------------------------------
// 8. OPERATIONS INBOX (Signal Hub - Mesa de Ayuda y Órdenes)
// ------------------------------------------------------
model OperationsSignal {
id String @id @default(cuid())
ticketId String @unique
ticketNumber Int @default(autoincrement()) // Sequential for analytics
type String // "ORDER", "DIAGNOSTIC", "CONSULTATION"
status String @default("PENDING") // "PENDING", "REVIEWING", "RESOLVED"
// Client data
clientName String
clientEmail String
clientCompany String
clientPhone String?
message String?
// Payloads
cartPayload String? @default("[]")
attachedFiles String? @default("[]")
aiAnalysis String?
// Email delivery tracking
emailSentTo String? // Comma-separated list of emails that received notification
emailSentAt DateTime? // When the email was dispatched
emailError String? // Error message if email failed
// 🔥 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
@@index([type])
@@index([status])
@@index([createdAt(sort: Desc)])
}
// ------------------------------------------------------
// 9. RUTAS DE NOTIFICACIÓN (Gestión de Emails)
// ------------------------------------------------------
model NotificationRoute {
id String @id @default(cuid())
routeType String @unique // Ej: "ORDER", "DIAGNOSTIC", "CONSULTATION"
emails String // Correos separados por coma (Ej: "sales@fluxsrl.com, tech@fluxsrl.com")
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// ------------------------------------------------------
// 10. PAGE CONTENT (Metadata y Textos de Páginas)
// ------------------------------------------------------
model PageContent {
id String @id @default(cuid())
slug String @unique // Identificador de la página (Ej: "parts-catalog")
title String
subtitle String?
description String?
// 🌍 MOTOR DE TRADUCCIONES
translationsJson String? @default("{}")
updatedAt DateTime @updatedAt
}
// ------------------------------------------------------
// 11. HERO REEL (Carrusel principal del Home)
// ------------------------------------------------------
// Manages the rotating images/videos shown in the home hero section.
// Replaces the previous filesystem-scan approach (fs.readdirSync of
// /public/footage/main) with full CMS control: ordering, on/off toggle,
// focal-point per slide for proper responsive cropping on mobile/tablet,
// and per-slide alt text for SEO.
model HeroSlide {
id String @id @default(cuid())
mediaUrl String // Public path, e.g. "/footage/main/01_tifas.png"
mediaType String @default("image") // "image" | "video"
altText String? // For accessibility + SEO; falls back to title if null
order Int @default(0)
isActive Boolean @default(true)
// Focal point for object-position on mobile/tablet crops (01 range).
// Lets the editor pick "what should stay visible when the image is cropped".
focalPointX Float @default(0.5)
focalPointY Float @default(0.5)
// Optional per-slide caption that overrides the global Hero text.
// Stored as JSON keyed by locale: {"en":{"title":"...","subtitle":"..."}}
translationsJson String? @default("{}")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// ------------------------------------------------------
// 12. SITE SETTINGS (Favicon, Footer, Branding global)
// ------------------------------------------------------
// Single-row pattern (key-value) for global site config that doesn't
// fit any other model: favicon, logos, footer, OG image, social links.
model SiteSetting {
id String @id @default(cuid())
key String @unique // e.g. "favicon", "footer", "logo", "og_image", "hero_text"
valueJson String @default("{}") // Flexible JSON payload per key
// 🌍 Translation engine (used for things like footer link labels)
translationsJson String? @default("{}")
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)
// ------------------------------------------------------
model ClientUser {
id String @id @default(cuid())
email String @unique
passwordHash String
fullName String
companyName String
phone String?
// Control de Acceso
isApproved Boolean @default(false) // Requiere aprobación del Admin
// Historial de Compras/Tickets
signals OperationsSignal[]
lastLoginAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// ------------------------------------------------------
// 14. THE TEAM (Equipo de FLUX — página pública + CMS)
// ------------------------------------------------------
// Minimal LinkedIn-style profiles. Editable in the HQ Command Center with
// drag-to-reorder (same pattern as HeroSlide). Name stays as written; role
// and bio are translatable through the AI translation engine. Social links
// are all optional — only the ones filled in render on the public card.
model TeamMember {
id String @id @default(cuid())
name String // Proper name — never translated
role String // Job title, e.g. "Founder & CEO" — translatable
bio String? // Short biography (Markdown allowed) — translatable
photoUrl String? // Portrait, served from /team/ bucket
// Optional social links — render only when present
email String?
linkedinUrl String?
xUrl String? // X / Twitter
websiteUrl String?
order Int @default(0) // Drag-to-reorder
isActive Boolean @default(true)
// 🌍 Translation engine — holds localized role + bio per locale
translationsJson String? @default("{}")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([isActive, order])
}