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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-27 08:10:19 -05:00
parent 792dd6794b
commit 3a94e7c003
31 changed files with 1813 additions and 455 deletions
@@ -43,6 +43,8 @@ export default function AuthModal({ session }: { session: any }) {
setError(res.error);
} else {
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();
}
setIsLoading(false);
@@ -84,9 +86,10 @@ export default function AuthModal({ session }: { session: any }) {
};
const handleLogout = async () => {
setIsLoading(true);
await logoutClient();
setIsOpen(false);
setIsLoading(true);
await logoutClient();
setIsOpen(false);
if (typeof window !== "undefined") window.dispatchEvent(new CustomEvent("flux:session-changed"));
router.refresh();
};
+7 -1
View File
@@ -5,7 +5,13 @@ import bcrypt from "bcryptjs";
import { cookies } from "next/headers";
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) {
const fullName = formData.get("fullName") as string;
+174 -4
View File
@@ -1,8 +1,10 @@
import { openai } from '@ai-sdk/openai';
import { streamText, stepCountIs, UIMessage, convertToModelMessages, tool } from 'ai';
import { z } from 'zod';
import { createHash } from 'crypto';
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;
@@ -161,11 +163,24 @@ function industryFromSlug(slug: string): string {
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 ──────────────────────────────────────────────
export async function POST(req: Request) {
// ─── Rate limit (per-IP token bucket, 30 req/min) ──────────────
const rate = checkChatRateLimit(req);
const rate = await checkChatRateLimit(req);
if (!rate.ok) {
return new Response(
JSON.stringify({
@@ -183,16 +198,86 @@ export async function POST(req: Request) {
);
}
const { messages, context }: {
const {
messages,
context,
sessionId,
locale,
pageUrl,
}: {
messages: UIMessage[];
context?: { section?: string; activeTab?: string };
sessionId?: string;
locale?: string;
pageUrl?: string | null;
} = await req.json();
const contextNote = context?.section
? `\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 coreMessages = await convertToModelMessages(messages);
@@ -201,6 +286,91 @@ export async function POST(req: Request) {
model: openai('gpt-4o'),
system: systemPrompt + contextNote,
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
// search → calculator → case-study → consultation in a single turn,
// exactly as the SPIN methodology in the system prompt was designed for.
+140 -38
View File
@@ -1,12 +1,50 @@
// /src/app/api/consultation/route.ts
// Public API endpoint for ConsultationScheduler OperationsSignal
// Uses SMTP mailer (no Resend dependency)
// Public API endpoint for ConsultationScheduler -> OperationsSignal.
// 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 { z } from "zod";
import { prisma } from "@/lib/prisma";
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> {
const year = new Date().getFullYear();
const count = await prisma.operationsSignal.count({
@@ -16,34 +54,54 @@ async function generateConsultationTicketId(): Promise<string> {
}
export async function POST(request: NextRequest) {
// ── CSRF: double-submit cookie + header must match ──────────────────────
const csrfCookie = request.cookies.get(CSRF_COOKIE_NAME)?.value ?? null;
const csrfHeader = request.headers.get(CSRF_HEADER_NAME);
if (!csrfCookie || !csrfHeader || csrfCookie !== csrfHeader || !verifyCsrfToken(csrfHeader)) {
log.warn("consultation.csrf_rejected", { hasCookie: !!csrfCookie, hasHeader: !!csrfHeader });
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();
const { contact, aiContext, meta } = body;
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 },
);
}
if (!contact?.name || !contact?.email || !contact?.company) {
return NextResponse.json({ error: "Missing required contact fields" }, { status: 400 });
}
const { contact, aiContext, meta } = parsed;
try {
const ticketId = await generateConsultationTicketId();
// Build structured AI analysis
// Build structured AI analysis (plain text, no markup needed)
const aiParts: string[] = [];
if (aiContext?.industryLabel && aiContext?.process) aiParts.push(`[INDUSTRY] ${aiContext.industryLabel}${aiContext.process}`);
if (aiContext?.estimatedSavingsPercent) aiParts.push(`[ESTIMATED SAVINGS] ~${aiContext.estimatedSavingsPercent}% energy reduction`);
if (aiContext?.industryLabel && aiContext?.process)
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?.conversationInsights?.length > 0) aiParts.push(`[AI DISCUSSION POINTS]\n${aiContext.conversationInsights.map((i: string) => `${i}`).join("\n")}`);
if (aiContext?.suggestedTopics?.length > 0) aiParts.push(`[SUGGESTED ENGINEERING TOPICS]\n${aiContext.suggestedTopics.map((t: string) => ` ${t}`).join("\n")}`);
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"}`);
if (aiContext?.conversationInsights?.length)
aiParts.push(`[AI DISCUSSION POINTS]\n${aiContext.conversationInsights.map((i) => `- ${i}`).join("\n")}`);
if (aiContext?.suggestedTopics?.length)
aiParts.push(`[SUGGESTED ENGINEERING TOPICS]\n${aiContext.suggestedTopics.map((t) => `-> ${t}`).join("\n")}`);
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 messageParts: string[] = [];
if (contact.message) messageParts.push(contact.message);
if (contact.timeframe) messageParts.push(`Timeframe: ${contact.timeframe}`);
if (contact.preferredContact) messageParts.push(`Preferred contact: ${contact.preferredContact}`);
// Save to DB
const signal = await prisma.operationsSignal.create({
data: {
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 targetEmails = route && route.isActive
? route.emails.split(",").map((e: string) => e.trim()).filter(Boolean)
: ["engineering@fluxsrl.com"];
const targetEmails =
route && route.isActive
? route.emails.split(",").map((e: string) => e.trim()).filter(Boolean)
: ["engineering@fluxsrl.com"];
const appUrl = process.env.NEXT_PUBLIC_APP_URL || "https://flux.com";
// Send via SMTP
const emailResult = await sendEmail({
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),
replyTo: contact.email,
});
// Track email delivery
await prisma.operationsSignal.update({
where: { id: signal.id },
data: {
@@ -86,6 +154,8 @@ export async function POST(request: NextRequest) {
},
});
log.info("consultation.submitted", { ticketId, emailSent: emailResult.success });
return NextResponse.json({
success: true,
ticketId,
@@ -93,33 +163,65 @@ export async function POST(request: NextRequest) {
emailError: emailResult.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 });
}
}
function generateConsultationEmail(contact: any, aiContext: any, ticketId: string, appUrl: string) {
const insights = (aiContext?.conversationInsights || []).map((i: string) => `<li style="margin-bottom: 6px;">${i}</li>`).join("");
const topics = (aiContext?.suggestedTopics || []).map((t: string) => `<li style="margin-bottom: 4px; color: #0066CC;">${t}</li>`).join("");
type ParsedContact = z.infer<typeof ConsultationSchema>["contact"];
type ParsedAiContext = z.infer<typeof ConsultationSchema>["aiContext"];
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 `
<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;">
<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>
<p style="font-family: monospace; color: #00F0FF;">${ticketId}</p>
<p style="font-family: monospace; color: #00F0FF;">${safeTicketId}</p>
</div>
<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;">Email: <a href="mailto:${contact.email}" style="color: #0066CC;">${contact.email}</a></p>
${contact.phone ? `<p style="margin: 4px 0;">Phone: ${contact.phone}</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;"><strong>${safeName}</strong> - ${safeCompany}</p>
<p style="margin: 4px 0;">Email: <a href="${mailHref}" style="color: #0066CC;">${safeEmail}</a></p>
${safePhone ? `<p style="margin: 4px 0;">Phone: ${safePhone}</p>` : ""}
<p style="margin: 4px 0;">Preferred: <strong>${safePreferred}</strong> &middot; Timeframe: <strong>${safeTimeframe}</strong></p>
</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>` : ""}
${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>` : ""}
${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>
${
safeIndustry
? `<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>`
: ""
}
${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 &middot; Reply to contact ${safeName} directly.</p></div>
</div>
`;
}
+19
View File
@@ -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;
}
+36
View File
@@ -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 },
);
}
}
+39 -22
View File
@@ -2,19 +2,20 @@ import { NextRequest, NextResponse } from "next/server";
import fs from "fs";
import path from "path";
import { revalidateContent } from "@/lib/revalidate";
import { detectFileType, expectedTypeForExtension } from "@/lib/fileType";
import { log } from "@/lib/logger";
// 1. REGLAS DE SEGURIDAD ESTRICTAS
const ALLOWED_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.webp', '.mp4', '.mov'];
const MAX_FILE_SIZE = 500 * 1024 * 1024; // 500MB Límite
// 1. STRICT SECURITY RULES
const ALLOWED_EXTENSIONS = [".jpg", ".jpeg", ".png", ".webp", ".mp4", ".mov"];
const MAX_FILE_SIZE = 500 * 1024 * 1024; // 500MB
export async function POST(request: NextRequest) {
try {
const formData = await request.formData();
const file = formData.get("file") as File;
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) {
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();
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, "");
// Convertimos "David Herran!" a "david-herran"
const safeClientName = clientName.toLowerCase().replace(/[^a-z0-9]/g, "-").substring(0, 30);
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);
// 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"))) {
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)) {
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 filePath = path.join(uploadDir, safeFileName);
const buffer = Buffer.from(await file.arrayBuffer());
fs.writeFileSync(filePath, buffer);
// 6. DEVOLVER LA URL PÚBLICA PARA GUARDARLA EN POSTGRES
const publicUrl = `/operations-inbox/${folderName}/${safeFileName}`;
// Invalida caché del operations-inbox / dashboard
revalidateContent({ scope: "operations-inbox", slug: folderName });
log.info("public_upload.saved", { ticketId: safeTicketId, file: safeFileName, detected, bytes: file.size });
return NextResponse.json({
success: true,
url: publicUrl,
fileName: safeFileName,
type: ext === '.mp4' || ext === '.mov' ? 'video' : 'image'
type: ext === ".mp4" || ext === ".mov" ? "video" : "image",
});
} 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 });
}
}
}
@@ -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>
);
}
+9
View File
@@ -177,6 +177,15 @@ export default async function DashboardPage() {
color: "text-fuchsia-400",
bg: "bg-fuchsia-500/10",
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"
}
];
+10 -1
View File
@@ -251,9 +251,18 @@ export default function ConsultationScheduler({ data }: { data: ConsultationData
};
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", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "same-origin",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": csrfToken,
},
body: JSON.stringify(payload),
});
+8 -1
View File
@@ -18,6 +18,8 @@ import CaseStudyViewer from "./CaseStudyViewer";
import EquipmentConfigurator from "./EquipmentConfigurator";
import EfficiencyCard from "./EfficiencyCard";
import { getAiSessionId } from "@/lib/aiSessionId";
export default function SilentObserver() {
const {
isAiExpanded, toggleAi, setAiExpanded,
@@ -54,15 +56,20 @@ export default function SilentObserver() {
};
// ═══ 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({
api: "/api/chat",
body: () => ({
sessionId: getAiSessionId(),
locale,
pageUrl: typeof window !== "undefined" ? window.location.href : null,
context: {
section: sectionRef.current,
activeTab: tabRef.current,
},
}),
}), []);
}), [locale]);
// ═══ AI SDK 6: useChat ═══
const { messages, sendMessage, addToolOutput, status } = useChat({
+17 -6
View File
@@ -62,20 +62,31 @@ export default function NavBar() {
};
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 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);
};
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 () => {
window.removeEventListener("scroll", handleScroll);
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 { useTranslations } from "next-intl";
import { getIconForSlug } from "@/lib/applicationIcons";
import type { AppCard, DashboardMetric } from "@/types/cms";
import { parseJsonField } from "@/types/cms";
export default function ApplicationsDashboard({ dbApps = [] }: { dbApps?: any[] }) {
const activeApps = dbApps.filter(app => app.isActive);
export default function ApplicationsDashboard({ dbApps = [] }: { dbApps?: AppCard[] }) {
const activeApps = dbApps.filter((app) => app.isActive);
if (!activeApps || activeApps.length === 0) return null;
const [activeSlug, setActiveSlug] = useState(activeApps[0]?.slug);
const activeApp = activeApps.find(app => app.slug === activeSlug) || activeApps[0];
const [activeSlug, setActiveSlug] = useState<string | undefined>(activeApps[0]?.slug);
const activeApp = activeApps.find((app) => app.slug === activeSlug) || activeApps[0];
const t = useTranslations("AppsDashboard"); // 🔥 LLAMAMOS AL DICCIONARIO
const t = useTranslations("AppsDashboard");
let metrics = [];
try { metrics = JSON.parse(activeApp.dashboardMetricsJson || "[]"); } catch (e) {}
const metrics = parseJsonField<DashboardMetric[]>(activeApp?.dashboardMetricsJson, []);
const triggerFluxAI = (prompt: string) => {
window.dispatchEvent(new CustomEvent("flux:trigger-ai", { detail: { prompt } }));
+2 -1
View File
@@ -9,6 +9,7 @@ import { MapPin, Calendar, X, ArrowUpRight, Globe, Package, Building2, Layers }
import Image from "next/image";
import CaseStudyModal, { CaseStudyData } from "@/components/ui/CaseStudyModal";
import { useTranslations } from "next-intl";
import type { AppCard, NodeMarker } from "@/types/cms";
const RADIUS = 2;
const CAM_FOV = 50;
@@ -536,7 +537,7 @@ function NodeCard({ node, isDark, onClose, onViewCase }: {
// ─────────────────────────────────────────────────────────────
// 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 [subFilter, setSubFilter] = 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} />
</>
);
}
+64
View File
@@ -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 {}
}
+76
View File
@@ -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,
};
+49
View File
@@ -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> = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
"/": "&#x2F;",
"`": "&#x60;",
"=": "&#x3D;",
};
/**
* 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}`);
}
+86
View File
@@ -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;
}
}
+34
View File
@@ -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),
};
+136 -52
View File
@@ -1,34 +1,17 @@
// src/lib/rateLimit.ts
// ─────────────────────────────────────────────────────────────────────────────
// Lightweight in-memory rate limiter (token bucket per IP).
// 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.
// ─────────────────────────────────────────────────────────────────────────────
// -----------------------------------------------------------------------------
// Token-bucket rate limiter with pluggable backend.
//
// - 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 {
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;
}
import { log } from "@/lib/logger";
export interface RateLimitResult {
ok: boolean;
@@ -36,38 +19,129 @@ export interface RateLimitResult {
retryAfterSec: number;
}
export function rateLimit(key: string, config: RateLimitConfig): RateLimitResult {
const now = Date.now();
gc(now);
interface RateLimitStore {
consume(key: string, capacity: number, refillPerSec: number): Promise<RateLimitResult> | RateLimitResult;
}
const existing = buckets.get(key);
let bucket: Bucket;
// ── In-memory store ──────────────────────────────────────────────────────────
if (!existing) {
bucket = { tokens: config.capacity - 1, updatedAt: now };
buckets.set(key, bucket);
return { ok: true, remaining: bucket.tokens, retryAfterSec: 0 };
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;
}
const elapsedSec = (now - existing.updatedAt) / 1000;
const refilled = Math.min(config.capacity, existing.tokens + elapsedSec * config.refillPerSec);
consume(key: string, capacity: number, refillPerSec: number): RateLimitResult {
const now = Date.now();
this.gc(now);
if (refilled < 1) {
const retryAfterSec = Math.ceil((1 - refilled) / config.refillPerSec);
existing.tokens = refilled;
const existing = this.buckets.get(key);
if (!existing) {
this.buckets.set(key, { tokens: capacity - 1, updatedAt: now });
return { ok: true, remaining: capacity - 1, retryAfterSec: 0 };
}
const elapsedSec = (now - existing.updatedAt) / 1000;
const refilled = Math.min(capacity, existing.tokens + elapsedSec * refillPerSec);
if (refilled < 1) {
const retryAfterSec = Math.ceil((1 - refilled) / refillPerSec);
existing.tokens = refilled;
existing.updatedAt = now;
return { ok: false, remaining: 0, retryAfterSec };
}
existing.tokens = refilled - 1;
existing.updatedAt = now;
return { ok: false, remaining: 0, retryAfterSec };
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);
}
existing.tokens = refilled - 1;
existing.updatedAt = now;
return { ok: true, remaining: Math.floor(existing.tokens), retryAfterSec: 0 };
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 ──────────────────────────────────────────────────────────────────
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");
if (xff) return xff.split(",")[0].trim();
const real = req.headers.get("x-real-ip");
@@ -75,12 +149,22 @@ export function getClientIp(req: Request): string {
return "unknown";
}
interface RateLimitConfig {
capacity: number;
refillPerSec: number;
}
const CHAT_LIMIT: RateLimitConfig = {
capacity: 30, // Burst of 30 messages
refillPerSec: 0.5, // = 30/min sustained
capacity: 30, // burst
refillPerSec: 0.5, // = 30/min sustained
};
export function checkChatRateLimit(req: Request): RateLimitResult {
export async function checkChatRateLimit(req: Request): Promise<RateLimitResult> {
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
View File
@@ -1,8 +1,16 @@
import { SignJWT, jwtVerify } from "jose";
import { cookies } from "next/headers";
// Usamos una variable de entorno secreta, o un fallback súper seguro para desarrollo
const secretKey = process.env.SESSION_SECRET || "FLUX_SUPER_SECRET_KEY_2026_ARCHITECTURE";
// SESSION_SECRET is REQUIRED. No fallback: a public default would let anyone
// 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);
export async function createSession(userId: string, username: string) {
+85
View File
@@ -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;
}
}