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:
@@ -58,6 +58,10 @@ services:
|
|||||||
SMTP_FROM: ${SMTP_FROM}
|
SMTP_FROM: ${SMTP_FROM}
|
||||||
SMTP_SECURE: ${SMTP_SECURE}
|
SMTP_SECURE: ${SMTP_SECURE}
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
|
# Optional: REDIS_URL enables multi-instance rate limiting. Leave unset
|
||||||
|
# for the current single-container deploy — the in-memory store is used.
|
||||||
|
REDIS_URL: ${REDIS_URL:-}
|
||||||
|
REDIS_TOKEN: ${REDIS_TOKEN:-}
|
||||||
volumes:
|
volumes:
|
||||||
- ./public/footage:/app/public/footage
|
- ./public/footage:/app/public/footage
|
||||||
- ./public/applications:/app/public/applications
|
- ./public/applications:/app/public/applications
|
||||||
@@ -70,6 +74,14 @@ services:
|
|||||||
- flux-net
|
- flux-net
|
||||||
expose:
|
expose:
|
||||||
- "3000"
|
- "3000"
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
- CMD-SHELL
|
||||||
|
- "node -e \"fetch('http://localhost:3000/api/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))\""
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 40s
|
||||||
|
|
||||||
# ── Nginx Reverse Proxy ──
|
# ── Nginx Reverse Proxy ──
|
||||||
nginx:
|
nginx:
|
||||||
|
|||||||
@@ -5,8 +5,16 @@
|
|||||||
#:xDATABASE_URL="postgresql://flux_user:STRONG_PASSWORD@postgres:5432/flux_db?schema=public"
|
#:xDATABASE_URL="postgresql://flux_user:STRONG_PASSWORD@postgres:5432/flux_db?schema=public"
|
||||||
DATABASE_URL="postgresql://flux_user:dev_password@localhost:5432/flux_db?schema=public"
|
DATABASE_URL="postgresql://flux_user:dev_password@localhost:5432/flux_db?schema=public"
|
||||||
|
|
||||||
#FLUX SECRET Esto no se que hace
|
# SESSION_SECRET (REQUIRED, min 32 chars).
|
||||||
SESSION_SECRET="FLUX_SUPER_SECRET_KEY_2026_ARCHITECTURE"
|
# Used to sign 7-day admin JWTs in src/lib/session.ts and CSRF tokens in
|
||||||
|
# src/lib/csrf.ts. The app refuses to boot without it. Generate with:
|
||||||
|
# openssl rand -base64 48
|
||||||
|
SESSION_SECRET="CHANGE_ME_openssl_rand_base64_48_min_32_chars"
|
||||||
|
|
||||||
|
# Optional: multi-instance rate limiting via Upstash Redis REST API.
|
||||||
|
# Leave both unset to use the in-memory bucket store (fine for single VPS).
|
||||||
|
#REDIS_URL="https://xxx.upstash.io"
|
||||||
|
#REDIS_TOKEN="xxxxx"
|
||||||
|
|
||||||
# OPEN AI KEY
|
# OPEN AI KEY
|
||||||
OPENAI_API_KEY=sk-proj-dsdsdsds-kaDAHhZXPYsK6-4uw0UWJZ0YuLnQfVoEA
|
OPENAI_API_KEY=sk-proj-dsdsdsds-kaDAHhZXPYsK6-4uw0UWJZ0YuLnQfVoEA
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
|
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
|
||||||
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/s;
|
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/s;
|
||||||
|
# Slow zone for media uploads: 5 requests per minute per IP.
|
||||||
|
limit_req_zone $binary_remote_addr zone=upload:10m rate=5r/m;
|
||||||
|
|
||||||
upstream nextjs {
|
upstream nextjs {
|
||||||
server app:3000;
|
server app:3000;
|
||||||
@@ -44,6 +46,14 @@ server {
|
|||||||
ssl_session_tickets off;
|
ssl_session_tickets off;
|
||||||
|
|
||||||
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
|
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
|
||||||
|
# ── Security headers ────────────────────────────────────────────────
|
||||||
|
# 'unsafe-inline' / 'unsafe-eval' on script-src are required by Next.js
|
||||||
|
# for hydration. Tightening to nonces is tracked as future work.
|
||||||
|
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; media-src 'self' blob: https:; font-src 'self' data:; connect-src 'self' https://api.openai.com https://*.upstash.io; frame-ancestors 'none'; base-uri 'self'; form-action 'self';" always;
|
||||||
|
add_header X-Frame-Options "DENY" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||||
|
|
||||||
# Next.js bundles use content hashing — safe to cache forever
|
# Next.js bundles use content hashing — safe to cache forever
|
||||||
location /_next/static/ {
|
location /_next/static/ {
|
||||||
@@ -75,6 +85,7 @@ server {
|
|||||||
|
|
||||||
# Asset uploads (large files, long timeout)
|
# Asset uploads (large files, long timeout)
|
||||||
location /api/assets {
|
location /api/assets {
|
||||||
|
limit_req zone=upload burst=10 nodelay;
|
||||||
client_max_body_size 500M;
|
client_max_body_size 500M;
|
||||||
proxy_pass http://nextjs;
|
proxy_pass http://nextjs;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
@@ -87,6 +98,7 @@ server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
location /api/public-upload {
|
location /api/public-upload {
|
||||||
|
limit_req zone=upload burst=10 nodelay;
|
||||||
client_max_body_size 500M;
|
client_max_body_size 500M;
|
||||||
proxy_pass http://nextjs;
|
proxy_pass http://nextjs;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
|
|||||||
+2
-1
@@ -6,7 +6,8 @@
|
|||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint"
|
"lint": "eslint",
|
||||||
|
"test:ai": "node --test tests/ai/golden.test.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/openai": "^3.0.41",
|
"@ai-sdk/openai": "^3.0.41",
|
||||||
|
|||||||
@@ -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 $$;
|
||||||
@@ -60,6 +60,10 @@ model GlobalNode {
|
|||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([isActive])
|
||||||
|
@@index([nodeType])
|
||||||
|
@@index([nodeType, isActive])
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------
|
// ------------------------------------------------------
|
||||||
@@ -90,6 +94,9 @@ model Application {
|
|||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([isActive])
|
||||||
|
@@index([category])
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------
|
// ------------------------------------------------------
|
||||||
@@ -134,6 +141,9 @@ model NewsArticle {
|
|||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([isActive])
|
||||||
|
@@index([isActive, publishedAt(sort: Desc)])
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------
|
// ------------------------------------------------------
|
||||||
@@ -177,6 +187,8 @@ model SparePart {
|
|||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([isActive])
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------
|
// ------------------------------------------------------
|
||||||
@@ -210,6 +222,9 @@ model OperationsSignal {
|
|||||||
clientId String?
|
clientId String?
|
||||||
client ClientUser? @relation(fields: [clientId], references: [id])
|
client ClientUser? @relation(fields: [clientId], references: [id])
|
||||||
|
|
||||||
|
// FluxAI telemetry back-ref: which AI conversations converted into this ticket.
|
||||||
|
conversations AiConversation[]
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@ -293,6 +308,58 @@ model SiteSetting {
|
|||||||
updatedAt DateTime @updatedAt
|
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)
|
// 13. CLIENT PORTAL (Usuarios B2B Aprobados)
|
||||||
// ------------------------------------------------------
|
// ------------------------------------------------------
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ export default function AuthModal({ session }: { session: any }) {
|
|||||||
setError(res.error);
|
setError(res.error);
|
||||||
} else {
|
} else {
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
|
// NavBar listens to this event to refresh its session badge without polling.
|
||||||
|
if (typeof window !== "undefined") window.dispatchEvent(new CustomEvent("flux:session-changed"));
|
||||||
router.refresh();
|
router.refresh();
|
||||||
}
|
}
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
@@ -87,6 +89,7 @@ export default function AuthModal({ session }: { session: any }) {
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
await logoutClient();
|
await logoutClient();
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
|
if (typeof window !== "undefined") window.dispatchEvent(new CustomEvent("flux:session-changed"));
|
||||||
router.refresh();
|
router.refresh();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,13 @@ import bcrypt from "bcryptjs";
|
|||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
import { SignJWT, jwtVerify } from "jose";
|
import { SignJWT, jwtVerify } from "jose";
|
||||||
|
|
||||||
const getSecretKey = () => new TextEncoder().encode(process.env.SESSION_SECRET || "flux-super-secret-key-2026");
|
const getSecretKey = () => {
|
||||||
|
const s = process.env.SESSION_SECRET;
|
||||||
|
if (!s || s.length < 32) {
|
||||||
|
throw new Error("SESSION_SECRET environment variable is required (min 32 chars).");
|
||||||
|
}
|
||||||
|
return new TextEncoder().encode(s);
|
||||||
|
};
|
||||||
|
|
||||||
export async function registerClientRequest(formData: FormData) {
|
export async function registerClientRequest(formData: FormData) {
|
||||||
const fullName = formData.get("fullName") as string;
|
const fullName = formData.get("fullName") as string;
|
||||||
|
|||||||
+174
-4
@@ -1,8 +1,10 @@
|
|||||||
import { openai } from '@ai-sdk/openai';
|
import { openai } from '@ai-sdk/openai';
|
||||||
import { streamText, stepCountIs, UIMessage, convertToModelMessages, tool } from 'ai';
|
import { streamText, stepCountIs, UIMessage, convertToModelMessages, tool } from 'ai';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
import { createHash } from 'crypto';
|
||||||
import { prisma } from '@/lib/prisma';
|
import { prisma } from '@/lib/prisma';
|
||||||
import { checkChatRateLimit } from '@/lib/rateLimit';
|
import { checkChatRateLimit, getClientIp } from '@/lib/rateLimit';
|
||||||
|
import { log } from '@/lib/logger';
|
||||||
|
|
||||||
export const maxDuration = 60;
|
export const maxDuration = 60;
|
||||||
|
|
||||||
@@ -161,11 +163,24 @@ function industryFromSlug(slug: string): string {
|
|||||||
return 'other';
|
return 'other';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Lightweight industry sniffer used for AiConversation.industryLabel telemetry.
|
||||||
|
// Order matters — more specific terms first.
|
||||||
|
function detectIndustryFromText(text: string): string | null {
|
||||||
|
const t = text.toLowerCase();
|
||||||
|
if (/text|fabric|dye|stenter|finishing|yarn/.test(t)) return 'textile';
|
||||||
|
if (/food|defrost|bak|pasteuriz|tempering|cook/.test(t)) return 'food';
|
||||||
|
if (/rubber|latex|vulcaniz|foam|tyre|tire/.test(t)) return 'rubber';
|
||||||
|
if (/pharma|cannabis|drug|api\b|lab/.test(t)) return 'pharma';
|
||||||
|
if (/wood|timber|lumber|kiln/.test(t)) return 'wood';
|
||||||
|
if (/ceramic|kiln|clay/.test(t)) return 'other';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// ─── ROUTE HANDLER ──────────────────────────────────────────────
|
// ─── ROUTE HANDLER ──────────────────────────────────────────────
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
// ─── Rate limit (per-IP token bucket, 30 req/min) ──────────────
|
// ─── Rate limit (per-IP token bucket, 30 req/min) ──────────────
|
||||||
const rate = checkChatRateLimit(req);
|
const rate = await checkChatRateLimit(req);
|
||||||
if (!rate.ok) {
|
if (!rate.ok) {
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
@@ -183,16 +198,86 @@ export async function POST(req: Request) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { messages, context }: {
|
const {
|
||||||
|
messages,
|
||||||
|
context,
|
||||||
|
sessionId,
|
||||||
|
locale,
|
||||||
|
pageUrl,
|
||||||
|
}: {
|
||||||
messages: UIMessage[];
|
messages: UIMessage[];
|
||||||
context?: { section?: string; activeTab?: string };
|
context?: { section?: string; activeTab?: string };
|
||||||
|
sessionId?: string;
|
||||||
|
locale?: string;
|
||||||
|
pageUrl?: string | null;
|
||||||
} = await req.json();
|
} = await req.json();
|
||||||
|
|
||||||
const contextNote = context?.section
|
const contextNote = context?.section
|
||||||
? `\n\n[CONTEXT: User is currently viewing the "${context.section}" section${context.activeTab ? `, tab: "${context.activeTab}"` : ''}.`
|
? `\n\n[CONTEXT: User is currently viewing the "${context.section}" section${context.activeTab ? `, tab: "${context.activeTab}"` : ''}.`
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
// Build system prompt with live database context
|
// ─── FluxAI telemetry: upsert conversation + record user message ────────
|
||||||
|
// Wrapped in try/catch — telemetry never blocks the chat response.
|
||||||
|
let conversationId: string | null = null;
|
||||||
|
const startedAt = Date.now();
|
||||||
|
if (sessionId) {
|
||||||
|
try {
|
||||||
|
const ipHash = createHash('sha256')
|
||||||
|
.update(`${getClientIp(req)}|${process.env.SESSION_SECRET ?? ''}`)
|
||||||
|
.digest('hex')
|
||||||
|
.slice(0, 32);
|
||||||
|
|
||||||
|
const lastUserMsg = [...messages].reverse().find((m) => m.role === 'user');
|
||||||
|
const lastUserText = lastUserMsg
|
||||||
|
? (lastUserMsg as unknown as { parts?: { type: string; text?: string }[] }).parts
|
||||||
|
?.filter((p) => p.type === 'text')
|
||||||
|
.map((p) => p.text || '')
|
||||||
|
.join(' ')
|
||||||
|
.slice(0, 8000)
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const detectedIndustry = lastUserText ? detectIndustryFromText(lastUserText) : null;
|
||||||
|
|
||||||
|
const conv = await prisma.aiConversation.upsert({
|
||||||
|
where: { sessionId },
|
||||||
|
update: {
|
||||||
|
lastMessageAt: new Date(),
|
||||||
|
messageCount: { increment: 1 },
|
||||||
|
...(detectedIndustry ? { industryLabel: detectedIndustry } : {}),
|
||||||
|
// Once we have an industry, advance to QUALIFY.
|
||||||
|
...(detectedIndustry ? { funnelStage: 'QUALIFY' } : {}),
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
sessionId,
|
||||||
|
visitorIp: ipHash,
|
||||||
|
userAgent: req.headers.get('user-agent')?.slice(0, 240) ?? null,
|
||||||
|
locale: locale ?? null,
|
||||||
|
pageUrl: pageUrl ?? null,
|
||||||
|
industryLabel: detectedIndustry,
|
||||||
|
funnelStage: detectedIndustry ? 'QUALIFY' : 'DISCOVERY',
|
||||||
|
messageCount: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
conversationId = conv.id;
|
||||||
|
|
||||||
|
if (lastUserText) {
|
||||||
|
await prisma.aiEvent.create({
|
||||||
|
data: {
|
||||||
|
conversationId: conv.id,
|
||||||
|
type: 'user_msg',
|
||||||
|
payloadJson: JSON.stringify({ text: lastUserText }).slice(0, 8000),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
log.warn('chat.telemetry_upsert_failed', { err: String(e) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build system prompt with live database context.
|
||||||
|
// The static section (personality, knowledge, rules) is identical across
|
||||||
|
// requests, so we tag it with `providerOptions.openai.promptCacheKey` —
|
||||||
|
// a no-op today, but ready for prompt caching when the SDK lands it.
|
||||||
const systemPrompt = await buildSystemPrompt();
|
const systemPrompt = await buildSystemPrompt();
|
||||||
|
|
||||||
const coreMessages = await convertToModelMessages(messages);
|
const coreMessages = await convertToModelMessages(messages);
|
||||||
@@ -201,6 +286,91 @@ export async function POST(req: Request) {
|
|||||||
model: openai('gpt-4o'),
|
model: openai('gpt-4o'),
|
||||||
system: systemPrompt + contextNote,
|
system: systemPrompt + contextNote,
|
||||||
messages: coreMessages,
|
messages: coreMessages,
|
||||||
|
providerOptions: { openai: { promptCacheKey: 'fluxai-v1' } },
|
||||||
|
onFinish: async ({ usage, toolCalls, toolResults }) => {
|
||||||
|
if (!conversationId) return;
|
||||||
|
try {
|
||||||
|
const latencyMs = Date.now() - startedAt;
|
||||||
|
// 1. Persist the assistant message (compact)
|
||||||
|
await prisma.aiEvent.create({
|
||||||
|
data: {
|
||||||
|
conversationId,
|
||||||
|
type: 'ai_msg',
|
||||||
|
payloadJson: JSON.stringify({
|
||||||
|
toolCalls: toolCalls?.map((tc) => ({ name: tc.toolName })) ?? [],
|
||||||
|
}).slice(0, 8000),
|
||||||
|
latencyMs,
|
||||||
|
tokensIn:
|
||||||
|
(usage as unknown as { inputTokens?: number; promptTokens?: number })?.inputTokens ??
|
||||||
|
(usage as unknown as { promptTokens?: number })?.promptTokens ??
|
||||||
|
null,
|
||||||
|
tokensOut:
|
||||||
|
(usage as unknown as { outputTokens?: number; completionTokens?: number })?.outputTokens ??
|
||||||
|
(usage as unknown as { completionTokens?: number })?.completionTokens ??
|
||||||
|
null,
|
||||||
|
cachedTokens:
|
||||||
|
(usage as unknown as { cachedTokens?: number })?.cachedTokens ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Persist each tool call/result
|
||||||
|
const tcArr = toolCalls ?? [];
|
||||||
|
const trArr = toolResults ?? [];
|
||||||
|
let advanceStage: string | null = null;
|
||||||
|
let savings: number | null = null;
|
||||||
|
let volume: string | null = null;
|
||||||
|
|
||||||
|
for (const tc of tcArr) {
|
||||||
|
await prisma.aiEvent.create({
|
||||||
|
data: {
|
||||||
|
conversationId,
|
||||||
|
type: 'tool_call',
|
||||||
|
toolName: tc.toolName,
|
||||||
|
payloadJson: JSON.stringify(
|
||||||
|
(tc as unknown as { args?: unknown; input?: unknown }).args ??
|
||||||
|
(tc as unknown as { input?: unknown }).input ??
|
||||||
|
{},
|
||||||
|
).slice(0, 8000),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (tc.toolName === 'energy_savings_calculator') advanceStage = 'RECOMMEND';
|
||||||
|
if (tc.toolName === 'schedule_consultation') {
|
||||||
|
advanceStage = 'HANDOFF';
|
||||||
|
const args = ((tc as unknown as { args?: unknown; input?: unknown }).args ??
|
||||||
|
(tc as unknown as { input?: unknown }).input ??
|
||||||
|
{}) as {
|
||||||
|
estimatedSavingsPercent?: number | null;
|
||||||
|
productionVolume?: string | null;
|
||||||
|
};
|
||||||
|
if (typeof args.estimatedSavingsPercent === 'number') savings = args.estimatedSavingsPercent;
|
||||||
|
if (typeof args.productionVolume === 'string') volume = args.productionVolume;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const tr of trArr) {
|
||||||
|
await prisma.aiEvent.create({
|
||||||
|
data: {
|
||||||
|
conversationId,
|
||||||
|
type: 'tool_result',
|
||||||
|
toolName: (tr as unknown as { toolName?: string }).toolName ?? null,
|
||||||
|
payloadJson: JSON.stringify((tr as unknown as { result?: unknown }).result ?? {}).slice(0, 8000),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Update conversation funnel + counters
|
||||||
|
await prisma.aiConversation.update({
|
||||||
|
where: { id: conversationId },
|
||||||
|
data: {
|
||||||
|
toolCallCount: { increment: tcArr.length },
|
||||||
|
...(advanceStage ? { funnelStage: advanceStage } : {}),
|
||||||
|
...(savings != null ? { estimatedSavingsPercent: savings } : {}),
|
||||||
|
...(volume ? { productionVolume: volume } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
log.warn('chat.telemetry_finish_failed', { err: String(e) });
|
||||||
|
}
|
||||||
|
},
|
||||||
// 🔥 RESTORED: AI SDK 6 multi-step autonomy. The agent can now chain
|
// 🔥 RESTORED: AI SDK 6 multi-step autonomy. The agent can now chain
|
||||||
// search → calculator → case-study → consultation in a single turn,
|
// search → calculator → case-study → consultation in a single turn,
|
||||||
// exactly as the SPIN methodology in the system prompt was designed for.
|
// exactly as the SPIN methodology in the system prompt was designed for.
|
||||||
|
|||||||
@@ -1,12 +1,50 @@
|
|||||||
// /src/app/api/consultation/route.ts
|
// /src/app/api/consultation/route.ts
|
||||||
// Public API endpoint for ConsultationScheduler → OperationsSignal
|
// Public API endpoint for ConsultationScheduler -> OperationsSignal.
|
||||||
// Uses SMTP mailer (no Resend dependency)
|
// Hardened (v2):
|
||||||
|
// - Zod schema validates every field, rejects malformed emails / oversize input.
|
||||||
|
// - Double-submit CSRF check rejects cross-site form posts.
|
||||||
|
// - escapeHtml() everywhere in the email template (no raw interpolation).
|
||||||
|
// - Structured logging (no silent console.error).
|
||||||
|
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { sendEmail } from "@/lib/mailer";
|
import { sendEmail } from "@/lib/mailer";
|
||||||
|
import { escapeHtml, escapeAttr, safeMailto } from "@/lib/escapeHtml";
|
||||||
|
import { log } from "@/lib/logger";
|
||||||
|
import { verifyCsrfToken, CSRF_COOKIE_NAME, CSRF_HEADER_NAME } from "@/lib/csrf";
|
||||||
|
|
||||||
|
const ConsultationSchema = z.object({
|
||||||
|
contact: z.object({
|
||||||
|
name: z.string().min(1).max(120),
|
||||||
|
email: z.string().email().max(254),
|
||||||
|
company: z.string().min(1).max(160),
|
||||||
|
phone: z.string().max(40).optional().nullable(),
|
||||||
|
message: z.string().max(4000).optional().nullable(),
|
||||||
|
preferredContact: z.enum(["email", "phone", "whatsapp"]).optional().nullable(),
|
||||||
|
timeframe: z.string().max(80).optional().nullable(),
|
||||||
|
}),
|
||||||
|
aiContext: z
|
||||||
|
.object({
|
||||||
|
industryLabel: z.string().max(120).optional().nullable(),
|
||||||
|
process: z.string().max(120).optional().nullable(),
|
||||||
|
estimatedSavingsPercent: z.number().min(0).max(100).optional().nullable(),
|
||||||
|
productionVolume: z.string().max(120).optional().nullable(),
|
||||||
|
conversationInsights: z.array(z.string().max(500)).max(20).optional().nullable(),
|
||||||
|
suggestedTopics: z.array(z.string().max(160)).max(20).optional().nullable(),
|
||||||
|
sessionId: z.string().uuid().optional().nullable(),
|
||||||
|
})
|
||||||
|
.partial()
|
||||||
|
.optional(),
|
||||||
|
meta: z
|
||||||
|
.object({
|
||||||
|
source: z.string().max(80).optional().nullable(),
|
||||||
|
url: z.string().url().max(500).optional().nullable(),
|
||||||
|
})
|
||||||
|
.partial()
|
||||||
|
.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
// Helper: sequential ticket ID
|
|
||||||
async function generateConsultationTicketId(): Promise<string> {
|
async function generateConsultationTicketId(): Promise<string> {
|
||||||
const year = new Date().getFullYear();
|
const year = new Date().getFullYear();
|
||||||
const count = await prisma.operationsSignal.count({
|
const count = await prisma.operationsSignal.count({
|
||||||
@@ -16,34 +54,54 @@ async function generateConsultationTicketId(): Promise<string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
// ── CSRF: double-submit cookie + header must match ──────────────────────
|
||||||
const body = await request.json();
|
const csrfCookie = request.cookies.get(CSRF_COOKIE_NAME)?.value ?? null;
|
||||||
const { contact, aiContext, meta } = body;
|
const csrfHeader = request.headers.get(CSRF_HEADER_NAME);
|
||||||
|
if (!csrfCookie || !csrfHeader || csrfCookie !== csrfHeader || !verifyCsrfToken(csrfHeader)) {
|
||||||
if (!contact?.name || !contact?.email || !contact?.company) {
|
log.warn("consultation.csrf_rejected", { hasCookie: !!csrfCookie, hasHeader: !!csrfHeader });
|
||||||
return NextResponse.json({ error: "Missing required contact fields" }, { status: 400 });
|
return NextResponse.json({ error: "Invalid CSRF token" }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Body parse + schema validation ──────────────────────────────────────
|
||||||
|
let parsed: z.infer<typeof ConsultationSchema>;
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
parsed = ConsultationSchema.parse(body);
|
||||||
|
} catch (e) {
|
||||||
|
log.warn("consultation.validation_failed", { error: e instanceof z.ZodError ? e.issues : String(e) });
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Invalid payload", details: e instanceof z.ZodError ? e.issues : undefined },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { contact, aiContext, meta } = parsed;
|
||||||
|
|
||||||
|
try {
|
||||||
const ticketId = await generateConsultationTicketId();
|
const ticketId = await generateConsultationTicketId();
|
||||||
|
|
||||||
// Build structured AI analysis
|
// Build structured AI analysis (plain text, no markup needed)
|
||||||
const aiParts: string[] = [];
|
const aiParts: string[] = [];
|
||||||
if (aiContext?.industryLabel && aiContext?.process) aiParts.push(`[INDUSTRY] ${aiContext.industryLabel} — ${aiContext.process}`);
|
if (aiContext?.industryLabel && aiContext?.process)
|
||||||
if (aiContext?.estimatedSavingsPercent) aiParts.push(`[ESTIMATED SAVINGS] ~${aiContext.estimatedSavingsPercent}% energy reduction`);
|
aiParts.push(`[INDUSTRY] ${aiContext.industryLabel} - ${aiContext.process}`);
|
||||||
|
if (aiContext?.estimatedSavingsPercent)
|
||||||
|
aiParts.push(`[ESTIMATED SAVINGS] ~${aiContext.estimatedSavingsPercent}% energy reduction`);
|
||||||
if (aiContext?.productionVolume) aiParts.push(`[PRODUCTION VOLUME] ${aiContext.productionVolume}`);
|
if (aiContext?.productionVolume) aiParts.push(`[PRODUCTION VOLUME] ${aiContext.productionVolume}`);
|
||||||
if (aiContext?.conversationInsights?.length > 0) aiParts.push(`[AI DISCUSSION POINTS]\n${aiContext.conversationInsights.map((i: string) => `• ${i}`).join("\n")}`);
|
if (aiContext?.conversationInsights?.length)
|
||||||
if (aiContext?.suggestedTopics?.length > 0) aiParts.push(`[SUGGESTED ENGINEERING TOPICS]\n${aiContext.suggestedTopics.map((t: string) => `→ ${t}`).join("\n")}`);
|
aiParts.push(`[AI DISCUSSION POINTS]\n${aiContext.conversationInsights.map((i) => `- ${i}`).join("\n")}`);
|
||||||
if (contact?.preferredContact) aiParts.push(`[PREFERRED CONTACT] ${contact.preferredContact.toUpperCase()}`);
|
if (aiContext?.suggestedTopics?.length)
|
||||||
if (contact?.timeframe) aiParts.push(`[TIMEFRAME] ${contact.timeframe}`);
|
aiParts.push(`[SUGGESTED ENGINEERING TOPICS]\n${aiContext.suggestedTopics.map((t) => `-> ${t}`).join("\n")}`);
|
||||||
if (meta?.source) aiParts.push(`[SOURCE] ${meta.source} — ${meta.url || "N/A"}`);
|
if (contact.preferredContact) aiParts.push(`[PREFERRED CONTACT] ${contact.preferredContact.toUpperCase()}`);
|
||||||
|
if (contact.timeframe) aiParts.push(`[TIMEFRAME] ${contact.timeframe}`);
|
||||||
|
if (meta?.source) aiParts.push(`[SOURCE] ${meta.source} - ${meta.url || "N/A"}`);
|
||||||
|
|
||||||
const aiAnalysis = aiParts.join("\n\n");
|
const aiAnalysis = aiParts.join("\n\n");
|
||||||
|
|
||||||
const messageParts: string[] = [];
|
const messageParts: string[] = [];
|
||||||
if (contact.message) messageParts.push(contact.message);
|
if (contact.message) messageParts.push(contact.message);
|
||||||
if (contact.timeframe) messageParts.push(`Timeframe: ${contact.timeframe}`);
|
if (contact.timeframe) messageParts.push(`Timeframe: ${contact.timeframe}`);
|
||||||
if (contact.preferredContact) messageParts.push(`Preferred contact: ${contact.preferredContact}`);
|
if (contact.preferredContact) messageParts.push(`Preferred contact: ${contact.preferredContact}`);
|
||||||
|
|
||||||
// Save to DB
|
|
||||||
const signal = await prisma.operationsSignal.create({
|
const signal = await prisma.operationsSignal.create({
|
||||||
data: {
|
data: {
|
||||||
ticketId,
|
ticketId,
|
||||||
@@ -60,23 +118,33 @@ export async function POST(request: NextRequest) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Resolve email targets
|
// ── Link conversation -> signal (best-effort, never blocks the response)
|
||||||
|
if (aiContext?.sessionId) {
|
||||||
|
try {
|
||||||
|
await prisma.aiConversation.updateMany({
|
||||||
|
where: { sessionId: aiContext.sessionId },
|
||||||
|
data: { outcome: "CONSULTATION", signalId: signal.id, closedAt: new Date() },
|
||||||
|
});
|
||||||
|
} catch (linkErr) {
|
||||||
|
log.warn("consultation.link_conversation_failed", { sessionId: aiContext.sessionId, err: String(linkErr) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const route = await prisma.notificationRoute.findUnique({ where: { routeType: "CONSULTATION" } });
|
const route = await prisma.notificationRoute.findUnique({ where: { routeType: "CONSULTATION" } });
|
||||||
const targetEmails = route && route.isActive
|
const targetEmails =
|
||||||
|
route && route.isActive
|
||||||
? route.emails.split(",").map((e: string) => e.trim()).filter(Boolean)
|
? route.emails.split(",").map((e: string) => e.trim()).filter(Boolean)
|
||||||
: ["engineering@fluxsrl.com"];
|
: ["engineering@fluxsrl.com"];
|
||||||
|
|
||||||
const appUrl = process.env.NEXT_PUBLIC_APP_URL || "https://flux.com";
|
const appUrl = process.env.NEXT_PUBLIC_APP_URL || "https://flux.com";
|
||||||
|
|
||||||
// Send via SMTP
|
|
||||||
const emailResult = await sendEmail({
|
const emailResult = await sendEmail({
|
||||||
to: targetEmails,
|
to: targetEmails,
|
||||||
subject: `[CONSULTATION] ${aiContext?.industryLabel || "General"} inquiry from ${contact.company} — ${ticketId}`,
|
subject: `[CONSULTATION] ${aiContext?.industryLabel || "General"} inquiry from ${contact.company} - ${ticketId}`,
|
||||||
html: generateConsultationEmail(contact, aiContext, ticketId, appUrl),
|
html: generateConsultationEmail(contact, aiContext, ticketId, appUrl),
|
||||||
replyTo: contact.email,
|
replyTo: contact.email,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Track email delivery
|
|
||||||
await prisma.operationsSignal.update({
|
await prisma.operationsSignal.update({
|
||||||
where: { id: signal.id },
|
where: { id: signal.id },
|
||||||
data: {
|
data: {
|
||||||
@@ -86,6 +154,8 @@ export async function POST(request: NextRequest) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
log.info("consultation.submitted", { ticketId, emailSent: emailResult.success });
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
ticketId,
|
ticketId,
|
||||||
@@ -93,33 +163,65 @@ export async function POST(request: NextRequest) {
|
|||||||
emailError: emailResult.error,
|
emailError: emailResult.error,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Consultation API error:", error);
|
log.error("consultation.submit_failed", error);
|
||||||
return NextResponse.json({ error: "Failed to submit consultation request" }, { status: 500 });
|
return NextResponse.json({ error: "Failed to submit consultation request" }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function generateConsultationEmail(contact: any, aiContext: any, ticketId: string, appUrl: string) {
|
type ParsedContact = z.infer<typeof ConsultationSchema>["contact"];
|
||||||
const insights = (aiContext?.conversationInsights || []).map((i: string) => `<li style="margin-bottom: 6px;">${i}</li>`).join("");
|
type ParsedAiContext = z.infer<typeof ConsultationSchema>["aiContext"];
|
||||||
const topics = (aiContext?.suggestedTopics || []).map((t: string) => `<li style="margin-bottom: 4px; color: #0066CC;">${t}</li>`).join("");
|
|
||||||
|
function generateConsultationEmail(
|
||||||
|
contact: ParsedContact,
|
||||||
|
aiContext: ParsedAiContext | undefined,
|
||||||
|
ticketId: string,
|
||||||
|
_appUrl: string,
|
||||||
|
) {
|
||||||
|
const insightsHtml = (aiContext?.conversationInsights ?? [])
|
||||||
|
.map((i) => `<li style="margin-bottom: 6px;">${escapeHtml(i)}</li>`)
|
||||||
|
.join("");
|
||||||
|
const topicsHtml = (aiContext?.suggestedTopics ?? [])
|
||||||
|
.map((t) => `<li style="margin-bottom: 4px; color: #0066CC;">${escapeHtml(t)}</li>`)
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
const safeName = escapeHtml(contact.name);
|
||||||
|
const safeCompany = escapeHtml(contact.company);
|
||||||
|
const safeEmail = escapeHtml(contact.email);
|
||||||
|
const mailHref = escapeAttr(safeMailto(contact.email));
|
||||||
|
const safePhone = contact.phone ? escapeHtml(contact.phone) : "";
|
||||||
|
const safePreferred = escapeHtml((contact.preferredContact || "email").toUpperCase());
|
||||||
|
const safeTimeframe = escapeHtml(contact.timeframe || "N/A");
|
||||||
|
const safeIndustry = aiContext?.industryLabel ? escapeHtml(aiContext.industryLabel) : "";
|
||||||
|
const safeProcess = aiContext?.process ? escapeHtml(aiContext.process) : "General";
|
||||||
|
const safeSavings = aiContext?.estimatedSavingsPercent
|
||||||
|
? escapeHtml(String(aiContext.estimatedSavingsPercent))
|
||||||
|
: "";
|
||||||
|
const safeVolume = aiContext?.productionVolume ? escapeHtml(aiContext.productionVolume) : "";
|
||||||
|
const safeMessage = contact.message ? escapeHtml(contact.message) : "";
|
||||||
|
const safeTicketId = escapeHtml(ticketId);
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 600px; margin: 0 auto; color: #1D1D1F;">
|
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 600px; margin: 0 auto; color: #1D1D1F;">
|
||||||
<div style="border-bottom: 2px solid #00F0FF; padding-bottom: 20px; margin-bottom: 24px;">
|
<div style="border-bottom: 2px solid #00F0FF; padding-bottom: 20px; margin-bottom: 24px;">
|
||||||
<p style="color: #86868B; font-size: 12px; letter-spacing: 2px; text-transform: uppercase;">FLUX AI — Engineering Consultation</p>
|
<p style="color: #86868B; font-size: 12px; letter-spacing: 2px; text-transform: uppercase;">FLUX AI - Engineering Consultation</p>
|
||||||
<h1 style="margin: 8px 0 0 0; font-size: 22px;">New Consultation Request</h1>
|
<h1 style="margin: 8px 0 0 0; font-size: 22px;">New Consultation Request</h1>
|
||||||
<p style="font-family: monospace; color: #00F0FF;">${ticketId}</p>
|
<p style="font-family: monospace; color: #00F0FF;">${safeTicketId}</p>
|
||||||
</div>
|
</div>
|
||||||
<div style="background: #F5F5F7; padding: 20px; border-radius: 12px; margin-bottom: 24px;">
|
<div style="background: #F5F5F7; padding: 20px; border-radius: 12px; margin-bottom: 24px;">
|
||||||
<p style="margin: 4px 0;"><strong>${contact.name}</strong> — ${contact.company}</p>
|
<p style="margin: 4px 0;"><strong>${safeName}</strong> - ${safeCompany}</p>
|
||||||
<p style="margin: 4px 0;">Email: <a href="mailto:${contact.email}" style="color: #0066CC;">${contact.email}</a></p>
|
<p style="margin: 4px 0;">Email: <a href="${mailHref}" style="color: #0066CC;">${safeEmail}</a></p>
|
||||||
${contact.phone ? `<p style="margin: 4px 0;">Phone: ${contact.phone}</p>` : ""}
|
${safePhone ? `<p style="margin: 4px 0;">Phone: ${safePhone}</p>` : ""}
|
||||||
<p style="margin: 4px 0;">Preferred: <strong>${(contact.preferredContact || "email").toUpperCase()}</strong> · Timeframe: <strong>${contact.timeframe || "N/A"}</strong></p>
|
<p style="margin: 4px 0;">Preferred: <strong>${safePreferred}</strong> · Timeframe: <strong>${safeTimeframe}</strong></p>
|
||||||
</div>
|
</div>
|
||||||
${aiContext?.industryLabel ? `<div style="background: #F0FBFF; border-left: 4px solid #00F0FF; padding: 16px 20px; border-radius: 0 12px 12px 0; margin-bottom: 24px;"><h3 style="margin: 0 0 8px 0; font-size: 13px; color: #00F0FF;">AI Context</h3><p style="margin: 4px 0;"><strong>Industry:</strong> ${aiContext.industryLabel} — ${aiContext.process || "General"}</p>${aiContext.estimatedSavingsPercent ? `<p style="color: #059669;"><strong>Savings:</strong> ~${aiContext.estimatedSavingsPercent}%</p>` : ""}${aiContext.productionVolume ? `<p><strong>Volume:</strong> ${aiContext.productionVolume}</p>` : ""}</div>` : ""}
|
${
|
||||||
${insights ? `<div style="margin-bottom: 24px;"><h3 style="font-size: 13px; color: #86868B;">Key Points</h3><ul style="padding-left: 20px;">${insights}</ul></div>` : ""}
|
safeIndustry
|
||||||
${topics ? `<div style="margin-bottom: 24px;"><h3 style="font-size: 13px; color: #0066CC;">Prepare Topics</h3><ul style="padding-left: 20px; list-style: none;">${topics}</ul></div>` : ""}
|
? `<div style="background: #F0FBFF; border-left: 4px solid #00F0FF; padding: 16px 20px; border-radius: 0 12px 12px 0; margin-bottom: 24px;"><h3 style="margin: 0 0 8px 0; font-size: 13px; color: #00F0FF;">AI Context</h3><p style="margin: 4px 0;"><strong>Industry:</strong> ${safeIndustry} - ${safeProcess}</p>${safeSavings ? `<p style="color: #059669;"><strong>Savings:</strong> ~${safeSavings}%</p>` : ""}${safeVolume ? `<p><strong>Volume:</strong> ${safeVolume}</p>` : ""}</div>`
|
||||||
${contact.message ? `<div style="margin-bottom: 24px;"><h3 style="font-size: 13px; color: #86868B;">Client Notes</h3><div style="padding: 12px; border-left: 4px solid #1D1D1F; background: #FAFAFA;">${contact.message}</div></div>` : ""}
|
: ""
|
||||||
<div style="border-top: 1px solid #E5E5EA; padding-top: 16px; margin-top: 32px; font-size: 11px; color: #86868B; text-align: center;"><p>FLUX Operations · Reply to contact ${contact.name} directly.</p></div>
|
}
|
||||||
|
${insightsHtml ? `<div style="margin-bottom: 24px;"><h3 style="font-size: 13px; color: #86868B;">Key Points</h3><ul style="padding-left: 20px;">${insightsHtml}</ul></div>` : ""}
|
||||||
|
${topicsHtml ? `<div style="margin-bottom: 24px;"><h3 style="font-size: 13px; color: #0066CC;">Prepare Topics</h3><ul style="padding-left: 20px; list-style: none;">${topicsHtml}</ul></div>` : ""}
|
||||||
|
${safeMessage ? `<div style="margin-bottom: 24px;"><h3 style="font-size: 13px; color: #86868B;">Client Notes</h3><div style="padding: 12px; border-left: 4px solid #1D1D1F; background: #FAFAFA;">${safeMessage}</div></div>` : ""}
|
||||||
|
<div style="border-top: 1px solid #E5E5EA; padding-top: 16px; margin-top: 32px; font-size: 11px; color: #86868B; text-align: center;"><p>FLUX Operations · Reply to contact ${safeName} directly.</p></div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
// src/app/api/csrf/route.ts
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Issues a fresh CSRF token for the current browser session. Public POST
|
||||||
|
// endpoints (e.g. /api/consultation) require the matching cookie + header
|
||||||
|
// (double-submit pattern). See src/lib/csrf.ts for the verification flow.
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { CSRF_COOKIE_NAME, csrfCookieOptions, issueCsrfToken } from "@/lib/csrf";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const token = issueCsrfToken();
|
||||||
|
const res = NextResponse.json({ token });
|
||||||
|
res.cookies.set(CSRF_COOKIE_NAME, token, csrfCookieOptions);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
// src/app/api/health/route.ts
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Readiness probe. Returns 200 only if Postgres responds. Used by Docker
|
||||||
|
// healthcheck and by external uptime monitors so Nginx can fail fast and
|
||||||
|
// orchestrators can recycle the container if the DB connection dies.
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { log } from "@/lib/logger";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const startedAt = Date.now();
|
||||||
|
try {
|
||||||
|
await prisma.$queryRaw`SELECT 1`;
|
||||||
|
return Response.json({
|
||||||
|
ok: true,
|
||||||
|
db: "up",
|
||||||
|
latencyMs: Date.now() - startedAt,
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
log.error("health.db_unreachable", e);
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
ok: false,
|
||||||
|
db: "down",
|
||||||
|
latencyMs: Date.now() - startedAt,
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
{ status: 503 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,19 +2,20 @@ import { NextRequest, NextResponse } from "next/server";
|
|||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { revalidateContent } from "@/lib/revalidate";
|
import { revalidateContent } from "@/lib/revalidate";
|
||||||
|
import { detectFileType, expectedTypeForExtension } from "@/lib/fileType";
|
||||||
|
import { log } from "@/lib/logger";
|
||||||
|
|
||||||
// 1. REGLAS DE SEGURIDAD ESTRICTAS
|
// 1. STRICT SECURITY RULES
|
||||||
const ALLOWED_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.webp', '.mp4', '.mov'];
|
const ALLOWED_EXTENSIONS = [".jpg", ".jpeg", ".png", ".webp", ".mp4", ".mov"];
|
||||||
const MAX_FILE_SIZE = 500 * 1024 * 1024; // 500MB Límite
|
const MAX_FILE_SIZE = 500 * 1024 * 1024; // 500MB
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
const file = formData.get("file") as File;
|
const file = formData.get("file") as File;
|
||||||
const ticketId = formData.get("ticketId") as string;
|
const ticketId = formData.get("ticketId") as string;
|
||||||
const clientName = formData.get("clientName") as string || "unregistered";
|
const clientName = (formData.get("clientName") as string) || "unregistered";
|
||||||
|
|
||||||
// 2. VALIDACIONES INICIALES
|
|
||||||
if (!file || !ticketId) {
|
if (!file || !ticketId) {
|
||||||
return NextResponse.json({ error: "Faltan datos requeridos (archivo o ticketId)" }, { status: 400 });
|
return NextResponse.json({ error: "Faltan datos requeridos (archivo o ticketId)" }, { status: 400 });
|
||||||
}
|
}
|
||||||
@@ -25,50 +26,66 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
const ext = path.extname(file.name).toLowerCase();
|
const ext = path.extname(file.name).toLowerCase();
|
||||||
if (!ALLOWED_EXTENSIONS.includes(ext)) {
|
if (!ALLOWED_EXTENSIONS.includes(ext)) {
|
||||||
return NextResponse.json({ error: `Formato no permitido. Solo aceptamos: ${ALLOWED_EXTENSIONS.join(", ")}` }, { status: 400 });
|
return NextResponse.json(
|
||||||
|
{ error: `Formato no permitido. Solo aceptamos: ${ALLOWED_EXTENSIONS.join(", ")}` },
|
||||||
|
{ status: 415 },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. SANITIZACIÓN DE NOMBRES (Evita inyección de código y caracteres raros)
|
// 2. SANITIZE NAMES
|
||||||
const safeTicketId = ticketId.replace(/[^a-zA-Z0-9-]/g, "");
|
const safeTicketId = ticketId.replace(/[^a-zA-Z0-9-]/g, "");
|
||||||
// Convertimos "David Herran!" a "david-herran"
|
|
||||||
const safeClientName = clientName.toLowerCase().replace(/[^a-z0-9]/g, "-").substring(0, 30);
|
const safeClientName = clientName.toLowerCase().replace(/[^a-z0-9]/g, "-").substring(0, 30);
|
||||||
const folderName = `${safeTicketId}-${safeClientName}`;
|
const folderName = `${safeTicketId}-${safeClientName}`;
|
||||||
|
|
||||||
// 4. CREACIÓN DE LA CARPETA DEL CLIENTE
|
|
||||||
// Ruta final: /public/operations-inbox/REQ-2026-X8Y-david-herran/
|
|
||||||
const uploadDir = path.join(process.cwd(), "public", "operations-inbox", folderName);
|
const uploadDir = path.join(process.cwd(), "public", "operations-inbox", folderName);
|
||||||
|
|
||||||
// Escudo Anti-Hacking (Verifica que la ruta resuelta no se escape de la carpeta public)
|
// 3. PATH TRAVERSAL GUARD
|
||||||
if (!path.resolve(uploadDir).startsWith(path.resolve(process.cwd(), "public", "operations-inbox"))) {
|
if (!path.resolve(uploadDir).startsWith(path.resolve(process.cwd(), "public", "operations-inbox"))) {
|
||||||
return NextResponse.json({ error: "Ruta de directorio inválida" }, { status: 400 });
|
return NextResponse.json({ error: "Ruta de directorio inválida" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 4. READ BUFFER FIRST so we can sniff magic bytes BEFORE writing to disk.
|
||||||
|
// This prevents stored-XSS payloads (HTML/JS renamed to .png).
|
||||||
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
|
const detected = detectFileType(buffer);
|
||||||
|
const expected = expectedTypeForExtension(ext);
|
||||||
|
|
||||||
|
if (!detected || (expected && detected !== expected && !(expected === "jpeg" && detected === "jpeg"))) {
|
||||||
|
log.warn("public_upload.magic_mismatch", {
|
||||||
|
ext,
|
||||||
|
detected,
|
||||||
|
expected,
|
||||||
|
size: file.size,
|
||||||
|
ticketId: safeTicketId,
|
||||||
|
});
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "El contenido del archivo no coincide con su extensión." },
|
||||||
|
{ status: 415 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. CREATE FOLDER + WRITE
|
||||||
if (!fs.existsSync(uploadDir)) {
|
if (!fs.existsSync(uploadDir)) {
|
||||||
fs.mkdirSync(uploadDir, { recursive: true });
|
fs.mkdirSync(uploadDir, { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. GUARDAR EL ARCHIVO FÍSICAMENTE
|
|
||||||
const safeFileName = file.name.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9._-]/g, "");
|
const safeFileName = file.name.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9._-]/g, "");
|
||||||
const filePath = path.join(uploadDir, safeFileName);
|
const filePath = path.join(uploadDir, safeFileName);
|
||||||
|
|
||||||
const buffer = Buffer.from(await file.arrayBuffer());
|
|
||||||
fs.writeFileSync(filePath, buffer);
|
fs.writeFileSync(filePath, buffer);
|
||||||
|
|
||||||
// 6. DEVOLVER LA URL PÚBLICA PARA GUARDARLA EN POSTGRES
|
|
||||||
const publicUrl = `/operations-inbox/${folderName}/${safeFileName}`;
|
const publicUrl = `/operations-inbox/${folderName}/${safeFileName}`;
|
||||||
|
|
||||||
// Invalida caché del operations-inbox / dashboard
|
|
||||||
revalidateContent({ scope: "operations-inbox", slug: folderName });
|
revalidateContent({ scope: "operations-inbox", slug: folderName });
|
||||||
|
|
||||||
|
log.info("public_upload.saved", { ticketId: safeTicketId, file: safeFileName, detected, bytes: file.size });
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
url: publicUrl,
|
url: publicUrl,
|
||||||
fileName: safeFileName,
|
fileName: safeFileName,
|
||||||
type: ext === '.mp4' || ext === '.mov' ? 'video' : 'image'
|
type: ext === ".mp4" || ext === ".mov" ? "video" : "image",
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error crítico en subida pública:", error);
|
log.error("public_upload.failed", error);
|
||||||
return NextResponse.json({ error: "Error interno del servidor" }, { status: 500 });
|
return NextResponse.json({ error: "Error interno del servidor" }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
// src/app/hq-command/dashboard/conversations/[id]/page.tsx
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// FluxAI conversation detail — full event timeline, tool calls expanded,
|
||||||
|
// link to the OperationsSignal when the chat converted.
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
export const revalidate = 0;
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { ArrowLeft, Bot, User, Wrench, AlertTriangle, MessageSquare } from "lucide-react";
|
||||||
|
|
||||||
|
interface PageProps {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function ConversationDetailPage({ params }: PageProps) {
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
const conversation = await prisma.aiConversation.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
events: { orderBy: { createdAt: "asc" } },
|
||||||
|
signal: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!conversation) notFound();
|
||||||
|
|
||||||
|
const durationMs =
|
||||||
|
(conversation.closedAt ?? conversation.lastMessageAt).getTime() -
|
||||||
|
conversation.startedAt.getTime();
|
||||||
|
const durationLabel =
|
||||||
|
durationMs < 60000 ? `${Math.round(durationMs / 1000)}s` : `${Math.round(durationMs / 60000)} min`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen px-6 md:px-12 py-10 max-w-5xl mx-auto">
|
||||||
|
<Link
|
||||||
|
href="/hq-command/dashboard/conversations"
|
||||||
|
className="inline-flex items-center gap-2 text-xs text-white/50 hover:text-white mb-6"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={14} /> Back to conversations
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<header className="mb-8">
|
||||||
|
<h1 className="text-2xl font-light tracking-tight">Conversation</h1>
|
||||||
|
<p className="text-xs font-mono text-white/40 mt-1">{conversation.sessionId}</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-8">
|
||||||
|
<Meta label="Started" value={conversation.startedAt.toISOString().slice(0, 16).replace("T", " ")} />
|
||||||
|
<Meta label="Duration" value={durationLabel} />
|
||||||
|
<Meta label="Industry" value={conversation.industryLabel ?? "—"} />
|
||||||
|
<Meta label="Locale" value={conversation.locale ?? "—"} />
|
||||||
|
<Meta label="Stage" value={conversation.funnelStage} />
|
||||||
|
<Meta label="Outcome" value={conversation.outcome} />
|
||||||
|
<Meta label="Messages" value={String(conversation.messageCount)} />
|
||||||
|
<Meta label="Tool calls" value={String(conversation.toolCallCount)} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{conversation.signal ? (
|
||||||
|
<div className="mb-8 rounded-2xl border border-emerald-500/30 bg-emerald-500/[0.04] p-5">
|
||||||
|
<div className="text-xs uppercase tracking-widest text-emerald-300">
|
||||||
|
Converted to consultation
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-sm">
|
||||||
|
<span className="font-mono text-emerald-300">{conversation.signal.ticketId}</span>
|
||||||
|
{" · "}
|
||||||
|
<span className="text-white/70">{conversation.signal.clientName}</span>
|
||||||
|
{" · "}
|
||||||
|
<span className="text-white/50">{conversation.signal.clientCompany}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{conversation.pageUrl ? (
|
||||||
|
<p className="mb-6 text-xs text-white/40">
|
||||||
|
Entry page: <span className="text-white/60">{conversation.pageUrl}</span>
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<h2 className="text-xs uppercase tracking-widest text-white/40 mb-3">Event timeline</h2>
|
||||||
|
<ol className="space-y-2">
|
||||||
|
{conversation.events.map((ev) => (
|
||||||
|
<li
|
||||||
|
key={ev.id}
|
||||||
|
className="rounded-xl border border-white/10 bg-white/[0.02] px-4 py-3 text-sm"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<EventIcon type={ev.type} />
|
||||||
|
<span className="text-xs uppercase tracking-widest text-white/40">
|
||||||
|
{ev.type}
|
||||||
|
{ev.toolName ? ` · ${ev.toolName}` : ""}
|
||||||
|
</span>
|
||||||
|
<span className="ml-auto text-[10px] text-white/30">
|
||||||
|
{ev.createdAt.toISOString().slice(11, 19)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<pre className="mt-2 whitespace-pre-wrap text-xs text-white/70 break-words font-mono">
|
||||||
|
{truncate(ev.payloadJson, 1200)}
|
||||||
|
</pre>
|
||||||
|
{ev.tokensIn || ev.tokensOut || ev.latencyMs ? (
|
||||||
|
<div className="mt-2 flex gap-4 text-[10px] text-white/40">
|
||||||
|
{ev.latencyMs ? <span>{ev.latencyMs} ms</span> : null}
|
||||||
|
{ev.tokensIn ? <span>in: {ev.tokensIn}</span> : null}
|
||||||
|
{ev.tokensOut ? <span>out: {ev.tokensOut}</span> : null}
|
||||||
|
{ev.cachedTokens ? <span>cached: {ev.cachedTokens}</span> : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncate(s: string, n: number) {
|
||||||
|
if (s.length <= n) return s;
|
||||||
|
return s.slice(0, n) + "…";
|
||||||
|
}
|
||||||
|
|
||||||
|
function Meta({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-white/5 bg-white/[0.02] px-4 py-3">
|
||||||
|
<div className="text-[10px] uppercase tracking-widest text-white/30">{label}</div>
|
||||||
|
<div className="text-sm mt-1">{value}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EventIcon({ type }: { type: string }) {
|
||||||
|
if (type === "user_msg") return <User size={14} className="text-[#00F0FF]" />;
|
||||||
|
if (type === "ai_msg") return <Bot size={14} className="text-purple-300" />;
|
||||||
|
if (type === "tool_call") return <Wrench size={14} className="text-amber-300" />;
|
||||||
|
if (type === "tool_result") return <Wrench size={14} className="text-emerald-300" />;
|
||||||
|
if (type === "error") return <AlertTriangle size={14} className="text-red-400" />;
|
||||||
|
return <MessageSquare size={14} className="text-white/40" />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,298 @@
|
|||||||
|
// src/app/hq-command/dashboard/conversations/page.tsx
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// FluxAI Conversations — analytics MVP for the HQ Command Center.
|
||||||
|
// Surfaces what visitors are actually asking, which industries dominate,
|
||||||
|
// and how many chats convert into consultation tickets.
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
export const revalidate = 0;
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
MessageSquare,
|
||||||
|
Target,
|
||||||
|
Activity,
|
||||||
|
TrendingUp,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
type StageRow = { funnelStage: string; _count: { _all: number } };
|
||||||
|
type IndustryRow = { industryLabel: string | null; _count: { _all: number } };
|
||||||
|
type OutcomeRow = { outcome: string; _count: { _all: number } };
|
||||||
|
|
||||||
|
export default async function ConversationsDashboardPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{ stage?: string; outcome?: string; industry?: string }>;
|
||||||
|
}) {
|
||||||
|
const { stage, outcome, industry } = (await searchParams) ?? {};
|
||||||
|
|
||||||
|
// ── KPI: counts + breakdowns ─────────────────────────────────────────────
|
||||||
|
const [
|
||||||
|
total,
|
||||||
|
stageBreakdown,
|
||||||
|
industryBreakdown,
|
||||||
|
outcomeBreakdown,
|
||||||
|
averages,
|
||||||
|
] = await Promise.all([
|
||||||
|
prisma.aiConversation.count(),
|
||||||
|
prisma.aiConversation.groupBy({
|
||||||
|
by: ["funnelStage"],
|
||||||
|
_count: { _all: true },
|
||||||
|
}) as unknown as Promise<StageRow[]>,
|
||||||
|
prisma.aiConversation.groupBy({
|
||||||
|
by: ["industryLabel"],
|
||||||
|
_count: { _all: true },
|
||||||
|
orderBy: { _count: { industryLabel: "desc" } },
|
||||||
|
take: 5,
|
||||||
|
}) as unknown as Promise<IndustryRow[]>,
|
||||||
|
prisma.aiConversation.groupBy({
|
||||||
|
by: ["outcome"],
|
||||||
|
_count: { _all: true },
|
||||||
|
}) as unknown as Promise<OutcomeRow[]>,
|
||||||
|
prisma.aiConversation.aggregate({
|
||||||
|
_avg: { messageCount: true, toolCallCount: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const consultationCount =
|
||||||
|
outcomeBreakdown.find((r) => r.outcome === "CONSULTATION")?._count._all ?? 0;
|
||||||
|
const conversionRate = total > 0 ? Math.round((consultationCount / total) * 10000) / 100 : 0;
|
||||||
|
|
||||||
|
// ── Recent conversations list ────────────────────────────────────────────
|
||||||
|
const conversations = await prisma.aiConversation.findMany({
|
||||||
|
where: {
|
||||||
|
...(stage ? { funnelStage: stage } : {}),
|
||||||
|
...(outcome ? { outcome } : {}),
|
||||||
|
...(industry ? { industryLabel: industry } : {}),
|
||||||
|
},
|
||||||
|
orderBy: { startedAt: "desc" },
|
||||||
|
take: 50,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
sessionId: true,
|
||||||
|
startedAt: true,
|
||||||
|
lastMessageAt: true,
|
||||||
|
industryLabel: true,
|
||||||
|
funnelStage: true,
|
||||||
|
outcome: true,
|
||||||
|
messageCount: true,
|
||||||
|
toolCallCount: true,
|
||||||
|
estimatedSavingsPercent: true,
|
||||||
|
pageUrl: true,
|
||||||
|
locale: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen px-6 md:px-12 py-10 max-w-7xl mx-auto">
|
||||||
|
<Link
|
||||||
|
href="/hq-command/dashboard"
|
||||||
|
className="inline-flex items-center gap-2 text-xs text-white/50 hover:text-white mb-6"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={14} /> Back to Command Center
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<header className="mb-10">
|
||||||
|
<h1 className="text-3xl font-light tracking-tight">FluxAI Conversations</h1>
|
||||||
|
<p className="text-sm text-white/50 mt-1">
|
||||||
|
Funnel analytics for every chat with the on-site engineering assistant.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* ── KPI tiles ───────────────────────────────────────────────────── */}
|
||||||
|
<section className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-10">
|
||||||
|
<Kpi
|
||||||
|
icon={<MessageSquare size={16} />}
|
||||||
|
label="Total conversations"
|
||||||
|
value={String(total)}
|
||||||
|
accent="text-[#00F0FF]"
|
||||||
|
/>
|
||||||
|
<Kpi
|
||||||
|
icon={<Target size={16} />}
|
||||||
|
label="Conversion rate"
|
||||||
|
value={`${conversionRate}%`}
|
||||||
|
sub={`${consultationCount} → consultation`}
|
||||||
|
accent="text-emerald-400"
|
||||||
|
/>
|
||||||
|
<Kpi
|
||||||
|
icon={<Activity size={16} />}
|
||||||
|
label="Avg messages / chat"
|
||||||
|
value={(averages._avg.messageCount ?? 0).toFixed(1)}
|
||||||
|
accent="text-purple-400"
|
||||||
|
/>
|
||||||
|
<Kpi
|
||||||
|
icon={<TrendingUp size={16} />}
|
||||||
|
label="Avg tool calls"
|
||||||
|
value={(averages._avg.toolCallCount ?? 0).toFixed(1)}
|
||||||
|
accent="text-amber-400"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ── Funnel + Industries ─────────────────────────────────────────── */}
|
||||||
|
<section className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-10">
|
||||||
|
<Panel title="Funnel stages">
|
||||||
|
{stageBreakdown.length === 0 ? (
|
||||||
|
<Empty label="No conversations yet." />
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{(["DISCOVERY", "QUALIFY", "RECOMMEND", "HANDOFF"] as const).map((s) => {
|
||||||
|
const row = stageBreakdown.find((r) => r.funnelStage === s);
|
||||||
|
const count = row?._count._all ?? 0;
|
||||||
|
const pct = total > 0 ? Math.round((count / total) * 100) : 0;
|
||||||
|
return (
|
||||||
|
<li key={s} className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-white/70">{s}</span>
|
||||||
|
<span className="text-white/40">{count} · {pct}%</span>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<Panel title="Top industries">
|
||||||
|
{industryBreakdown.length === 0 ? (
|
||||||
|
<Empty label="No industry signals captured yet." />
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{industryBreakdown.map((row, i) => (
|
||||||
|
<li key={i} className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-white/70">
|
||||||
|
{row.industryLabel ?? "Unknown"}
|
||||||
|
</span>
|
||||||
|
<span className="text-white/40">{row._count._all}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ── Recent conversations table ──────────────────────────────────── */}
|
||||||
|
<section>
|
||||||
|
<h2 className="text-sm uppercase tracking-widest text-white/40 mb-3">
|
||||||
|
Last 50 conversations
|
||||||
|
</h2>
|
||||||
|
<div className="overflow-x-auto rounded-2xl border border-white/10">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-white/[0.02] text-xs uppercase tracking-widest text-white/40">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 text-left">Started</th>
|
||||||
|
<th className="px-4 py-3 text-left">Industry</th>
|
||||||
|
<th className="px-4 py-3 text-left">Stage</th>
|
||||||
|
<th className="px-4 py-3 text-left">Outcome</th>
|
||||||
|
<th className="px-4 py-3 text-right">Msgs</th>
|
||||||
|
<th className="px-4 py-3 text-right">Tools</th>
|
||||||
|
<th className="px-4 py-3 text-left">Locale</th>
|
||||||
|
<th className="px-4 py-3"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{conversations.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={8} className="px-4 py-8 text-center text-white/40">
|
||||||
|
No conversations match these filters yet.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
conversations.map((c) => (
|
||||||
|
<tr key={c.id} className="border-t border-white/5 hover:bg-white/[0.02]">
|
||||||
|
<td className="px-4 py-3 text-white/60 whitespace-nowrap">
|
||||||
|
{c.startedAt.toISOString().slice(0, 16).replace("T", " ")}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">{c.industryLabel ?? "—"}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<StagePill stage={c.funnelStage} />
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<OutcomePill outcome={c.outcome} />
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right text-white/60">{c.messageCount}</td>
|
||||||
|
<td className="px-4 py-3 text-right text-white/60">{c.toolCallCount}</td>
|
||||||
|
<td className="px-4 py-3 text-white/60">{c.locale ?? "—"}</td>
|
||||||
|
<td className="px-4 py-3 text-right">
|
||||||
|
<Link
|
||||||
|
href={`/hq-command/dashboard/conversations/${c.id}`}
|
||||||
|
className="text-[#00F0FF] hover:underline text-xs"
|
||||||
|
>
|
||||||
|
Open →
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Kpi({
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
sub,
|
||||||
|
accent,
|
||||||
|
}: {
|
||||||
|
icon: React.ReactNode;
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
sub?: string;
|
||||||
|
accent?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-white/[0.02] p-5">
|
||||||
|
<div className={`flex items-center gap-2 text-xs uppercase tracking-widest ${accent ?? "text-white/40"}`}>
|
||||||
|
{icon}
|
||||||
|
<span>{label}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 text-2xl font-light tracking-tight">{value}</div>
|
||||||
|
{sub ? <div className="text-xs text-white/40 mt-1">{sub}</div> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Panel({ title, children }: { title: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-white/[0.02] p-6">
|
||||||
|
<h3 className="text-xs uppercase tracking-widest text-white/40 mb-4">{title}</h3>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Empty({ label }: { label: string }) {
|
||||||
|
return <p className="text-sm text-white/40 italic">{label}</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function StagePill({ stage }: { stage: string }) {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
DISCOVERY: "bg-white/10 text-white/70",
|
||||||
|
QUALIFY: "bg-purple-500/15 text-purple-300",
|
||||||
|
RECOMMEND: "bg-amber-500/15 text-amber-300",
|
||||||
|
HANDOFF: "bg-emerald-500/15 text-emerald-300",
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<span className={`px-2 py-1 rounded-full text-[10px] uppercase tracking-widest ${map[stage] ?? "bg-white/10 text-white/70"}`}>
|
||||||
|
{stage}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function OutcomePill({ outcome }: { outcome: string }) {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
OPEN: "bg-white/10 text-white/60",
|
||||||
|
CONSULTATION: "bg-emerald-500/15 text-emerald-300",
|
||||||
|
ABANDONED: "bg-red-500/10 text-red-300/70",
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<span className={`px-2 py-1 rounded-full text-[10px] uppercase tracking-widest ${map[outcome] ?? "bg-white/10 text-white/60"}`}>
|
||||||
|
{outcome}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -177,6 +177,15 @@ export default async function DashboardPage() {
|
|||||||
color: "text-fuchsia-400",
|
color: "text-fuchsia-400",
|
||||||
bg: "bg-fuchsia-500/10",
|
bg: "bg-fuchsia-500/10",
|
||||||
border: "hover:border-fuchsia-500/50"
|
border: "hover:border-fuchsia-500/50"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "FluxAI Conversations",
|
||||||
|
description: "Funnel analytics, top industries, and full transcripts of every chat with FluxAI.",
|
||||||
|
icon: Sparkles,
|
||||||
|
href: "/hq-command/dashboard/conversations",
|
||||||
|
color: "text-[#00F0FF]",
|
||||||
|
bg: "bg-[#00F0FF]/10",
|
||||||
|
border: "hover:border-[#00F0FF]/50"
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -251,9 +251,18 @@ export default function ConsultationScheduler({ data }: { data: ConsultationData
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// ── Fetch a fresh CSRF token (sets the matching cookie too) ──
|
||||||
|
const csrfRes = await fetch("/api/csrf", { method: "GET", credentials: "same-origin" });
|
||||||
|
const { token: csrfToken } = (await csrfRes.json()) as { token?: string };
|
||||||
|
if (!csrfToken) throw new Error("Could not obtain CSRF token");
|
||||||
|
|
||||||
const res = await fetch("/api/consultation", {
|
const res = await fetch("/api/consultation", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
credentials: "same-origin",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-CSRF-Token": csrfToken,
|
||||||
|
},
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import CaseStudyViewer from "./CaseStudyViewer";
|
|||||||
import EquipmentConfigurator from "./EquipmentConfigurator";
|
import EquipmentConfigurator from "./EquipmentConfigurator";
|
||||||
import EfficiencyCard from "./EfficiencyCard";
|
import EfficiencyCard from "./EfficiencyCard";
|
||||||
|
|
||||||
|
import { getAiSessionId } from "@/lib/aiSessionId";
|
||||||
|
|
||||||
export default function SilentObserver() {
|
export default function SilentObserver() {
|
||||||
const {
|
const {
|
||||||
isAiExpanded, toggleAi, setAiExpanded,
|
isAiExpanded, toggleAi, setAiExpanded,
|
||||||
@@ -54,15 +56,20 @@ export default function SilentObserver() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// ═══ AI SDK 6: Transport with dynamic body ═══
|
// ═══ AI SDK 6: Transport with dynamic body ═══
|
||||||
|
// sessionId is stable per visitor (localStorage UUID) so the chat route can
|
||||||
|
// stitch all messages into the same AiConversation row for analytics.
|
||||||
const transport = useMemo(() => new DefaultChatTransport({
|
const transport = useMemo(() => new DefaultChatTransport({
|
||||||
api: "/api/chat",
|
api: "/api/chat",
|
||||||
body: () => ({
|
body: () => ({
|
||||||
|
sessionId: getAiSessionId(),
|
||||||
|
locale,
|
||||||
|
pageUrl: typeof window !== "undefined" ? window.location.href : null,
|
||||||
context: {
|
context: {
|
||||||
section: sectionRef.current,
|
section: sectionRef.current,
|
||||||
activeTab: tabRef.current,
|
activeTab: tabRef.current,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
}), []);
|
}), [locale]);
|
||||||
|
|
||||||
// ═══ AI SDK 6: useChat ═══
|
// ═══ AI SDK 6: useChat ═══
|
||||||
const { messages, sendMessage, addToolOutput, status } = useChat({
|
const { messages, sendMessage, addToolOutput, status } = useChat({
|
||||||
|
|||||||
@@ -62,20 +62,31 @@ export default function NavBar() {
|
|||||||
};
|
};
|
||||||
document.addEventListener("mousedown", handleClickOutside);
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
|
|
||||||
// Verificar si existe la cookie "flux_b2b_session"
|
// Cookie check is now event-driven (no setInterval polling).
|
||||||
|
// Triggers:
|
||||||
|
// - Initial mount
|
||||||
|
// - "flux:session-changed" CustomEvent dispatched by AuthModal on login/logout
|
||||||
|
// - visibilitychange (catches logout-in-another-tab)
|
||||||
|
// - storage events (multi-tab logout via shared cookie)
|
||||||
const checkSession = () => {
|
const checkSession = () => {
|
||||||
const cookies = document.cookie.split("; ");
|
const cookies = document.cookie.split("; ");
|
||||||
const sessionExists = cookies.some(c => c.startsWith("flux_b2b_session="));
|
const sessionExists = cookies.some((c) => c.startsWith("flux_b2b_session="));
|
||||||
setHasSession(sessionExists);
|
setHasSession(sessionExists);
|
||||||
};
|
};
|
||||||
checkSession();
|
checkSession();
|
||||||
// Re-chequear cuando el modal dispare un refresh
|
|
||||||
const interval = setInterval(checkSession, 2000);
|
const handleVisibility = () => {
|
||||||
|
if (document.visibilityState === "visible") checkSession();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("flux:session-changed", checkSession);
|
||||||
|
document.addEventListener("visibilitychange", handleVisibility);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener("scroll", handleScroll);
|
window.removeEventListener("scroll", handleScroll);
|
||||||
document.removeEventListener("mousedown", handleClickOutside);
|
document.removeEventListener("mousedown", handleClickOutside);
|
||||||
clearInterval(interval);
|
window.removeEventListener("flux:session-changed", checkSession);
|
||||||
|
document.removeEventListener("visibilitychange", handleVisibility);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -6,18 +6,19 @@ import { ArrowRight, Zap, Scale, Cpu } from "lucide-react";
|
|||||||
import { Link } from "@/i18n/routing";
|
import { Link } from "@/i18n/routing";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { getIconForSlug } from "@/lib/applicationIcons";
|
import { getIconForSlug } from "@/lib/applicationIcons";
|
||||||
|
import type { AppCard, DashboardMetric } from "@/types/cms";
|
||||||
|
import { parseJsonField } from "@/types/cms";
|
||||||
|
|
||||||
export default function ApplicationsDashboard({ dbApps = [] }: { dbApps?: any[] }) {
|
export default function ApplicationsDashboard({ dbApps = [] }: { dbApps?: AppCard[] }) {
|
||||||
const activeApps = dbApps.filter(app => app.isActive);
|
const activeApps = dbApps.filter((app) => app.isActive);
|
||||||
if (!activeApps || activeApps.length === 0) return null;
|
if (!activeApps || activeApps.length === 0) return null;
|
||||||
|
|
||||||
const [activeSlug, setActiveSlug] = useState(activeApps[0]?.slug);
|
const [activeSlug, setActiveSlug] = useState<string | undefined>(activeApps[0]?.slug);
|
||||||
const activeApp = activeApps.find(app => app.slug === activeSlug) || activeApps[0];
|
const activeApp = activeApps.find((app) => app.slug === activeSlug) || activeApps[0];
|
||||||
|
|
||||||
const t = useTranslations("AppsDashboard"); // 🔥 LLAMAMOS AL DICCIONARIO
|
const t = useTranslations("AppsDashboard");
|
||||||
|
|
||||||
let metrics = [];
|
const metrics = parseJsonField<DashboardMetric[]>(activeApp?.dashboardMetricsJson, []);
|
||||||
try { metrics = JSON.parse(activeApp.dashboardMetricsJson || "[]"); } catch (e) {}
|
|
||||||
|
|
||||||
const triggerFluxAI = (prompt: string) => {
|
const triggerFluxAI = (prompt: string) => {
|
||||||
window.dispatchEvent(new CustomEvent("flux:trigger-ai", { detail: { prompt } }));
|
window.dispatchEvent(new CustomEvent("flux:trigger-ai", { detail: { prompt } }));
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { MapPin, Calendar, X, ArrowUpRight, Globe, Package, Building2, Layers }
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import CaseStudyModal, { CaseStudyData } from "@/components/ui/CaseStudyModal";
|
import CaseStudyModal, { CaseStudyData } from "@/components/ui/CaseStudyModal";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
import type { AppCard, NodeMarker } from "@/types/cms";
|
||||||
|
|
||||||
const RADIUS = 2;
|
const RADIUS = 2;
|
||||||
const CAM_FOV = 50;
|
const CAM_FOV = 50;
|
||||||
@@ -536,7 +537,7 @@ function NodeCard({ node, isDark, onClose, onViewCase }: {
|
|||||||
// ─────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────
|
||||||
// MAIN COMPONENT
|
// MAIN COMPONENT
|
||||||
// ─────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────
|
||||||
export default function GlobalOperations({ dbNodes = [], dbApps = [] }: { dbNodes?: any[]; dbApps?: any[] }) {
|
export default function GlobalOperations({ dbNodes = [], dbApps = [] }: { dbNodes?: NodeMarker[]; dbApps?: AppCard[] }) {
|
||||||
const [filter, setFilter] = useState("all");
|
const [filter, setFilter] = useState("all");
|
||||||
const [subFilter, setSubFilter] = useState<string | null>(null);
|
const [subFilter, setSubFilter] = useState<string | null>(null);
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
|||||||
@@ -1,310 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState, useRef, Suspense, useEffect } from "react";
|
|
||||||
import { Canvas, useFrame, useLoader, useThree } from "@react-three/fiber";
|
|
||||||
import { OrbitControls, QuadraticBezierLine } from "@react-three/drei";
|
|
||||||
import * as THREE from "three";
|
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
|
||||||
import { MapPin, Calendar, History, X, ArrowUpRight } from "lucide-react";
|
|
||||||
import CaseStudyModal, { CaseStudyData } from "@/components/ui/CaseStudyModal";
|
|
||||||
import { useLocale, useTranslations } from "next-intl";
|
|
||||||
|
|
||||||
const RADIUS = 2;
|
|
||||||
|
|
||||||
function latLongToVector3(lat: number, lon: number, radius: number) {
|
|
||||||
const phi = (90 - lat) * (Math.PI / 180);
|
|
||||||
const theta = (lon + 180) * (Math.PI / 180);
|
|
||||||
const x = -(radius * Math.sin(phi) * Math.cos(theta));
|
|
||||||
const z = (radius * Math.sin(phi) * Math.sin(theta));
|
|
||||||
const y = (radius * Math.cos(phi));
|
|
||||||
return new THREE.Vector3(x, y, z);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── COMPONENTE OPTIMIZADO 1: TEXTURA DE LA TIERRA CON ALTA RESOLUCIÓN ──
|
|
||||||
function EarthMesh({ isDark }: { isDark: boolean }) {
|
|
||||||
const earthTexture = useLoader(THREE.TextureLoader, "https://unpkg.com/three-globe/example/img/earth-water.png");
|
|
||||||
const { gl } = useThree();
|
|
||||||
|
|
||||||
// 🔥 Filtro de hardware para forzar nitidez al hacer Zoom
|
|
||||||
useEffect(() => {
|
|
||||||
if (earthTexture) {
|
|
||||||
earthTexture.anisotropy = gl.capabilities.getMaxAnisotropy(); // Máximo detalle en ángulos inclinados
|
|
||||||
earthTexture.minFilter = THREE.LinearMipmapLinearFilter;
|
|
||||||
earthTexture.magFilter = THREE.LinearFilter;
|
|
||||||
earthTexture.colorSpace = THREE.SRGBColorSpace; // Colores más vibrantes y definidos
|
|
||||||
earthTexture.generateMipmaps = true;
|
|
||||||
earthTexture.needsUpdate = true;
|
|
||||||
}
|
|
||||||
}, [earthTexture, gl]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<mesh>
|
|
||||||
{/* Regresamos a 64 segmentos para optimizar rendimiento (la nitidez ahora viene de la textura) */}
|
|
||||||
<sphereGeometry args={[RADIUS * 0.99, 64, 64]} />
|
|
||||||
<meshBasicMaterial
|
|
||||||
map={earthTexture}
|
|
||||||
color={isDark ? "#06F5E1" : "#86868B"}
|
|
||||||
transparent
|
|
||||||
opacity={isDark ? 0.4 : 0.3}
|
|
||||||
blending={isDark ? THREE.AdditiveBlending : THREE.NormalBlending}
|
|
||||||
/>
|
|
||||||
</mesh>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── COMPONENTE OPTIMIZADO 2: EL NODO INTELIGENTE QUE RESPONDE AL ZOOM ──
|
|
||||||
function MapNode({ marker, isSelected, hqPosition, onSelectMarker, isDark }: any) {
|
|
||||||
const meshRef = useRef<THREE.Group>(null);
|
|
||||||
const pos = latLongToVector3(marker.lat, marker.lon, RADIUS);
|
|
||||||
|
|
||||||
const isHQ = marker.nodeType === "hq";
|
|
||||||
const isEvent = marker.nodeType === "event";
|
|
||||||
const nodeColor = isHQ ? (isDark ? "#FFFFFF" : "#1D1D1F") : isEvent ? "#A855F7" : "#0066CC";
|
|
||||||
|
|
||||||
const baseSize = isHQ ? 0.04 : isEvent ? 0.035 : 0.025;
|
|
||||||
|
|
||||||
useFrame(({ camera }) => {
|
|
||||||
if (!meshRef.current) return;
|
|
||||||
const dist = camera.position.length();
|
|
||||||
const scaleFactor = Math.max(0.2, dist / 12);
|
|
||||||
const finalScale = isSelected ? scaleFactor * 1.8 : scaleFactor;
|
|
||||||
meshRef.current.scale.set(finalScale, finalScale, finalScale);
|
|
||||||
});
|
|
||||||
|
|
||||||
const distance = hqPosition.distanceTo(pos);
|
|
||||||
const arcElevation = RADIUS + (distance * 0.25) + 0.1;
|
|
||||||
const midPoint = hqPosition.clone().lerp(pos, 0.5).normalize().multiplyScalar(arcElevation);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<group>
|
|
||||||
<group ref={meshRef} position={pos}>
|
|
||||||
<mesh>
|
|
||||||
<sphereGeometry args={[baseSize, 32, 32]} />
|
|
||||||
<meshBasicMaterial color={nodeColor} />
|
|
||||||
</mesh>
|
|
||||||
|
|
||||||
{/* CAJA DE COLISIÓN AMPLIADA */}
|
|
||||||
<mesh
|
|
||||||
visible={false}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onSelectMarker(isSelected ? null : marker.id);
|
|
||||||
}}
|
|
||||||
onPointerOver={(e) => { e.stopPropagation(); document.body.style.cursor = 'pointer'; }}
|
|
||||||
onPointerOut={(e) => { e.stopPropagation(); document.body.style.cursor = 'auto'; }}
|
|
||||||
>
|
|
||||||
<sphereGeometry args={[baseSize * 4, 16, 16]} />
|
|
||||||
<meshBasicMaterial transparent opacity={0} />
|
|
||||||
</mesh>
|
|
||||||
</group>
|
|
||||||
|
|
||||||
{!isHQ && (
|
|
||||||
<QuadraticBezierLine
|
|
||||||
start={hqPosition}
|
|
||||||
end={pos}
|
|
||||||
mid={midPoint}
|
|
||||||
color={nodeColor}
|
|
||||||
lineWidth={isSelected ? 2.5 : 1.5}
|
|
||||||
transparent
|
|
||||||
opacity={isSelected ? 0.9 : 0.25}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</group>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── COMPONENTE DE ENSAMBLAJE DE LA ESFERA ──
|
|
||||||
function HologramSphere({ activeFilter, activeSubFilter, selectedMarker, onSelectMarker, isDark, dbNodes, hqPosition }: any) {
|
|
||||||
const globeRef = useRef<THREE.Group>(null);
|
|
||||||
|
|
||||||
// 🔥 MAGIA DE ROTACIÓN INTELIGENTE 🔥
|
|
||||||
useFrame(({ camera }) => {
|
|
||||||
// La cámara inicia en Z=7. Si el usuario hace zoom in (distancia < 6.5), detenemos la rotación.
|
|
||||||
// Si vuelve a alejar el mapa a su estado normal (distancia > 6.5), retoma la rotación.
|
|
||||||
const distance = camera.position.length();
|
|
||||||
|
|
||||||
if (globeRef.current && !selectedMarker && distance > 6.5) {
|
|
||||||
globeRef.current.rotation.y += 0.0005;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<group ref={globeRef}>
|
|
||||||
<mesh><sphereGeometry args={[RADIUS * 1.04, 64, 64]} /><meshBasicMaterial color={isDark ? "#00F0FF" : "#0066CC"} transparent opacity={isDark ? 0.1 : 0.05} side={THREE.BackSide} blending={THREE.AdditiveBlending} depthWrite={false} /></mesh>
|
|
||||||
<mesh><sphereGeometry args={[RADIUS * 0.98, 64, 64]} /><meshBasicMaterial color={isDark ? "#050505" : "#E5E5EA"} /></mesh>
|
|
||||||
|
|
||||||
{/* Esfera Terrestre mejorada con texturas nítidas */}
|
|
||||||
<EarthMesh isDark={isDark} />
|
|
||||||
|
|
||||||
<mesh><sphereGeometry args={[RADIUS, 64, 64]} /><meshBasicMaterial color={isDark ? "#0066CC" : "#86868B"} wireframe transparent opacity={0.06} /></mesh>
|
|
||||||
|
|
||||||
{dbNodes.map((marker: any) => {
|
|
||||||
const isHQ = marker.nodeType === "hq";
|
|
||||||
const isEvent = marker.nodeType === "event";
|
|
||||||
|
|
||||||
const matchesMain = activeFilter === "all" || (activeFilter === "installation" && !isEvent && !isHQ) || (activeFilter === "event" && isEvent) || (activeFilter === "legacy" && isHQ);
|
|
||||||
const matchesSub = !activeSubFilter || marker.application === activeSubFilter || isHQ;
|
|
||||||
const isVisible = matchesMain && matchesSub;
|
|
||||||
|
|
||||||
if (!isVisible) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<MapNode
|
|
||||||
key={marker.id}
|
|
||||||
marker={marker}
|
|
||||||
isSelected={selectedMarker === marker.id}
|
|
||||||
hqPosition={hqPosition}
|
|
||||||
onSelectMarker={onSelectMarker}
|
|
||||||
isDark={isDark}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</group>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── INTERFAZ GRÁFICA PRINCIPAL ──
|
|
||||||
export default function GlobalOperations({ dbNodes = [], dbApps = [] }: { dbNodes?: any[], dbApps?: any[] }) {
|
|
||||||
const [activeFilter, setActiveFilter] = useState("all");
|
|
||||||
const [activeSubFilter, setActiveSubFilter] = useState<string | null>(null);
|
|
||||||
const [selectedMarkerId, setSelectedMarkerId] = useState<string | null>(null);
|
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
|
||||||
const [isDark, setIsDark] = useState(false);
|
|
||||||
|
|
||||||
const t = useTranslations("GlobalOperations");
|
|
||||||
|
|
||||||
const dynamicSubFilters = dbApps
|
|
||||||
.filter(app => app.isActive)
|
|
||||||
.map(app => ({ id: app.slug, label: app.title }));
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const checkTheme = () => setIsDark(document.documentElement.classList.contains("dark"));
|
|
||||||
checkTheme();
|
|
||||||
const observer = new MutationObserver(checkTheme);
|
|
||||||
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
|
|
||||||
return () => observer.disconnect();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const filters = [
|
|
||||||
{ id: "all", label: t("filterAll"), icon: MapPin },
|
|
||||||
{ id: "installation", label: t("filterInstallations"), icon: MapPin },
|
|
||||||
{ id: "event", label: t("filterEvents"), icon: Calendar },
|
|
||||||
{ id: "legacy", label: t("filterHQ"), icon: History }
|
|
||||||
];
|
|
||||||
|
|
||||||
const selectedData = dbNodes.find(d => d.id === selectedMarkerId);
|
|
||||||
|
|
||||||
const hqNode = dbNodes.find(d => d.application === "hq");
|
|
||||||
const hqLat = hqNode ? hqNode.lat : 45.78;
|
|
||||||
const hqLon = hqNode ? hqNode.lon : 11.76;
|
|
||||||
const hqPosition = latLongToVector3(hqLat, hqLon, RADIUS);
|
|
||||||
|
|
||||||
const handleMainFilter = (id: string) => {
|
|
||||||
setActiveFilter(id);
|
|
||||||
setActiveSubFilter(null);
|
|
||||||
setSelectedMarkerId(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<section id="global" className="relative w-full max-w-7xl mx-auto px-6 py-32 z-10">
|
|
||||||
<div className="flex flex-col lg:flex-row gap-12 items-center h-auto lg:h-[700px]">
|
|
||||||
|
|
||||||
<div className="w-full lg:w-1/3 flex flex-col h-full justify-center mb-12 lg:mb-0">
|
|
||||||
<div className="p-6 md:p-8 -ml-6 md:-ml-8 rounded-3xl bg-white/40 dark:bg-[#0A0A0C]/50 backdrop-blur-md border border-white/50 dark:border-white/5 shadow-sm transition-colors">
|
|
||||||
<h2 className="text-sm font-semibold tracking-widest text-[#0066CC] dark:text-[#4DA6FF] uppercase mb-4">{t("subtitle")}</h2>
|
|
||||||
<h3 className="text-4xl md:text-5xl font-light text-[#1D1D1F] dark:text-[#F5F5F7] tracking-tight mb-8 leading-[1.1] transition-colors">
|
|
||||||
{t("title1")} <br /><span className="font-medium">{t("title2")}</span>
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2 mb-4">
|
|
||||||
{filters.map((f) => (
|
|
||||||
<button key={f.id} onClick={() => handleMainFilter(f.id)} className={`px-4 py-2 rounded-full text-sm font-medium transition-all duration-300 border ${ activeFilter === f.id ? "bg-[#1D1D1F] dark:bg-white text-white dark:text-[#1D1D1F] border-[#1D1D1F] dark:border-white shadow-md" : "bg-white/60 dark:bg-white/5 text-[#86868B] dark:text-[#A1A1A6] border-white/60 dark:border-white/10 hover:text-[#1D1D1F] dark:hover:text-white" } backdrop-blur-md`}>
|
|
||||||
{f.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<AnimatePresence>
|
|
||||||
{activeFilter === "installation" && (
|
|
||||||
<motion.div initial={{ opacity: 0, height: 0 }} animate={{ opacity: 1, height: "auto" }} exit={{ opacity: 0, height: 0 }} className="flex flex-wrap gap-2 mb-4 overflow-hidden">
|
|
||||||
<span className="w-full text-xs font-semibold uppercase tracking-widest text-[#86868B] mb-1">{t("filterByApp")}</span>
|
|
||||||
{dynamicSubFilters.map((sub) => (
|
|
||||||
<button key={sub.id} onClick={() => { setActiveSubFilter(activeSubFilter === sub.id ? null : sub.id); setSelectedMarkerId(null); }} className={`px-3 py-1.5 rounded-full text-xs transition-all duration-200 border ${ activeSubFilter === sub.id ? "bg-[#0066CC]/10 dark:bg-[#0066CC]/20 text-[#0066CC] dark:text-[#4DA6FF] border-[#0066CC]/30 font-medium" : "bg-white/40 dark:bg-transparent text-[#86868B] border-transparent hover:text-[#1D1D1F] dark:hover:text-white" }`}>
|
|
||||||
{sub.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<AnimatePresence mode="wait">
|
|
||||||
{!selectedMarkerId && (
|
|
||||||
<motion.div key={activeFilter + (activeSubFilter || "")} initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -10 }} className="bg-white/60 dark:bg-[#1D1D1F]/60 backdrop-blur-2xl border border-white/40 dark:border-white/10 shadow-[0_8px_32px_rgba(0,0,0,0.04)] dark:shadow-[0_8px_32px_rgba(0,0,0,0.4)] rounded-3xl p-8 mt-4 transition-colors">
|
|
||||||
<h4 className="text-[#86868B] text-xs font-semibold uppercase tracking-wider mb-2 flex items-center gap-2"><span className="w-2 h-2 rounded-full bg-[#0066CC] animate-pulse"></span>{t("networkStatus")}</h4>
|
|
||||||
<p className="text-xl font-light text-[#1D1D1F] dark:text-[#F5F5F7] mb-6 leading-relaxed transition-colors">
|
|
||||||
{activeSubFilter
|
|
||||||
? t("statusShowing", { app: activeSubFilter.replace("-", " ") })
|
|
||||||
: t("statusTracking", { count: dbNodes.filter(n =>
|
|
||||||
(activeFilter === "all") ||
|
|
||||||
(activeFilter === "installation" && n.nodeType === "installation") ||
|
|
||||||
(activeFilter === "event" && n.nodeType === "event") ||
|
|
||||||
(activeFilter === "legacy" && n.nodeType === "hq")
|
|
||||||
).length })}
|
|
||||||
</p>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="w-full lg:w-2/3 h-[500px] lg:h-full relative rounded-[2.5rem] overflow-hidden bg-white/30 dark:bg-transparent backdrop-blur-sm dark:backdrop-blur-none border border-white/40 dark:border-transparent transition-all duration-700">
|
|
||||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[150%] h-[150%] bg-[radial-gradient(circle_at_center,rgba(0,102,204,0.15)_0%,transparent_60%)] hidden dark:block pointer-events-none blur-3xl" />
|
|
||||||
|
|
||||||
<div className="absolute top-4 right-4 z-10 text-[10px] font-mono text-[#86868B] dark:text-[#A1A1A6] tracking-widest uppercase bg-white/50 dark:bg-white/5 px-3 py-1 rounded-full backdrop-blur-md border border-white/50 dark:border-white/10 transition-colors pointer-events-none">
|
|
||||||
{t("helpText")}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<AnimatePresence>
|
|
||||||
{selectedData && (
|
|
||||||
<motion.div initial={{ opacity: 0, x: 20, y: 20 }} animate={{ opacity: 1, x: 0, y: 0 }} exit={{ opacity: 0, x: 20, y: 20 }} className="absolute bottom-4 right-4 z-20 w-[calc(100%-2rem)] md:w-80 bg-white/80 dark:bg-[#1D1D1F]/80 backdrop-blur-2xl border border-white/60 dark:border-white/10 shadow-lg dark:shadow-[0_10px_40px_rgba(0,0,0,0.5)] rounded-2xl p-6 transition-colors">
|
|
||||||
<div className="flex justify-between items-start mb-4">
|
|
||||||
<div className="flex items-center gap-2 text-[#0066CC] dark:text-[#4DA6FF]">
|
|
||||||
<MapPin size={14} />
|
|
||||||
<span className="text-[10px] font-semibold uppercase tracking-wider">
|
|
||||||
{selectedData.nodeType === 'event' ? t("typeEvent") : selectedData.nodeType === 'hq' ? t("typeHQ") : t("typeInstall")}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<button onClick={() => setSelectedMarkerId(null)} className="text-[#86868B] hover:text-[#1D1D1F] dark:hover:text-white transition-colors">
|
|
||||||
<X size={16} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<h4 className="text-xl font-medium text-[#1D1D1F] dark:text-[#F5F5F7] mb-1">{selectedData.title}</h4>
|
|
||||||
<p className="text-sm text-[#86868B] dark:text-[#A1A1A6] mb-4">{selectedData.location}</p>
|
|
||||||
<div className="bg-[#F5F5F7] dark:bg-white/5 rounded-lg p-3 mb-4 border border-transparent dark:border-white/5">
|
|
||||||
<span className="text-[10px] uppercase tracking-wider text-[#86868B] dark:text-[#A1A1A6] block mb-1">{t("statusDetails")}</span>
|
|
||||||
<span className="text-sm font-medium text-[#1D1D1F] dark:text-[#F5F5F7]">{selectedData.stats}</span>
|
|
||||||
</div>
|
|
||||||
<button onClick={() => setIsModalOpen(true)} className="w-full flex justify-center items-center gap-2 bg-[#1D1D1F] dark:bg-[#0066CC] text-white py-2.5 rounded-xl text-sm font-medium hover:bg-[#0066CC] dark:hover:bg-[#4DA6FF] transition-colors">
|
|
||||||
{t("viewCaseStudy")} <ArrowUpRight size={14} />
|
|
||||||
</button>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
|
|
||||||
<Canvas camera={{ position: [0, 0, 7], fov: 55 }} dpr={[1, 2]} style={{ touchAction: "none" }}>
|
|
||||||
<ambientLight intensity={1.5} />
|
|
||||||
<directionalLight position={[10, 10, 5]} intensity={2} />
|
|
||||||
<OrbitControls enableZoom={true} minDistance={3} maxDistance={12} enablePan={false} autoRotate={false} />
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<HologramSphere activeFilter={activeFilter} activeSubFilter={activeSubFilter} selectedMarker={selectedMarkerId} onSelectMarker={setSelectedMarkerId} isDark={isDark} dbNodes={dbNodes} hqPosition={hqPosition} />
|
|
||||||
</Suspense>
|
|
||||||
</Canvas>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<CaseStudyModal isOpen={isModalOpen} onClose={() => setIsModalOpen(false)} data={selectedData as CaseStudyData || null} />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
// src/lib/aiSessionId.ts
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Per-visitor pseudonymous session id used to stitch together FluxAI
|
||||||
|
// conversations across messages. The id is generated on first chat and
|
||||||
|
// persisted in localStorage; if storage is unavailable (Safari ITP / privacy
|
||||||
|
// mode) we fall back to a per-tab id in sessionStorage; if both fail we use
|
||||||
|
// an ephemeral in-memory id (no tracking across reload).
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const STORAGE_KEY = "flux:ai:session";
|
||||||
|
|
||||||
|
function randomUUID(): string {
|
||||||
|
// crypto.randomUUID is available in all modern browsers + Node 14.17+.
|
||||||
|
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
||||||
|
return crypto.randomUUID();
|
||||||
|
}
|
||||||
|
// Tiny fallback (RFC 4122 v4-ish — sufficient for telemetry, not for security).
|
||||||
|
const rnd = (n: number) =>
|
||||||
|
Array.from({ length: n }, () => Math.floor(Math.random() * 16).toString(16)).join("");
|
||||||
|
return `${rnd(8)}-${rnd(4)}-4${rnd(3)}-${rnd(4)}-${rnd(12)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let memoryId: string | null = null;
|
||||||
|
|
||||||
|
export function getAiSessionId(): string {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
// SSR — caller must pass the id from the client.
|
||||||
|
if (!memoryId) memoryId = randomUUID();
|
||||||
|
return memoryId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// localStorage first
|
||||||
|
try {
|
||||||
|
const existing = window.localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (existing) return existing;
|
||||||
|
const fresh = randomUUID();
|
||||||
|
window.localStorage.setItem(STORAGE_KEY, fresh);
|
||||||
|
return fresh;
|
||||||
|
} catch {
|
||||||
|
// ignore — privacy mode
|
||||||
|
}
|
||||||
|
|
||||||
|
// sessionStorage next
|
||||||
|
try {
|
||||||
|
const existing = window.sessionStorage.getItem(STORAGE_KEY);
|
||||||
|
if (existing) return existing;
|
||||||
|
const fresh = randomUUID();
|
||||||
|
window.sessionStorage.setItem(STORAGE_KEY, fresh);
|
||||||
|
return fresh;
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
// In-memory (tab-scoped) last resort
|
||||||
|
if (!memoryId) memoryId = randomUUID();
|
||||||
|
return memoryId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetAiSessionId(): void {
|
||||||
|
memoryId = null;
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
try { window.localStorage.removeItem(STORAGE_KEY); } catch {}
|
||||||
|
try { window.sessionStorage.removeItem(STORAGE_KEY); } catch {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
// src/lib/csrf.ts
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// CSRF protection for public POST endpoints (consultation form, etc).
|
||||||
|
//
|
||||||
|
// Pattern: stateless double-submit cookie + header.
|
||||||
|
// 1. Server issues a token "<nonce>.<HMAC(nonce, SESSION_SECRET)>".
|
||||||
|
// Stored in a JS-readable cookie (so the client can copy it into a header).
|
||||||
|
// 2. Client POSTs with both the cookie and an X-CSRF-Token header.
|
||||||
|
// 3. Server verifies cookie === header AND the HMAC is valid.
|
||||||
|
//
|
||||||
|
// Why double-submit + HMAC (not just cookie):
|
||||||
|
// - The HMAC binds the cookie to the server, so an attacker can't mint a
|
||||||
|
// cookie via a subdomain or via a JS-injection on an unrelated site.
|
||||||
|
// - Tokens are stateless: no DB roundtrip, no Redis dep, no replay window
|
||||||
|
// beyond the cookie TTL.
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
import { createHmac, randomBytes, timingSafeEqual } from "crypto";
|
||||||
|
|
||||||
|
export const CSRF_COOKIE_NAME = "flux_csrf";
|
||||||
|
export const CSRF_HEADER_NAME = "x-csrf-token";
|
||||||
|
const CSRF_TTL_MS = 1000 * 60 * 60; // 1h — long enough for slow form fills
|
||||||
|
|
||||||
|
function getSecret(): Buffer {
|
||||||
|
const s = process.env.SESSION_SECRET;
|
||||||
|
if (!s) throw new Error("SESSION_SECRET required for CSRF");
|
||||||
|
return Buffer.from(s, "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
function hmac(payload: string): string {
|
||||||
|
return createHmac("sha256", getSecret()).update(payload).digest("base64url");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mint a fresh CSRF token. Format: "<nonce>.<issuedAtMs>.<hmac>".
|
||||||
|
* The browser will get this in a cookie; the JS client copies it into a header.
|
||||||
|
*/
|
||||||
|
export function issueCsrfToken(): string {
|
||||||
|
const nonce = randomBytes(16).toString("base64url");
|
||||||
|
const issuedAt = Date.now();
|
||||||
|
const payload = `${nonce}.${issuedAt}`;
|
||||||
|
return `${payload}.${hmac(payload)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constant-time verification. Returns true iff the token is well-formed,
|
||||||
|
* the HMAC matches, and the token has not expired.
|
||||||
|
*/
|
||||||
|
export function verifyCsrfToken(token: string | null | undefined): boolean {
|
||||||
|
if (!token || typeof token !== "string") return false;
|
||||||
|
const parts = token.split(".");
|
||||||
|
if (parts.length !== 3) return false;
|
||||||
|
const [nonce, issuedAtStr, mac] = parts;
|
||||||
|
if (!nonce || !issuedAtStr || !mac) return false;
|
||||||
|
const issuedAt = Number(issuedAtStr);
|
||||||
|
if (!Number.isFinite(issuedAt)) return false;
|
||||||
|
if (Date.now() - issuedAt > CSRF_TTL_MS) return false;
|
||||||
|
|
||||||
|
const expected = hmac(`${nonce}.${issuedAtStr}`);
|
||||||
|
const a = Buffer.from(mac);
|
||||||
|
const b = Buffer.from(expected);
|
||||||
|
if (a.length !== b.length) return false;
|
||||||
|
return timingSafeEqual(a, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cookie config helpers — kept here so client and server agree on flags.
|
||||||
|
*/
|
||||||
|
export const csrfCookieOptions = {
|
||||||
|
// NOT httpOnly: the client needs to read it to copy into the header.
|
||||||
|
httpOnly: false,
|
||||||
|
sameSite: "lax" as const,
|
||||||
|
secure: process.env.NODE_ENV === "production",
|
||||||
|
path: "/",
|
||||||
|
maxAge: CSRF_TTL_MS / 1000,
|
||||||
|
};
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
// src/lib/escapeHtml.ts
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Escape user-controlled strings before interpolating into HTML markup.
|
||||||
|
// Used by transactional email templates (src/app/api/consultation/route.ts)
|
||||||
|
// and anywhere we render untrusted text into raw HTML strings.
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const HTML_ESCAPES: Record<string, string> = {
|
||||||
|
"&": "&",
|
||||||
|
"<": "<",
|
||||||
|
">": ">",
|
||||||
|
'"': """,
|
||||||
|
"'": "'",
|
||||||
|
"/": "/",
|
||||||
|
"`": "`",
|
||||||
|
"=": "=",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escape characters that have special meaning inside HTML text content.
|
||||||
|
* Safe for <div>{value}</div>-style interpolation.
|
||||||
|
*/
|
||||||
|
export function escapeHtml(value: unknown): string {
|
||||||
|
if (value == null) return "";
|
||||||
|
return String(value).replace(/[&<>"'`=/]/g, (c) => HTML_ESCAPES[c] ?? c);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escape values that will be inserted inside double-quoted HTML attributes
|
||||||
|
* (e.g. href="..."). Strips control characters and escapes the quote chars.
|
||||||
|
*/
|
||||||
|
export function escapeAttr(value: unknown): string {
|
||||||
|
if (value == null) return "";
|
||||||
|
return String(value)
|
||||||
|
// eslint-disable-next-line no-control-regex
|
||||||
|
.replace(/[\x00-\x1F\x7F]/g, "")
|
||||||
|
.replace(/[&<>"'`]/g, (c) => HTML_ESCAPES[c] ?? c);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Conservative validator for use in mailto: hrefs. Accepts the same shape
|
||||||
|
* Zod's z.string().email() does. Returns empty string when invalid so the
|
||||||
|
* resulting <a href=""> does nothing harmful.
|
||||||
|
*/
|
||||||
|
export function safeMailto(email: unknown): string {
|
||||||
|
if (typeof email !== "string") return "";
|
||||||
|
if (!/^[^\s@<>"]+@[^\s@<>"]+\.[^\s@<>"]+$/.test(email)) return "";
|
||||||
|
return encodeURI(`mailto:${email}`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
// src/lib/fileType.ts
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Magic-byte sniffer for uploaded files. Trusts file content, NOT the
|
||||||
|
// client-provided extension or MIME type. Pure stdlib, zero deps.
|
||||||
|
//
|
||||||
|
// Used by /api/public-upload and /api/assets to reject HTML/JS payloads
|
||||||
|
// renamed to .png/.mp4 (a classic stored-XSS vector).
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type DetectedFileType = "jpeg" | "png" | "webp" | "gif" | "mp4" | "mov" | null;
|
||||||
|
|
||||||
|
function startsWith(buf: Buffer, bytes: number[], offset = 0): boolean {
|
||||||
|
if (buf.length < offset + bytes.length) return false;
|
||||||
|
for (let i = 0; i < bytes.length; i++) {
|
||||||
|
if (buf[offset + i] !== bytes[i]) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inspect the first ~16 bytes of a buffer and return the detected media type,
|
||||||
|
* or null if the file does not match any allow-listed signature.
|
||||||
|
*/
|
||||||
|
export function detectFileType(buf: Buffer): DetectedFileType {
|
||||||
|
if (!buf || buf.length < 12) return null;
|
||||||
|
|
||||||
|
// JPEG: FF D8 FF
|
||||||
|
if (startsWith(buf, [0xff, 0xd8, 0xff])) return "jpeg";
|
||||||
|
|
||||||
|
// PNG: 89 50 4E 47 0D 0A 1A 0A
|
||||||
|
if (startsWith(buf, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return "png";
|
||||||
|
|
||||||
|
// GIF: 47 49 46 38 (39|37) 61 — "GIF89a" / "GIF87a"
|
||||||
|
if (startsWith(buf, [0x47, 0x49, 0x46, 0x38]) && (buf[4] === 0x39 || buf[4] === 0x37) && buf[5] === 0x61) {
|
||||||
|
return "gif";
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebP: RIFF....WEBP (52 49 46 46 _ _ _ _ 57 45 42 50)
|
||||||
|
if (
|
||||||
|
startsWith(buf, [0x52, 0x49, 0x46, 0x46]) &&
|
||||||
|
startsWith(buf, [0x57, 0x45, 0x42, 0x50], 8)
|
||||||
|
) {
|
||||||
|
return "webp";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ISO Base Media (MP4 / MOV). The format begins with a 4-byte box size,
|
||||||
|
// then "ftyp", then a 4-byte major brand.
|
||||||
|
if (startsWith(buf, [0x66, 0x74, 0x79, 0x70], 4)) {
|
||||||
|
const brand = buf.subarray(8, 12).toString("ascii");
|
||||||
|
// Common MP4 brands
|
||||||
|
if (
|
||||||
|
brand === "isom" || brand === "iso2" || brand === "mp41" || brand === "mp42" ||
|
||||||
|
brand === "avc1" || brand === "M4V " || brand === "M4A " || brand === "dash" ||
|
||||||
|
brand === "MSNV"
|
||||||
|
) return "mp4";
|
||||||
|
// QuickTime
|
||||||
|
if (brand === "qt ") return "mov";
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map extension to expected detected type so callers can verify the upload's
|
||||||
|
* content matches its file name. Returns null when the extension is not on
|
||||||
|
* the public upload allow list.
|
||||||
|
*/
|
||||||
|
export function expectedTypeForExtension(ext: string): DetectedFileType | null {
|
||||||
|
switch (ext.toLowerCase()) {
|
||||||
|
case ".jpg":
|
||||||
|
case ".jpeg":
|
||||||
|
return "jpeg";
|
||||||
|
case ".png":
|
||||||
|
return "png";
|
||||||
|
case ".webp":
|
||||||
|
return "webp";
|
||||||
|
case ".gif":
|
||||||
|
return "gif";
|
||||||
|
case ".mp4":
|
||||||
|
return "mp4";
|
||||||
|
case ".mov":
|
||||||
|
return "mov";
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
// src/lib/logger.ts
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Minimal structured logger — JSON lines per event so `docker logs flux-app`
|
||||||
|
// can be piped through `jq` and shipped to Loki/Sentry/CloudWatch without code
|
||||||
|
// changes. Zero deps. Replace console.error/log with log.error/log.info.
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type LogContext = Record<string, unknown>;
|
||||||
|
|
||||||
|
function serialiseError(err: unknown) {
|
||||||
|
if (err instanceof Error) {
|
||||||
|
return { name: err.name, message: err.message, stack: err.stack };
|
||||||
|
}
|
||||||
|
return { value: String(err) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function emit(level: "info" | "warn" | "error", event: string, ctx?: LogContext, err?: unknown) {
|
||||||
|
const line = JSON.stringify({
|
||||||
|
lvl: level,
|
||||||
|
event,
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
...(err !== undefined ? { err: serialiseError(err) } : {}),
|
||||||
|
...ctx,
|
||||||
|
});
|
||||||
|
if (level === "error") console.error(line);
|
||||||
|
else if (level === "warn") console.warn(line);
|
||||||
|
else console.log(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const log = {
|
||||||
|
info: (event: string, ctx?: LogContext) => emit("info", event, ctx),
|
||||||
|
warn: (event: string, ctx?: LogContext) => emit("warn", event, ctx),
|
||||||
|
error: (event: string, err: unknown, ctx?: LogContext) => emit("error", event, ctx, err),
|
||||||
|
};
|
||||||
+127
-43
@@ -1,34 +1,17 @@
|
|||||||
// src/lib/rateLimit.ts
|
// src/lib/rateLimit.ts
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// -----------------------------------------------------------------------------
|
||||||
// Lightweight in-memory rate limiter (token bucket per IP).
|
// Token-bucket rate limiter with pluggable backend.
|
||||||
// Single Node process, no Redis dep — protects /api/chat from quota burning.
|
//
|
||||||
// Scales to one container; if you add replicas, swap the Map for Upstash Redis.
|
// - InMemoryStore (default): a per-process Map. Fine for single-container
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// deploys (current VPS). If you add replicas, the limit gets multiplied.
|
||||||
|
// - RedisStore (Upstash REST): synchronised across instances. Activated
|
||||||
|
// automatically when REDIS_URL + REDIS_TOKEN env vars are set.
|
||||||
|
//
|
||||||
|
// Both stores expose the same `consume(key, capacity, refillPerSec)` API so
|
||||||
|
// callers don't change when the deploy shape changes.
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
interface Bucket {
|
import { log } from "@/lib/logger";
|
||||||
tokens: number;
|
|
||||||
updatedAt: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RateLimitConfig {
|
|
||||||
capacity: number; // Max tokens in the bucket
|
|
||||||
refillPerSec: number; // Tokens added each second
|
|
||||||
}
|
|
||||||
|
|
||||||
const buckets = new Map<string, Bucket>();
|
|
||||||
|
|
||||||
// Garbage-collect stale buckets every 10 min so memory doesn't grow unbounded
|
|
||||||
let lastGc = Date.now();
|
|
||||||
const GC_INTERVAL = 10 * 60 * 1000;
|
|
||||||
const STALE_THRESHOLD = 30 * 60 * 1000;
|
|
||||||
|
|
||||||
function gc(now: number) {
|
|
||||||
if (now - lastGc < GC_INTERVAL) return;
|
|
||||||
for (const [key, bucket] of buckets) {
|
|
||||||
if (now - bucket.updatedAt > STALE_THRESHOLD) buckets.delete(key);
|
|
||||||
}
|
|
||||||
lastGc = now;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RateLimitResult {
|
export interface RateLimitResult {
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
@@ -36,24 +19,46 @@ export interface RateLimitResult {
|
|||||||
retryAfterSec: number;
|
retryAfterSec: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function rateLimit(key: string, config: RateLimitConfig): RateLimitResult {
|
interface RateLimitStore {
|
||||||
|
consume(key: string, capacity: number, refillPerSec: number): Promise<RateLimitResult> | RateLimitResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── In-memory store ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface Bucket {
|
||||||
|
tokens: number;
|
||||||
|
updatedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
class InMemoryStore implements RateLimitStore {
|
||||||
|
private buckets = new Map<string, Bucket>();
|
||||||
|
private lastGc = Date.now();
|
||||||
|
private readonly GC_INTERVAL = 10 * 60 * 1000;
|
||||||
|
private readonly STALE_THRESHOLD = 30 * 60 * 1000;
|
||||||
|
|
||||||
|
private gc(now: number) {
|
||||||
|
if (now - this.lastGc < this.GC_INTERVAL) return;
|
||||||
|
for (const [key, bucket] of this.buckets) {
|
||||||
|
if (now - bucket.updatedAt > this.STALE_THRESHOLD) this.buckets.delete(key);
|
||||||
|
}
|
||||||
|
this.lastGc = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
consume(key: string, capacity: number, refillPerSec: number): RateLimitResult {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
gc(now);
|
this.gc(now);
|
||||||
|
|
||||||
const existing = buckets.get(key);
|
|
||||||
let bucket: Bucket;
|
|
||||||
|
|
||||||
|
const existing = this.buckets.get(key);
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
bucket = { tokens: config.capacity - 1, updatedAt: now };
|
this.buckets.set(key, { tokens: capacity - 1, updatedAt: now });
|
||||||
buckets.set(key, bucket);
|
return { ok: true, remaining: capacity - 1, retryAfterSec: 0 };
|
||||||
return { ok: true, remaining: bucket.tokens, retryAfterSec: 0 };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const elapsedSec = (now - existing.updatedAt) / 1000;
|
const elapsedSec = (now - existing.updatedAt) / 1000;
|
||||||
const refilled = Math.min(config.capacity, existing.tokens + elapsedSec * config.refillPerSec);
|
const refilled = Math.min(capacity, existing.tokens + elapsedSec * refillPerSec);
|
||||||
|
|
||||||
if (refilled < 1) {
|
if (refilled < 1) {
|
||||||
const retryAfterSec = Math.ceil((1 - refilled) / config.refillPerSec);
|
const retryAfterSec = Math.ceil((1 - refilled) / refillPerSec);
|
||||||
existing.tokens = refilled;
|
existing.tokens = refilled;
|
||||||
existing.updatedAt = now;
|
existing.updatedAt = now;
|
||||||
return { ok: false, remaining: 0, retryAfterSec };
|
return { ok: false, remaining: 0, retryAfterSec };
|
||||||
@@ -63,11 +68,80 @@ export function rateLimit(key: string, config: RateLimitConfig): RateLimitResult
|
|||||||
existing.updatedAt = now;
|
existing.updatedAt = now;
|
||||||
return { ok: true, remaining: Math.floor(existing.tokens), retryAfterSec: 0 };
|
return { ok: true, remaining: Math.floor(existing.tokens), retryAfterSec: 0 };
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Upstash Redis store (REST API, fetch-only, no extra deps) ────────────────
|
||||||
|
|
||||||
|
class UpstashRedisStore implements RateLimitStore {
|
||||||
|
constructor(private url: string, private token: string) {}
|
||||||
|
|
||||||
|
private async pipeline(commands: (string | number)[][]): Promise<unknown[]> {
|
||||||
|
const res = await fetch(`${this.url}/pipeline`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Authorization: `Bearer ${this.token}`, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(commands),
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`Upstash error ${res.status}`);
|
||||||
|
const data = (await res.json()) as { result: unknown }[];
|
||||||
|
return data.map((d) => d.result);
|
||||||
|
}
|
||||||
|
|
||||||
|
async consume(key: string, capacity: number, refillPerSec: number): Promise<RateLimitResult> {
|
||||||
|
// Lua-free fallback: GET state, compute, SET with TTL. Race window is small
|
||||||
|
// and the worst case is one extra token consumed across replicas — acceptable.
|
||||||
|
const now = Date.now();
|
||||||
|
const stateKey = `rl:${key}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [raw] = await this.pipeline([["GET", stateKey]]);
|
||||||
|
let tokens = capacity;
|
||||||
|
let updatedAt = now;
|
||||||
|
|
||||||
|
if (typeof raw === "string") {
|
||||||
|
const parsed = JSON.parse(raw) as { tokens: number; updatedAt: number };
|
||||||
|
const elapsedSec = (now - parsed.updatedAt) / 1000;
|
||||||
|
tokens = Math.min(capacity, parsed.tokens + elapsedSec * refillPerSec);
|
||||||
|
updatedAt = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tokens < 1) {
|
||||||
|
const retryAfterSec = Math.ceil((1 - tokens) / refillPerSec);
|
||||||
|
await this.pipeline([["SET", stateKey, JSON.stringify({ tokens, updatedAt }), "EX", 1800]]);
|
||||||
|
return { ok: false, remaining: 0, retryAfterSec };
|
||||||
|
}
|
||||||
|
|
||||||
|
tokens = tokens - 1;
|
||||||
|
await this.pipeline([["SET", stateKey, JSON.stringify({ tokens, updatedAt }), "EX", 1800]]);
|
||||||
|
return { ok: true, remaining: Math.floor(tokens), retryAfterSec: 0 };
|
||||||
|
} catch (e) {
|
||||||
|
// Fail open: never block legitimate traffic because Redis is down.
|
||||||
|
log.warn("ratelimit.redis_unreachable", { err: String(e) });
|
||||||
|
return { ok: true, remaining: capacity, retryAfterSec: 0 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Singleton picker ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let store: RateLimitStore | null = null;
|
||||||
|
|
||||||
|
function getStore(): RateLimitStore {
|
||||||
|
if (store) return store;
|
||||||
|
const url = process.env.REDIS_URL;
|
||||||
|
const token = process.env.REDIS_TOKEN;
|
||||||
|
if (url && token) {
|
||||||
|
log.info("ratelimit.backend", { backend: "upstash" });
|
||||||
|
store = new UpstashRedisStore(url, token);
|
||||||
|
} else {
|
||||||
|
store = new InMemoryStore();
|
||||||
|
}
|
||||||
|
return store;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function getClientIp(req: Request): string {
|
export function getClientIp(req: Request): string {
|
||||||
// Nginx sets x-forwarded-for; first value is the real client.
|
|
||||||
const xff = req.headers.get("x-forwarded-for");
|
const xff = req.headers.get("x-forwarded-for");
|
||||||
if (xff) return xff.split(",")[0].trim();
|
if (xff) return xff.split(",")[0].trim();
|
||||||
const real = req.headers.get("x-real-ip");
|
const real = req.headers.get("x-real-ip");
|
||||||
@@ -75,12 +149,22 @@ export function getClientIp(req: Request): string {
|
|||||||
return "unknown";
|
return "unknown";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface RateLimitConfig {
|
||||||
|
capacity: number;
|
||||||
|
refillPerSec: number;
|
||||||
|
}
|
||||||
|
|
||||||
const CHAT_LIMIT: RateLimitConfig = {
|
const CHAT_LIMIT: RateLimitConfig = {
|
||||||
capacity: 30, // Burst of 30 messages
|
capacity: 30, // burst
|
||||||
refillPerSec: 0.5, // = 30/min sustained
|
refillPerSec: 0.5, // = 30/min sustained
|
||||||
};
|
};
|
||||||
|
|
||||||
export function checkChatRateLimit(req: Request): RateLimitResult {
|
export async function checkChatRateLimit(req: Request): Promise<RateLimitResult> {
|
||||||
const ip = getClientIp(req);
|
const ip = getClientIp(req);
|
||||||
return rateLimit(`chat:${ip}`, CHAT_LIMIT);
|
return Promise.resolve(getStore().consume(`chat:${ip}`, CHAT_LIMIT.capacity, CHAT_LIMIT.refillPerSec));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generic helper for ad-hoc limits in other routes.
|
||||||
|
export async function rateLimitAsync(key: string, capacity: number, refillPerSec: number): Promise<RateLimitResult> {
|
||||||
|
return Promise.resolve(getStore().consume(key, capacity, refillPerSec));
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-2
@@ -1,8 +1,16 @@
|
|||||||
import { SignJWT, jwtVerify } from "jose";
|
import { SignJWT, jwtVerify } from "jose";
|
||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
|
|
||||||
// Usamos una variable de entorno secreta, o un fallback súper seguro para desarrollo
|
// SESSION_SECRET is REQUIRED. No fallback: a public default would let anyone
|
||||||
const secretKey = process.env.SESSION_SECRET || "FLUX_SUPER_SECRET_KEY_2026_ARCHITECTURE";
|
// forge a 7-day admin JWT if the env var ever fails to load in production.
|
||||||
|
// Generate a strong value with: openssl rand -base64 48
|
||||||
|
const secretKey = process.env.SESSION_SECRET;
|
||||||
|
if (!secretKey || secretKey.length < 32) {
|
||||||
|
throw new Error(
|
||||||
|
"SESSION_SECRET environment variable is required (min 32 chars). " +
|
||||||
|
"Generate one with: openssl rand -base64 48"
|
||||||
|
);
|
||||||
|
}
|
||||||
const encodedKey = new TextEncoder().encode(secretKey);
|
const encodedKey = new TextEncoder().encode(secretKey);
|
||||||
|
|
||||||
export async function createSession(userId: string, username: string) {
|
export async function createSession(userId: string, username: string) {
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
// src/types/cms.ts
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Shared CMS types derived from Prisma models. Use these instead of `any[]`
|
||||||
|
// when passing DB-shaped data into React components. The Pick<> shapes mirror
|
||||||
|
// what the queries actually `select`, so the compiler catches drift.
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
import type {
|
||||||
|
Application,
|
||||||
|
GlobalNode,
|
||||||
|
NewsArticle,
|
||||||
|
SparePart,
|
||||||
|
HeroSlide,
|
||||||
|
HeritageSection,
|
||||||
|
TimelineEvent,
|
||||||
|
} from "@prisma/client";
|
||||||
|
|
||||||
|
// ── Application ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type AppCard = Pick<
|
||||||
|
Application,
|
||||||
|
"id" | "slug" | "title" | "subtitle" | "shortDescription" | "category" | "isActive"
|
||||||
|
> & {
|
||||||
|
// Only present when the query opts in:
|
||||||
|
dashboardMetricsJson?: string | null;
|
||||||
|
translationsJson?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AppFull = Application;
|
||||||
|
|
||||||
|
// ── GlobalNode (installations + events + HQ) ────────────────────────────────
|
||||||
|
|
||||||
|
export type NodeMarker = Pick<
|
||||||
|
GlobalNode,
|
||||||
|
"id" | "title" | "location" | "lat" | "lon" | "nodeType" | "application" | "stats" | "isActive"
|
||||||
|
> & {
|
||||||
|
mediaFileName?: string | null;
|
||||||
|
energySavings?: string | null;
|
||||||
|
eventDate?: Date | null;
|
||||||
|
translationsJson?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NodeFull = GlobalNode;
|
||||||
|
|
||||||
|
// ── Inside Flux ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type NewsCard = Pick<
|
||||||
|
NewsArticle,
|
||||||
|
"id" | "slug" | "title" | "excerpt" | "coverImage" | "category" | "publishedAt"
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type NewsFull = NewsArticle;
|
||||||
|
|
||||||
|
// ── Parts catalog ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type PartCard = Pick<SparePart, "id" | "sku" | "title" | "description" | "price" | "showPrice" | "isActive"> & {
|
||||||
|
mediaJson?: string | null;
|
||||||
|
specsJson?: string | null;
|
||||||
|
translationsJson?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Hero / story / timeline ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type HeroSlideRow = HeroSlide;
|
||||||
|
export type HeritageRow = HeritageSection;
|
||||||
|
export type TimelineRow = TimelineEvent;
|
||||||
|
|
||||||
|
// ── JSON helpers ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type GalleryImage = { url: string; alt?: string };
|
||||||
|
export type DashboardMetric = { label: string; value: string; trend?: string };
|
||||||
|
export type DatasheetRow = { label: string; value: string; unit?: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safe JSON parse for `translationsJson`, `galleryJson`, etc. Returns the
|
||||||
|
* fallback when the field is null, undefined, or malformed. Never throws.
|
||||||
|
*/
|
||||||
|
export function parseJsonField<T>(raw: string | null | undefined, fallback: T): T {
|
||||||
|
if (!raw) return fallback;
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw) as T;
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
// tests/ai/golden.test.mjs
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Golden tests for FluxAI hardening + analytics. Uses Node's built-in test
|
||||||
|
// runner (no new deps). Run with: `node --test tests/ai/golden.test.mjs`.
|
||||||
|
//
|
||||||
|
// These don't hit OpenAI — they verify the deterministic pieces of the stack:
|
||||||
|
// - escapeHtml strips XSS payloads
|
||||||
|
// - CSRF token issue/verify roundtrip works and rejects tampering
|
||||||
|
// - File-type detector recognises magic bytes and rejects HTML/JS pretending
|
||||||
|
// to be an image
|
||||||
|
// - Industry detector picks the right label from common B2B phrasings
|
||||||
|
// - Zod consultation schema accepts well-formed payloads, rejects bad ones
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { pathToFileURL } from "node:url";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
|
process.env.SESSION_SECRET ??= "test-secret-please-replace-with-32-chars-or-more";
|
||||||
|
|
||||||
|
// Helper: import .ts via project alias. Tests run against the source file
|
||||||
|
// to avoid coupling to the build output. tsx isn't installed by default so
|
||||||
|
// we use loader-less .mjs and import the TS sources via .ts? — but Node
|
||||||
|
// can't load .ts directly. So we copy the small predicates here.
|
||||||
|
|
||||||
|
// 1. escapeHtml — pulled inline because the source is tiny + pure.
|
||||||
|
const HTML_ESCAPES = {
|
||||||
|
"&": "&", "<": "<", ">": ">",
|
||||||
|
'"': """, "'": "'", "/": "/",
|
||||||
|
"`": "`", "=": "=",
|
||||||
|
};
|
||||||
|
function escapeHtml(v) {
|
||||||
|
if (v == null) return "";
|
||||||
|
return String(v).replace(/[&<>"'`=/]/g, (c) => HTML_ESCAPES[c] ?? c);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("escapeHtml: kills <script> injections", () => {
|
||||||
|
const input = `<script>alert(1)</script>`;
|
||||||
|
const out = escapeHtml(input);
|
||||||
|
assert.ok(!out.includes("<script>"));
|
||||||
|
assert.ok(out.includes("<script>"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("escapeHtml: escapes attribute-breakout payloads", () => {
|
||||||
|
const out = escapeHtml(`x" onmouseover="alert(1)`);
|
||||||
|
assert.ok(!out.includes('"'));
|
||||||
|
assert.ok(out.includes("""));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("escapeHtml: handles null/undefined", () => {
|
||||||
|
assert.equal(escapeHtml(null), "");
|
||||||
|
assert.equal(escapeHtml(undefined), "");
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. File-type magic-byte sniffer — synthetic buffers.
|
||||||
|
function startsWith(buf, bytes, offset = 0) {
|
||||||
|
if (buf.length < offset + bytes.length) return false;
|
||||||
|
for (let i = 0; i < bytes.length; i++) if (buf[offset + i] !== bytes[i]) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
function detectFileType(buf) {
|
||||||
|
if (!buf || buf.length < 12) return null;
|
||||||
|
if (startsWith(buf, [0xff, 0xd8, 0xff])) return "jpeg";
|
||||||
|
if (startsWith(buf, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return "png";
|
||||||
|
if (startsWith(buf, [0x47, 0x49, 0x46, 0x38]) && (buf[4] === 0x39 || buf[4] === 0x37) && buf[5] === 0x61) return "gif";
|
||||||
|
if (startsWith(buf, [0x52, 0x49, 0x46, 0x46]) && startsWith(buf, [0x57, 0x45, 0x42, 0x50], 8)) return "webp";
|
||||||
|
if (startsWith(buf, [0x66, 0x74, 0x79, 0x70], 4)) {
|
||||||
|
const brand = buf.subarray(8, 12).toString("ascii");
|
||||||
|
if (["isom", "iso2", "mp41", "mp42", "avc1", "M4V ", "M4A ", "dash", "MSNV"].includes(brand)) return "mp4";
|
||||||
|
if (brand === "qt ") return "mov";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("detectFileType: recognises PNG", () => {
|
||||||
|
const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0, 0, 0]);
|
||||||
|
assert.equal(detectFileType(png), "png");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("detectFileType: recognises JPEG", () => {
|
||||||
|
const jpg = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0, 0, 0, 0, 0, 0, 0, 0, 0]);
|
||||||
|
assert.equal(detectFileType(jpg), "jpeg");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("detectFileType: rejects HTML pretending to be PNG", () => {
|
||||||
|
const html = Buffer.from("<html><body><script>alert(1)</script></body></html>");
|
||||||
|
assert.equal(detectFileType(html), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("detectFileType: recognises MP4 ftyp box", () => {
|
||||||
|
// 4-byte size + "ftyp" + "isom" + ...
|
||||||
|
const mp4 = Buffer.from([0, 0, 0, 0x20, 0x66, 0x74, 0x79, 0x70, 0x69, 0x73, 0x6f, 0x6d, 0, 0, 0, 0]);
|
||||||
|
assert.equal(detectFileType(mp4), "mp4");
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Industry detector
|
||||||
|
function detectIndustryFromText(text) {
|
||||||
|
const t = text.toLowerCase();
|
||||||
|
if (/text|fabric|dye|stenter|finishing|yarn/.test(t)) return "textile";
|
||||||
|
if (/food|defrost|bak|pasteuriz|tempering|cook/.test(t)) return "food";
|
||||||
|
if (/rubber|latex|vulcaniz|foam|tyre|tire/.test(t)) return "rubber";
|
||||||
|
if (/pharma|cannabis|drug|api\b|lab/.test(t)) return "pharma";
|
||||||
|
if (/wood|timber|lumber|kiln/.test(t)) return "wood";
|
||||||
|
if (/ceramic|kiln|clay/.test(t)) return "other";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("industry detector: textile process picks textile", () => {
|
||||||
|
assert.equal(detectIndustryFromText("We dry fabric after dyeing in a stenter"), "textile");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("industry detector: food defrosting picks food", () => {
|
||||||
|
assert.equal(detectIndustryFromText("We defrost meat blocks for processing"), "food");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("industry detector: returns null when no industry is mentioned", () => {
|
||||||
|
assert.equal(detectIndustryFromText("Tell me a joke about engineers"), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. CSRF token — re-implements the verifier so tests don't need a TS loader.
|
||||||
|
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
||||||
|
|
||||||
|
const CSRF_TTL_MS = 1000 * 60 * 60;
|
||||||
|
function hmac(payload) {
|
||||||
|
return createHmac("sha256", Buffer.from(process.env.SESSION_SECRET, "utf8")).update(payload).digest("base64url");
|
||||||
|
}
|
||||||
|
function issueCsrfToken() {
|
||||||
|
const nonce = randomBytes(16).toString("base64url");
|
||||||
|
const issuedAt = Date.now();
|
||||||
|
const payload = `${nonce}.${issuedAt}`;
|
||||||
|
return `${payload}.${hmac(payload)}`;
|
||||||
|
}
|
||||||
|
function verifyCsrfToken(token) {
|
||||||
|
if (!token) return false;
|
||||||
|
const parts = String(token).split(".");
|
||||||
|
if (parts.length !== 3) return false;
|
||||||
|
const [n, t, m] = parts;
|
||||||
|
if (!n || !t || !m) return false;
|
||||||
|
const issuedAt = Number(t);
|
||||||
|
if (!Number.isFinite(issuedAt)) return false;
|
||||||
|
if (Date.now() - issuedAt > CSRF_TTL_MS) return false;
|
||||||
|
const expected = hmac(`${n}.${t}`);
|
||||||
|
const a = Buffer.from(m);
|
||||||
|
const b = Buffer.from(expected);
|
||||||
|
if (a.length !== b.length) return false;
|
||||||
|
return timingSafeEqual(a, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("CSRF: fresh token verifies", () => {
|
||||||
|
const t = issueCsrfToken();
|
||||||
|
assert.equal(verifyCsrfToken(t), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("CSRF: tampered token fails", () => {
|
||||||
|
const t = issueCsrfToken();
|
||||||
|
const tampered = t.slice(0, -1) + (t.endsWith("A") ? "B" : "A");
|
||||||
|
assert.equal(verifyCsrfToken(tampered), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("CSRF: garbage rejected", () => {
|
||||||
|
assert.equal(verifyCsrfToken("not-a-token"), false);
|
||||||
|
assert.equal(verifyCsrfToken(""), false);
|
||||||
|
assert.equal(verifyCsrfToken(null), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("Golden tests file resolved at:", pathToFileURL(resolve(import.meta.url)).href);
|
||||||
Reference in New Issue
Block a user