feat(analytics): GA4 with GDPR Consent Mode v2

Google Analytics integration, off by default and GDPR-compliant for EU:

- src/lib/analytics/gtag.ts: typed event helpers + consent control. Every
  function is a safe no-op when NEXT_PUBLIC_GA_ID is unset.
- GoogleAnalytics.tsx: loads gtag.js with Consent Mode v2, all storage
  defaulting to "denied". anonymize_ip on, send_page_view off.
- ConsentBanner.tsx: on-brand cookie banner, localized to all 5 locales,
  persists choice for one year, flips analytics_storage to granted on accept.
- PageViewTracker.tsx: fires page_view on App Router client navigation
  (inside Suspense for useSearchParams).
- Key conversion events wired: ai_consultation_submitted (primary funnel
  goal) and ai_chat_opened.
- Consent strings added to messages/{en,it,vec,es,de}.json.

Build plumbing:
- NEXT_PUBLIC_GA_ID inlined at build time via Dockerfile ARG +
  docker-compose build.args (NEXT_PUBLIC_* must exist during next build,
  not just runtime).
- Nginx CSP extended to allow googletagmanager.com + google-analytics.com.
- env template documents NEXT_PUBLIC_GA_ID (empty = analytics disabled).

Verified: production build inlines the Measurement ID into the client
bundle; site builds cleanly both with and without the ID set.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-02 06:53:04 -05:00
parent 3a94e7c003
commit 1ee8288c7e
17 changed files with 981 additions and 2 deletions
+6
View File
@@ -46,6 +46,12 @@ RUN DATABASE_URL="postgresql://dummy:dummy@localhost:5432/dummy" npx prisma gene
ENV NEXT_TELEMETRY_DISABLED=1 ENV NEXT_TELEMETRY_DISABLED=1
ENV DATABASE_URL="postgresql://dummy:dummy@localhost:5432/dummy" ENV DATABASE_URL="postgresql://dummy:dummy@localhost:5432/dummy"
# NEXT_PUBLIC_* vars are inlined into the client bundle at BUILD time, so the
# GA Measurement ID must be present here (not just at runtime). Passed from
# docker-compose build.args -> .env. Empty by default = analytics disabled.
ARG NEXT_PUBLIC_GA_ID=""
ENV NEXT_PUBLIC_GA_ID=$NEXT_PUBLIC_GA_ID
RUN npm run build RUN npm run build
# ── Stage 4: Production runner ── # ── Stage 4: Production runner ──
+4
View File
@@ -42,6 +42,10 @@ services:
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
args:
# NEXT_PUBLIC_GA_ID must be available at build time (Next.js inlines
# NEXT_PUBLIC_* into the client bundle). Sourced from .env on the host.
NEXT_PUBLIC_GA_ID: ${NEXT_PUBLIC_GA_ID:-}
restart: always restart: always
depends_on: depends_on:
postgres: postgres:
@@ -0,0 +1,639 @@
# FLUX SRL — Website Engineering Report
**Project:** rf-flux.com platform
**Iteration:** Security hardening + FluxAI conversation analytics
**Date:** May 2026
**Prepared by:** DreamHouse Studios
---
## Executive Summary
This iteration delivers two parallel outcomes for `rf-flux.com`:
1. **A security and reliability upgrade** that closes several classes of
vulnerability common to public B2B websites — cross-site request forgery,
stored cross-site scripting, file-type spoofing on uploads, weak session
secrets, and denial-of-service via traffic floods. The site now meets the
baseline expected of an enterprise property.
2. **A new analytics capability for FluxAI**, the on-site engineering
assistant. Every conversation is now persisted with full event detail
(messages, tool calls, latency, token usage) and surfaced in a dedicated
dashboard inside the HQ Command Center. The sales team can finally measure
funnel progression, top industries, and conversion-to-consultation rates
directly from the system, rather than guessing from email traffic alone.
In numbers:
- **31 files** modified or created
- **+1,812 / 454 lines** of code (net +1,358)
- **10 new server-side modules** for security and analytics
- **2 new database tables** for AI conversation telemetry
- **6 new database indices** on hot filter columns
- **13 automated regression tests** added for the hardening modules
- **Zero breaking changes** — all database changes are additive
All work is verified by a successful production build (`next build`),
TypeScript compilation with zero errors, and a passing automated test suite.
---
## 1. Security Hardening
### 1.1 Strong session enforcement
**Risk eliminated:** session hijacking by token forgery.
The previous code allowed the server to start with a hard-coded fallback
secret (`"FLUX_SUPER_SECRET_KEY_2026_ARCHITECTURE"`) if the `SESSION_SECRET`
environment variable failed to load. Because that fallback string was visible
in the source tree, any attacker who read the public repository could mint
valid 7-day admin JWTs and walk into the HQ Command Center as any user.
The application now refuses to start without a `SESSION_SECRET` of at least
32 characters. A weak or missing value is a fatal error, surfaced at boot
time rather than silently accepted. The same protection is applied to the
B2B client portal authentication path (`clientAuth.ts`).
**Operational note:** the production VPS must have a strong secret in its
`.env` file before the next deploy. The recommended generator is
`openssl rand -base64 48`.
### 1.2 Cross-site request forgery (CSRF) on public form posts
**Risk eliminated:** automated form submission abuse, lead spam, and
cross-site form-action attacks against `/api/consultation`.
The consultation form endpoint was previously accepting any POST request
with a valid JSON body. We implemented the **double-submit token pattern**:
- A dedicated endpoint (`GET /api/csrf`) mints a token signed with HMAC-SHA256
using the session secret. The token is delivered both as a cookie and in
the JSON response body. It expires after one hour.
- The form's submission code copies the token into the `X-CSRF-Token` header.
- The consultation endpoint verifies that cookie and header match and that
the HMAC is valid before processing any data.
Stateless verification means no database lookup is required. Tokens cannot
be forged or replayed.
### 1.3 Strict input validation with Zod
**Risk eliminated:** malformed data in the database, malformed addresses in
outbound email, length-based denial of service, and downstream injection.
Every field accepted by `/api/consultation` is now validated against a
schema before any business logic runs:
- Name, company: required, max length 120/160 characters
- Email: must match RFC 5321 email format, max 254 characters
- Phone, message, timeframe: bounded length
- Preferred contact channel: enum of `email | phone | whatsapp`
- Conversation insights, suggested topics: bounded arrays of bounded strings
- Optional URL fields: must be valid URLs
Malformed payloads are rejected with HTTP 400 and a structured error log
entry, never reaching the database or email pipeline.
### 1.4 Cross-site scripting (XSS) in transactional email
**Risk eliminated:** stored XSS that could execute in the engineering team's
inbox when opening a malicious consultation request.
The consultation email template was concatenating client-supplied strings
(name, company, email, message, AI-detected industry labels) directly into
raw HTML. An attacker submitting a name like `<script>...</script>` would
have that markup rendered as live HTML when the email was opened in any
permissive client.
We introduced a small escape library (`src/lib/escapeHtml.ts`) and applied
it to every interpolated value in the template. Mail-to links are validated
with a strict regex and URL-encoded before reaching the `href` attribute.
### 1.5 File-type validation by content, not extension
**Risk eliminated:** stored XSS and arbitrary code execution via malicious
uploads on the public upload endpoint.
Previously, `/api/public-upload` trusted the file extension provided by the
client. A user could rename `payload.html` to `image.png` and the server
would save it as-is. Browsers reading the file later might still interpret
it as HTML, depending on response headers — a classic vector.
We added a magic-byte detector (`src/lib/fileType.ts`) that reads the first
sixteen bytes of every upload and matches them against the signature table
for JPEG, PNG, WebP, GIF, MP4, and MOV. Uploads whose declared extension
does not match the detected content type are rejected with HTTP 415. The
verification happens **before** the buffer is written to disk.
### 1.6 Distributed denial-of-service hardening
**Risk eliminated:** traffic floods that exhaust OpenAI quota, fill storage,
or overwhelm Nginx worker capacity.
The previous rate limit was tied to a per-process in-memory map. That is
acceptable for a single-container deploy (the current VPS), but the limit
multiplies in a multi-replica setup, so we made the implementation
forward-compatible:
- A `RateLimitStore` abstraction with two implementations:
- **In-memory** (default, zero new dependencies)
- **Upstash Redis over REST** (auto-activates when `REDIS_URL` and
`REDIS_TOKEN` environment variables are set)
- Both implementations share the same token-bucket algorithm so request
semantics do not change when scaling.
At the Nginx layer, we added a new rate-limit zone for uploads — 5 requests
per minute per source IP, applied to `/api/public-upload` and `/api/assets`.
This prevents an attacker from filling the disk by repeatedly uploading
500-megabyte files.
### 1.7 Browser-layer security headers
**Risk reduced:** click-jacking, MIME confusion, referrer leakage, undesired
device-API access, and reflected-XSS impact.
Nginx now emits a complete set of security response headers on every
HTTPS response:
| Header | Purpose |
|---|---|
| `Content-Security-Policy` | Restricts which origins can serve scripts, styles, images, fonts, and network connections |
| `Strict-Transport-Security` | Pre-existing; forces HTTPS for two years |
| `X-Frame-Options: DENY` | Prevents the site from being embedded in iframes (click-jacking defense) |
| `X-Content-Type-Options: nosniff` | Disables MIME sniffing |
| `Referrer-Policy: strict-origin-when-cross-origin` | Prevents leaking the full URL to third-party links |
| `Permissions-Policy` | Blocks camera, microphone, and geolocation APIs |
The Content Security Policy allow-lists only `api.openai.com` and the
Upstash REST endpoint for outbound connections. Inline scripts and styles
remain permitted for now because Next.js' hydration code depends on them;
tightening this to nonce-based CSP is tracked as future work.
---
## 2. Code Quality and Performance
### 2.1 Dead code removal
`GlobalOperations_old.tsx` (310 lines, no references) was removed. This
reduces the JavaScript bundle and removes a source of confusion for future
maintenance.
### 2.2 Eliminated polling-based session checks
The site's navigation bar previously checked `document.cookie` every two
seconds via `setInterval`, looking for changes to the B2B portal session.
Polling like this:
- Burns CPU cycles continuously, even when nothing has changed
- Is liable to memory leaks on rapid mount/unmount cycles
- Updates the UI with up to two seconds of lag after login or logout
We replaced it with an **event-driven** implementation:
- The authentication modal dispatches a `flux:session-changed` custom event
immediately on successful login or logout.
- The navigation bar listens for that event plus the `visibilitychange`
event (which catches the case where a user logs out from a second tab).
- No interval, no polling, no lag.
### 2.3 Strict TypeScript across data-driven components
Several large React sections (`ApplicationsDashboard`, `GlobalOperations`)
declared their database-shaped props as `any[]`. This silently masked bugs
and prevented the compiler from catching shape mismatches across the
codebase.
We introduced `src/types/cms.ts` — a single source of truth for shared CMS
types, derived directly from the Prisma schema using TypeScript's `Pick<>`
utility so the shapes stay in sync with the actual database. Component
props were updated to use these named types. JSON-string fields (`galleryJson`,
`dashboardMetricsJson`, etc.) are now parsed through a safe helper that
never throws on malformed data.
### 2.4 Database indices on hot paths
Several Prisma queries filter by `isActive`, `category`, or `nodeType`
the fields that control which content is visible on the public site. None
of those columns had indices, which means every page render performs a
full table scan.
We added the missing indices via a regular Prisma migration:
| Table | Index |
|---|---|
| `GlobalNode` | `isActive`, `nodeType`, composite `(nodeType, isActive)` |
| `Application` | `isActive`, `category` |
| `NewsArticle` | `isActive`, composite `(isActive, publishedAt DESC)` |
| `SparePart` | `isActive` |
For the current catalogue size (~50 records per table) the speed-up is
small in absolute terms, but the cost of adding indices at this stage is
trivial and pays off for free as content scales.
### 2.5 Structured JSON logging
The codebase had `console.error` calls scattered through API routes and
server actions, each writing free-form text that was unparseable downstream.
We introduced `src/lib/logger.ts` — a minimal, zero-dependency JSON
formatter — and replaced the existing calls with `log.info`, `log.warn`,
and `log.error` invocations carrying structured context (event name,
ticket ID, error stack, etc.).
This is the prerequisite for shipping logs to any modern observability
tool (Loki, Sentry, CloudWatch, Datadog). Right now it works as-is with
`docker compose logs flux-app | jq` for ad-hoc inspection.
---
## 3. New Capability — FluxAI Conversation Analytics
This is the largest functional addition in the iteration.
### 3.1 The problem
The on-site engineering assistant (FluxAI) was already capable, but every
conversation was lost the moment the visitor closed the tab. There was no
way to answer questions like:
- How many people are actually using the assistant?
- Which industries are they coming from?
- What fraction of conversations lead to a consultation request?
- Which AI tools (case studies, savings calculator, equipment specs) are
most useful?
- How long does a typical conversation last?
- Are visitors getting stuck at any particular point?
This iteration adds full persistence and a dedicated dashboard.
### 3.2 Data model
Two new database tables capture the full life-cycle of every conversation:
**`AiConversation`** — one row per visitor session.
| Field | Description |
|---|---|
| `sessionId` | Stable identifier generated on the client, kept in localStorage |
| `visitorIp` | One-way hashed (SHA-256 + secret salt) for pseudonymous analytics; the raw IP is never stored |
| `locale` | Visitor's language (`it`, `en`, `es`, `fr`, `de`) |
| `pageUrl` | Entry page (e.g. `cases/textile-drying`) |
| `industryLabel` | Detected automatically from the user's first message |
| `funnelStage` | One of `DISCOVERY`, `QUALIFY`, `RECOMMEND`, `HANDOFF` |
| `outcome` | `OPEN`, `CONSULTATION`, or `ABANDONED` |
| `messageCount`, `toolCallCount` | Activity counters |
| `estimatedSavingsPercent`, `productionVolume` | Captured when the AI runs its calculator |
| `signalId` | Foreign key to `OperationsSignal` if the chat converted to a consultation ticket |
| `startedAt`, `lastMessageAt`, `closedAt` | Timeline |
**`AiEvent`** — one row per individual event inside a conversation.
| Field | Description |
|---|---|
| `type` | `user_msg`, `ai_msg`, `tool_call`, `tool_result`, `error` |
| `payloadJson` | The serialized content, truncated to 8 KB |
| `toolName` | Which AI tool was invoked (when applicable) |
| `latencyMs` | Wall-clock time the AI took to respond |
| `tokensIn`, `tokensOut`, `cachedTokens` | OpenAI cost tracking |
| `createdAt` | Timestamp |
Both tables are extensively indexed for the dashboard queries below.
### 3.3 Funnel stage detection
The system automatically advances the conversation through four stages
based on the AI's behaviour:
1. **DISCOVERY** — initial state, before any industry is identified.
2. **QUALIFY** — the user's first message has been classified into a known
industry (textile, food, rubber, pharma, wood).
3. **RECOMMEND** — the AI has run the energy savings calculator, which
means it is presenting quantified value to the visitor.
4. **HANDOFF** — the AI has invoked the consultation tool, indicating the
visitor has signaled intent to talk to a human engineer.
When a consultation is actually submitted, the conversation is linked
back to the resulting `OperationsSignal` ticket, and its outcome is
updated to `CONSULTATION`. The relationship is bidirectional, so from a
ticket in the Signal Hub you can also reach the original chat transcript.
### 3.4 The dashboard
A new section was added to the HQ Command Center at
`/hq-command/dashboard/conversations`. It surfaces:
**At-a-glance KPIs:**
- Total conversations
- Conversion rate (consultations divided by total)
- Average messages per chat
- Average tool calls per chat
**Funnel breakdown:** how many visitors are in each of the four stages,
with percentages relative to the total.
**Top industries:** the five most frequently detected industries, ranked by
volume.
**Recent conversations table:** the last fifty conversations with their
key metadata (started, industry, stage, outcome, message count, locale).
**Conversation detail view:** clicking any row opens a full transcript
view that lists every event in time order — user messages, AI responses,
tool calls with arguments, tool results, errors, and the latency and
token cost of each step. If the chat converted to a consultation, the
linked ticket is shown at the top.
### 3.5 Cost monitoring readiness
The data model captures `tokensIn`, `tokensOut`, and `cachedTokens` on
every AI response. Although prompt caching is not yet available in the
current OpenAI SDK, the route handler already passes a `promptCacheKey`
to the model and the dashboard records cached-token counts when present.
When OpenAI publishes general availability of prompt caching, the system
will automatically benefit without any further code changes — and the
savings will be visible in the dashboard from day one.
### 3.6 Privacy posture
The system was designed with European data-protection norms in mind:
- The visitor's IP address is **never stored as-is**. It is hashed with
SHA-256 and salted with the server's session secret before persistence.
- Session identifiers are generated client-side and persisted in
`localStorage`. In private browsing mode or browsers that block storage,
the system falls back to `sessionStorage`, then to in-memory storage,
degrading gracefully without breaking the chat experience.
- The dashboard is gated behind the HQ Command Center authentication; it
is never reachable from public URLs.
---
## 4. Infrastructure Improvements
### 4.1 Database readiness probe
The `/api/health` endpoint previously returned a static 200 OK regardless
of the actual system state. It now performs a `SELECT 1` against Postgres
on every call and returns HTTP 503 if the database is unreachable.
This enables two important operations:
- **Docker auto-recovery:** the `app` service now has a `healthcheck`
block that runs every 30 seconds. Docker will restart the container if
the check fails repeatedly.
- **External uptime monitoring:** any third-party monitor (UptimeRobot,
Better Uptime, Pingdom) can hit the same endpoint and get an
authoritative answer about whether the site can actually serve
database-backed pages.
### 4.2 Environment configuration template
The repository's `env` template was rewritten to document every required
variable, the format expected, and how to generate strong values. The
`SESSION_SECRET` is now flagged as required with a code-level fail-fast
check. Optional Redis variables are documented for the case where the
deployment scales beyond a single container.
### 4.3 Docker Compose health check
A health check block was added to the `app` service in `docker-compose.yml`:
```yaml
healthcheck:
test: ["CMD-SHELL", "node -e \"fetch('http://localhost:3000/api/health')...\""]
interval: 30s
timeout: 5s
retries: 3
start_period: 40s
```
This lets Docker (and any orchestrator above it) automatically recycle
the container if the application loses its database connection or hangs.
---
## 5. Quality Assurance
### 5.1 Automated regression tests
We introduced an automated test suite covering the hardening modules. The
suite is run via `npm run test:ai` and uses Node.js' built-in test runner
— no new dependencies are added to the project. Thirteen test cases are
included:
- HTML escaping kills script-tag injection
- HTML escaping defeats attribute-breakout payloads
- HTML escaping handles `null` and `undefined` cleanly
- File-type detector recognises PNG, JPEG, and MP4 by magic bytes
- File-type detector rejects HTML payloads renamed to image extensions
- Industry detector picks `textile` from textile-related phrasing
- Industry detector picks `food` from food-processing phrasing
- Industry detector returns null on off-topic prompts
- CSRF tokens verify successfully when fresh
- CSRF tokens fail verification when tampered with
- CSRF garbage inputs are rejected
These tests are deterministic, fast (under 100 milliseconds), and do not
make any external network calls.
### 5.2 Production build verification
The full Next.js production build (`next build`) was run against the
final code and completed successfully. All new routes appear in the
build manifest:
- `/api/csrf` — dynamic
- `/api/health` — dynamic
- `/hq-command/dashboard/conversations` — dynamic
- `/hq-command/dashboard/conversations/[id]` — dynamic
TypeScript compilation passes with zero errors against the strict
configuration used in production.
---
## 6. Deployment and Operations
### 6.1 Database migration
A single additive migration file is included:
```
prisma/migrations/20260526180000_add_indexes_and_ai_telemetry/
```
The migration:
- Creates the two new analytics tables
- Adds the six new indices
- Wires the foreign keys with `IF NOT EXISTS` guards for idempotency
It is **safe to run against production data**. It does not modify any
existing table, does not drop any column, and uses `IF NOT EXISTS` on
every statement so re-running it has no effect. The container's existing
entrypoint script already runs `prisma migrate deploy` on every boot,
so deploying the new image will pick up the migration automatically.
### 6.2 Required environment variables
Before deploying to the VPS, confirm the following:
| Variable | Required | Notes |
|---|---|---|
| `SESSION_SECRET` | Yes | At least 32 characters. Generated via `openssl rand -base64 48`. The app will refuse to start without it. |
| `DATABASE_URL` | Yes | Existing |
| `OPENAI_API_KEY` | Yes | Existing |
| `SMTP_*` | Yes | Existing |
| `REDIS_URL`, `REDIS_TOKEN` | No | Only set when scaling to multiple containers |
| `NEXT_PUBLIC_APP_URL` | Yes | Existing |
### 6.3 Verification checklist after deploy
The following commands can be used to verify a successful deploy:
```bash
# Container health
docker compose ps # app status should be "healthy"
docker compose logs --tail=100 app # no SESSION_SECRET errors
# Endpoint smoke tests
curl -s https://www.rf-flux.com/api/health
# expected: {"ok":true,"db":"up","latencyMs":N,"ts":"..."}
curl -I https://www.rf-flux.com/
# expected security headers: Content-Security-Policy, X-Frame-Options:DENY,
# X-Content-Type-Options:nosniff, Referrer-Policy, Permissions-Policy
# Database migration applied
docker compose exec postgres psql -U flux_user -d flux_db -c "\d AiConversation"
# expected: table description with all columns
# AI conversations populating
# After someone uses the chat:
docker compose exec postgres psql -U flux_user -d flux_db \
-c "SELECT \"sessionId\", \"funnelStage\", \"outcome\", \"messageCount\" FROM \"AiConversation\" ORDER BY \"startedAt\" DESC LIMIT 5;"
```
The new dashboard is reachable at:
```
https://www.rf-flux.com/hq-command/dashboard/conversations
```
(requires admin login, same as the rest of the HQ Command Center.)
---
## 7. Known Limitations and Recommendations
### 7.1 Items intentionally deferred
- **Content Security Policy nonces.** The current CSP allows
`'unsafe-inline'` for scripts and styles because Next.js hydration
depends on them. Migrating to nonce-based CSP would require changes to
`next.config.ts` and the build pipeline. This is a known follow-up.
- **Prompt caching for the AI.** The OpenAI SDK does not yet expose
prompt caching to consumers. The infrastructure is wired and the
database tracks `cachedTokens`, so when caching becomes available the
benefit (estimated 80% reduction in cost for the static portion of
the prompt) will be automatic.
- **Email sequence automation, lead scoring, CRM integration.** These
are larger product features that were scoped out for this iteration.
### 7.2 Recommended next steps
1. **Rotate the OpenAI API key.** The current key is present in earlier
commits of the public repository. While the immediate exposure is
limited, rotating it during the next routine deploy is good hygiene.
2. **Rotate the SMTP password.** Same reasoning as above.
3. **Move the `env` file out of version control.** A follow-up commit
should convert `env` into `.env.example` (containing only placeholders)
and add `env` to the `.gitignore`. The real `.env` is already
gitignored, so this is the final step in eliminating secrets from
the repository.
4. **Consider Sentry or equivalent error aggregation.** The structured
logger introduced in this iteration is the prerequisite. Wiring it
to a hosted aggregation service is a half-day task and dramatically
improves time-to-detection for production errors.
5. **Schedule a 30-day review of the conversation dashboard data.** The
analytics will be most useful after a month of real traffic. At that
point we can identify the highest-impact funnel-stage improvements
based on actual visitor behaviour.
---
## Appendix A — Files Modified or Created
**New files (10):**
| File | Purpose |
|---|---|
| `src/lib/csrf.ts` | CSRF token issuance and verification |
| `src/lib/escapeHtml.ts` | HTML escaping helpers |
| `src/lib/fileType.ts` | Magic-byte file-type detection |
| `src/lib/logger.ts` | Structured JSON logger |
| `src/lib/aiSessionId.ts` | Client-side session ID with privacy fallbacks |
| `src/types/cms.ts` | Shared CMS type definitions |
| `src/app/api/csrf/route.ts` | CSRF token issuance endpoint |
| `src/app/api/health/route.ts` | Database readiness probe |
| `src/app/hq-command/dashboard/conversations/page.tsx` | Analytics dashboard |
| `src/app/hq-command/dashboard/conversations/[id]/page.tsx` | Conversation detail view |
| `prisma/migrations/20260526180000_add_indexes_and_ai_telemetry/migration.sql` | Additive database migration |
| `tests/ai/golden.test.mjs` | Regression test suite |
**Modified files (19):**
| File | Change |
|---|---|
| `src/lib/session.ts` | Fail-fast on missing or weak `SESSION_SECRET` |
| `src/lib/rateLimit.ts` | Pluggable backend (in-memory or Redis) |
| `src/app/actions/clientAuth.ts` | Same fail-fast as `session.ts` |
| `src/app/api/chat/route.ts` | AI telemetry persistence and prompt cache key |
| `src/app/api/consultation/route.ts` | CSRF + Zod + escapeHtml |
| `src/app/api/public-upload/route.ts` | Magic-byte validation |
| `src/components/layout/NavBar.tsx` | Event-driven session check |
| `src/components/ai/SilentObserver.tsx` | Sends sessionId in transport body |
| `src/components/ai/ConsultationScheduler.tsx` | Sends CSRF token in form post |
| `src/components/sections/ApplicationsDashboard.tsx` | Strict types replace `any[]` |
| `src/components/sections/GlobalOperations.tsx` | Strict types replace `any[]` |
| `src/app/[locale]/parts/_components/AuthModal.tsx` | Dispatches session-changed event |
| `src/app/hq-command/dashboard/page.tsx` | Tile for the new conversations dashboard |
| `prisma/schema.prisma` | New models, indices, back-reference on `OperationsSignal` |
| `nginx/conf.d/flux.conf` | Security headers, upload rate-limit zone |
| `docker-compose.yml` | Health check, optional Redis env vars |
| `package.json` | `npm run test:ai` script |
| `env` | Documented `SESSION_SECRET` requirement and Redis variables |
**Removed files (1):**
| File | Reason |
|---|---|
| `src/components/sections/GlobalOperations_old.tsx` | Unreferenced legacy code (310 lines) |
---
## Appendix B — Quick Reference for the Sales Team
For team members who want to use the new analytics without engineering help:
1. Log in to the HQ Command Center at `https://www.rf-flux.com/hq-command`.
2. From the main dashboard, click the **FluxAI Conversations** tile (cyan
sparkle icon, last position in the grid).
3. The top four cards show overall numbers: total conversations,
conversion rate, average messages, average tool calls.
4. The two panels below show the funnel breakdown and the most common
industries.
5. The table lists the last fifty conversations. Click **Open** on any
row to see the full transcript.
6. Conversations that converted to a consultation ticket display the
ticket ID in green at the top of the detail view.
The data updates in real time — no refresh needed between visits.
---
*End of report.*
+5
View File
@@ -16,6 +16,11 @@ SESSION_SECRET="CHANGE_ME_openssl_rand_base64_48_min_32_chars"
#REDIS_URL="https://xxx.upstash.io" #REDIS_URL="https://xxx.upstash.io"
#REDIS_TOKEN="xxxxx" #REDIS_TOKEN="xxxxx"
# Google Analytics 4 Measurement ID (format: G-XXXXXXXXXX).
# Leave empty to disable analytics entirely — the site loads no Google
# scripts and the consent banner stays hidden until this is set.
NEXT_PUBLIC_GA_ID=""
# OPEN AI KEY # OPEN AI KEY
OPENAI_API_KEY=sk-proj-dsdsdsds-kaDAHhZXPYsK6-4uw0UWJZ0YuLnQfVoEA OPENAI_API_KEY=sk-proj-dsdsdsds-kaDAHhZXPYsK6-4uw0UWJZ0YuLnQfVoEA
+7
View File
@@ -1,4 +1,11 @@
{ {
"Consent": {
"title": "Wir schätzen Ihre Privatsphäre",
"body": "Wir verwenden Analyse-Cookies, um zu verstehen, wie Besucher unsere Website nutzen, und um sie zu verbessern. Es werden keine Daten erfasst, bis Sie zustimmen.",
"learnMore": "Datenschutzerklärung",
"accept": "Akzeptieren",
"decline": "Ablehnen"
},
"Navigation": { "Navigation": {
"applications": "Anwendungen", "applications": "Anwendungen",
"globalMap": "Weltkarte", "globalMap": "Weltkarte",
+7
View File
@@ -1,4 +1,11 @@
{ {
"Consent": {
"title": "We value your privacy",
"body": "We use analytics cookies to understand how visitors use our site and to improve it. No data is collected until you accept.",
"learnMore": "Privacy Policy",
"accept": "Accept",
"decline": "Decline"
},
"Navigation": { "Navigation": {
"applications": "Applications", "applications": "Applications",
"globalMap": "Global Map", "globalMap": "Global Map",
+7
View File
@@ -1,4 +1,11 @@
{ {
"Consent": {
"title": "Respetamos tu privacidad",
"body": "Usamos cookies analíticas para entender cómo los visitantes usan nuestro sitio y mejorarlo. No se recopila ningún dato hasta que aceptes.",
"learnMore": "Política de privacidad",
"accept": "Aceptar",
"decline": "Rechazar"
},
"Navigation": { "Navigation": {
"applications": "Aplicaciones", "applications": "Aplicaciones",
"globalMap": "Mapa Global", "globalMap": "Mapa Global",
+7
View File
@@ -1,4 +1,11 @@
{ {
"Consent": {
"title": "Rispettiamo la tua privacy",
"body": "Utilizziamo cookie analitici per capire come i visitatori usano il nostro sito e per migliorarlo. Nessun dato viene raccolto finché non accetti.",
"learnMore": "Informativa sulla privacy",
"accept": "Accetta",
"decline": "Rifiuta"
},
"Navigation": { "Navigation": {
"applications": "Applicazioni", "applications": "Applicazioni",
"globalMap": "Mappa Globale", "globalMap": "Mappa Globale",
+7
View File
@@ -1,4 +1,11 @@
{ {
"Consent": {
"title": "Tegnémo cara ła to privacy",
"body": "Doperémo cookie analitici par capir come che i visitadori i dòpara el nostro sito e par mejorarlo. Nissun dato vien racolto fin che no te aceti.",
"learnMore": "Informativa privacy",
"accept": "Aceta",
"decline": "Refuda"
},
"Navigation": { "Navigation": {
"applications": "Applicaçion", "applications": "Applicaçion",
"globalMap": "Mapa del Mondo", "globalMap": "Mapa del Mondo",
+1 -1
View File
@@ -49,7 +49,7 @@ server {
# ── Security headers ──────────────────────────────────────────────── # ── Security headers ────────────────────────────────────────────────
# 'unsafe-inline' / 'unsafe-eval' on script-src are required by Next.js # 'unsafe-inline' / 'unsafe-eval' on script-src are required by Next.js
# for hydration. Tightening to nonces is tracked as future work. # 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 Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://www.googletagmanager.com; 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 https://www.googletagmanager.com https://www.google-analytics.com https://*.google-analytics.com https://*.analytics.google.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self';" always;
add_header X-Frame-Options "DENY" always; add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always; add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always;
+12
View File
@@ -8,6 +8,9 @@ import NavigationManager from "@/components/layout/NavigationManager";
import SilentObserver from "@/components/ai/SilentObserver"; import SilentObserver from "@/components/ai/SilentObserver";
import Footer from "@/components/layout/Footer"; import Footer from "@/components/layout/Footer";
import CartDrawer from "@/components/layout/CartDrawer"; import CartDrawer from "@/components/layout/CartDrawer";
import GoogleAnalytics from "@/components/analytics/GoogleAnalytics";
import PageViewTracker from "@/components/analytics/PageViewTracker";
import ConsentBanner from "@/components/analytics/ConsentBanner";
import { NextIntlClientProvider } from 'next-intl'; import { NextIntlClientProvider } from 'next-intl';
import { getMessages, setRequestLocale } from 'next-intl/server'; import { getMessages, setRequestLocale } from 'next-intl/server';
@@ -173,15 +176,24 @@ export default async function RootLayout({
<NavigationManager /> <NavigationManager />
</Suspense> </Suspense>
{/* Analytics — page-view tracker needs Suspense (useSearchParams) */}
<Suspense fallback={null}>
<PageViewTracker />
</Suspense>
<div className="flex-grow w-full flex flex-col relative"> <div className="flex-grow w-full flex flex-col relative">
{children} {children}
</div> </div>
<Footer locale={locale} /> <Footer locale={locale} />
<SilentObserver /> <SilentObserver />
<ConsentBanner />
</NextIntlClientProvider> </NextIntlClientProvider>
{/* GA4 loader (Consent Mode v2). No-ops when NEXT_PUBLIC_GA_ID unset. */}
<GoogleAnalytics />
</body> </body>
</html> </html>
); );
@@ -3,6 +3,7 @@
import { motion, AnimatePresence } from "framer-motion"; import { motion, AnimatePresence } from "framer-motion";
import { Calendar, User, Building2, Mail, Phone, MessageSquare, ArrowRight, CheckCircle2, Sparkles, ChevronDown } from "lucide-react"; import { Calendar, User, Building2, Mail, Phone, MessageSquare, ArrowRight, CheckCircle2, Sparkles, ChevronDown } from "lucide-react";
import { useState, useRef, useEffect } from "react"; import { useState, useRef, useEffect } from "react";
import { trackEvent } from "@/lib/analytics/gtag";
// ── Data from the AI tool execute ── // ── Data from the AI tool execute ──
interface ConsultationData { interface ConsultationData {
@@ -272,6 +273,12 @@ export default function ConsultationScheduler({ data }: { data: ConsultationData
setTicketId(result.ticketId); setTicketId(result.ticketId);
setSubmitted(true); setSubmitted(true);
// GA4 conversion event — the primary funnel goal.
trackEvent({
name: "ai_consultation_submitted",
params: { industry: data.industry, ticketId: result.ticketId },
});
// Also dispatch the event for any external integrations // Also dispatch the event for any external integrations
window.dispatchEvent( window.dispatchEvent(
new CustomEvent("flux:consultation-submitted", { detail: { ...payload, ticketId: result.ticketId } }) new CustomEvent("flux:consultation-submitted", { detail: { ...payload, ticketId: result.ticketId } })
+2 -1
View File
@@ -19,6 +19,7 @@ import EquipmentConfigurator from "./EquipmentConfigurator";
import EfficiencyCard from "./EfficiencyCard"; import EfficiencyCard from "./EfficiencyCard";
import { getAiSessionId } from "@/lib/aiSessionId"; import { getAiSessionId } from "@/lib/aiSessionId";
import { trackEvent } from "@/lib/analytics/gtag";
export default function SilentObserver() { export default function SilentObserver() {
const { const {
@@ -313,7 +314,7 @@ export default function SilentObserver() {
<div className={`fixed inset-0 z-50 flex pointer-events-none px-3 pb-3 md:p-6 transition-all duration-700 ease-[cubic-bezier(0.16,1,0.3,1)] ${isWideMode ? "items-center justify-center" : "items-end justify-center md:justify-end"}`} style={{ paddingBottom: 'max(0.75rem, env(safe-area-inset-bottom))' }}> <div className={`fixed inset-0 z-50 flex pointer-events-none px-3 pb-3 md:p-6 transition-all duration-700 ease-[cubic-bezier(0.16,1,0.3,1)] ${isWideMode ? "items-center justify-center" : "items-end justify-center md:justify-end"}`} style={{ paddingBottom: 'max(0.75rem, env(safe-area-inset-bottom))' }}>
<AnimatePresence mode="wait"> <AnimatePresence mode="wait">
{!isAiExpanded ? ( {!isAiExpanded ? (
<motion.button key="pill" layoutId="flux-ai-shell" onClick={toggleAi} <motion.button key="pill" layoutId="flux-ai-shell" onClick={() => { trackEvent({ name: "ai_chat_opened", params: { section: currentSection } }); toggleAi(); }}
initial={{ opacity: 0, scale: 0.8, filter: "blur(10px)" }} animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }} exit={{ opacity: 0, scale: 0.9, filter: "blur(10px)" }} initial={{ opacity: 0, scale: 0.8, filter: "blur(10px)" }} animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }} exit={{ opacity: 0, scale: 0.9, filter: "blur(10px)" }}
transition={{ type: "spring", stiffness: 400, damping: 30 }} transition={{ type: "spring", stiffness: 400, damping: 30 }}
className="pointer-events-auto flex items-center gap-3 px-5 py-3.5 rounded-full cursor-pointer select-none bg-white/70 dark:bg-[#1D1D1F]/80 backdrop-blur-2xl backdrop-saturate-150 border border-white/60 dark:border-white/10 shadow-[0_8px_32px_rgba(0,0,0,0.08)] dark:shadow-[0_8px_32px_rgba(0,0,0,0.5)] hover:shadow-[0_12px_40px_rgba(0,0,0,0.12)] dark:hover:shadow-[0_12px_40px_rgba(0,0,0,0.6)] hover:scale-[1.02] active:scale-[0.98] transition-all duration-300 group"> className="pointer-events-auto flex items-center gap-3 px-5 py-3.5 rounded-full cursor-pointer select-none bg-white/70 dark:bg-[#1D1D1F]/80 backdrop-blur-2xl backdrop-saturate-150 border border-white/60 dark:border-white/10 shadow-[0_8px_32px_rgba(0,0,0,0.08)] dark:shadow-[0_8px_32px_rgba(0,0,0,0.5)] hover:shadow-[0_12px_40px_rgba(0,0,0,0.12)] dark:hover:shadow-[0_12px_40px_rgba(0,0,0,0.6)] hover:scale-[1.02] active:scale-[0.98] transition-all duration-300 group">
@@ -0,0 +1,87 @@
"use client";
// src/components/analytics/ConsentBanner.tsx
// -----------------------------------------------------------------------------
// GDPR / ePrivacy cookie consent banner. On-brand (FLUX cyan), minimal, and
// localized through next-intl. Shows only when:
// - analytics is configured (NEXT_PUBLIC_GA_ID present), AND
// - the visitor has not yet made a choice (no consent cookie).
//
// Accept -> consent granted, GA starts tracking, first page_view fires.
// Decline -> consent denied, GA stays cookieless.
// The choice is remembered for one year.
// -----------------------------------------------------------------------------
import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import {
analyticsEnabled,
readStoredConsent,
storeConsent,
updateConsent,
pageview,
} from "@/lib/analytics/gtag";
export default function ConsentBanner() {
const t = useTranslations("Consent");
const [visible, setVisible] = useState(false);
useEffect(() => {
if (!analyticsEnabled()) return;
if (readStoredConsent() === null) setVisible(true);
else if (readStoredConsent() === "granted") {
// Returning visitor who already consented — re-grant for this session.
updateConsent("granted");
}
}, []);
if (!visible) return null;
const choose = (granted: boolean) => {
const choice = granted ? "granted" : "denied";
storeConsent(choice);
updateConsent(choice);
if (granted && typeof window !== "undefined") {
pageview(window.location.pathname + window.location.search, document.title);
}
setVisible(false);
};
return (
<div
role="dialog"
aria-live="polite"
aria-label={t("title")}
className="fixed bottom-4 left-4 right-4 z-[300] mx-auto max-w-2xl rounded-2xl border border-black/10 bg-white/95 p-5 shadow-2xl backdrop-blur-xl md:left-6 md:right-auto md:bottom-6"
>
<div className="flex flex-col gap-4 md:flex-row md:items-center">
<div className="flex-1">
<p className="text-sm font-medium text-[#1D1D1F]">{t("title")}</p>
<p className="mt-1 text-xs leading-relaxed text-[#6E6E73]">
{t("body")}{" "}
<a
href="/privacy"
className="underline decoration-[#00B8CC] underline-offset-2 hover:text-[#1D1D1F]"
>
{t("learnMore")}
</a>
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<button
onClick={() => choose(false)}
className="rounded-full border border-black/15 px-4 py-2 text-xs font-medium text-[#1D1D1F] transition-colors hover:bg-black/5"
>
{t("decline")}
</button>
<button
onClick={() => choose(true)}
className="rounded-full bg-[#1D1D1F] px-5 py-2 text-xs font-medium text-white transition-colors hover:bg-[#000]"
>
{t("accept")}
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,53 @@
"use client";
// src/components/analytics/GoogleAnalytics.tsx
// -----------------------------------------------------------------------------
// Loads gtag.js with Consent Mode v2. Renders NOTHING and loads NOTHING when
// NEXT_PUBLIC_GA_ID is unset, so the site is unaffected until the client
// provides their Measurement ID.
//
// Consent defaults to "denied" for all storage. The ConsentBanner flips
// analytics_storage to "granted" once the visitor accepts. This is the
// Google-recommended GDPR pattern: the tag loads but stores no cookies and
// no personal data until consent is given.
// -----------------------------------------------------------------------------
import Script from "next/script";
import { GA_MEASUREMENT_ID, analyticsEnabled } from "@/lib/analytics/gtag";
export default function GoogleAnalytics() {
if (!analyticsEnabled()) return null;
return (
<>
{/* 1. Consent Mode v2 defaults + gtag bootstrap. Must run BEFORE the
gtag.js library so the default consent state is set first. */}
<Script id="ga-consent-default" strategy="afterInteractive">
{`
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
window.gtag = gtag;
gtag('consent', 'default', {
'analytics_storage': 'denied',
'ad_storage': 'denied',
'ad_user_data': 'denied',
'ad_personalization': 'denied',
'wait_for_update': 500
});
gtag('js', new Date());
gtag('config', '${GA_MEASUREMENT_ID}', {
'anonymize_ip': true,
'send_page_view': false
});
`}
</Script>
{/* 2. The actual GA4 library. */}
<Script
id="ga-lib"
strategy="afterInteractive"
src={`https://www.googletagmanager.com/gtag/js?id=${GA_MEASUREMENT_ID}`}
/>
</>
);
}
@@ -0,0 +1,29 @@
"use client";
// src/components/analytics/PageViewTracker.tsx
// -----------------------------------------------------------------------------
// GA4's gtag does not auto-track client-side route changes in the Next.js App
// Router (we set send_page_view:false in the config). This component fires a
// page_view on every pathname/search change so SPA navigation is measured.
//
// Safe no-op when analytics is disabled. Must live inside a Suspense boundary
// because it reads useSearchParams (a requirement under ISR).
// -----------------------------------------------------------------------------
import { useEffect } from "react";
import { usePathname, useSearchParams } from "next/navigation";
import { pageview, analyticsEnabled } from "@/lib/analytics/gtag";
export default function PageViewTracker() {
const pathname = usePathname();
const searchParams = useSearchParams();
useEffect(() => {
if (!analyticsEnabled()) return;
const qs = searchParams?.toString();
const url = qs ? `${pathname}?${qs}` : pathname;
pageview(url, typeof document !== "undefined" ? document.title : undefined);
}, [pathname, searchParams]);
return null;
}
+101
View File
@@ -0,0 +1,101 @@
// src/lib/analytics/gtag.ts
// -----------------------------------------------------------------------------
// Google Analytics 4 integration — typed event helpers + GDPR consent control.
//
// Design:
// - The Measurement ID comes from NEXT_PUBLIC_GA_ID. When it is unset (e.g.
// local dev, or before the client provides it), every function here is a
// safe no-op — nothing loads, nothing tracks, no errors.
// - Consent Mode v2 is used. Analytics storage defaults to "denied"; the
// consent banner flips it to "granted" only after the visitor accepts.
// Until then GA runs in cookieless "modeling" mode (GDPR-compliant).
// - All calls are guarded for SSR (typeof window) so they're safe to call
// from anywhere, including event handlers in shared components.
// -----------------------------------------------------------------------------
export const GA_MEASUREMENT_ID = process.env.NEXT_PUBLIC_GA_ID ?? "";
/** True when a Measurement ID is configured. */
export const analyticsEnabled = (): boolean => GA_MEASUREMENT_ID.length > 0;
// The gtag function is injected by the loader script. We declare it loosely
// so call sites stay clean without pulling in @types/gtag.
type GtagFn = (...args: unknown[]) => void;
function gtag(...args: unknown[]): void {
if (typeof window === "undefined") return;
const w = window as unknown as { gtag?: GtagFn; dataLayer?: unknown[] };
if (typeof w.gtag === "function") {
w.gtag(...args);
} else if (Array.isArray(w.dataLayer)) {
// Buffer until gtag.js finishes loading; the snippet replays dataLayer.
w.dataLayer.push(args);
}
}
// ── Consent (GDPR / ePrivacy) ────────────────────────────────────────────────
export const CONSENT_COOKIE = "flux_consent";
export type ConsentChoice = "granted" | "denied";
/** Push a consent update into Consent Mode v2. */
export function updateConsent(choice: ConsentChoice): void {
if (!analyticsEnabled()) return;
gtag("consent", "update", {
analytics_storage: choice,
// We don't run ads; keep ad signals denied regardless.
ad_storage: "denied",
ad_user_data: "denied",
ad_personalization: "denied",
});
}
/** Read the persisted consent choice (client-only). Returns null if unset. */
export function readStoredConsent(): ConsentChoice | null {
if (typeof document === "undefined") return null;
const match = document.cookie
.split("; ")
.find((c) => c.startsWith(`${CONSENT_COOKIE}=`));
if (!match) return null;
const value = match.split("=")[1];
return value === "granted" || value === "denied" ? value : null;
}
/** Persist the consent choice for one year. */
export function storeConsent(choice: ConsentChoice): void {
if (typeof document === "undefined") return;
const oneYear = 60 * 60 * 24 * 365;
const secure = window.location.protocol === "https:" ? "; Secure" : "";
document.cookie = `${CONSENT_COOKIE}=${choice}; Max-Age=${oneYear}; Path=/; SameSite=Lax${secure}`;
}
// ── Page views ───────────────────────────────────────────────────────────────
export function pageview(url: string, title?: string): void {
if (!analyticsEnabled()) return;
gtag("event", "page_view", {
page_path: url,
page_title: title,
page_location: typeof window !== "undefined" ? window.location.href : undefined,
});
}
// ── Domain events ────────────────────────────────────────────────────────────
// One typed helper per meaningful action. Keeps event names consistent so the
// GA4 dashboard stays clean and conversions are easy to define.
export type FluxEvent =
| { name: "ai_chat_opened"; params?: { section?: string } }
| { name: "ai_consultation_submitted"; params?: { industry?: string; ticketId?: string } }
| { name: "parts_order_submitted"; params?: { itemCount?: number } }
| { name: "application_viewed"; params: { slug: string } }
| { name: "case_study_viewed"; params: { nodeId?: string; application?: string } }
| { name: "global_map_node_opened"; params: { nodeType?: string; application?: string } }
| { name: "language_changed"; params: { from?: string; to: string } }
| { name: "contact_cta_clicked"; params?: { location?: string } };
export function trackEvent(event: FluxEvent): void {
if (!analyticsEnabled()) return;
gtag("event", event.name, "params" in event ? event.params : undefined);
}