Commit Graph

69 Commits

Author SHA1 Message Date
davidherran afcaf991b5 feat(applications): drag-to-reorder on the public site
Editors can now control the order applications appear in, the same way they
already reorder Hero slides.

- Application gains an `order Int @default(0)` column (additive migration
  20260605120000_add_application_order, IF NOT EXISTS, safe for deploy) plus
  an (isActive, order) index.
- New reorderApplications(orderedSlugs) server action — single $transaction
  renumbering, mirrors reorderHeroSlides.
- HQ applications panel: rows are now draggable by a grip handle (HTML5 DnD,
  optimistic local reorder, persisted on drop, toast feedback).
- All public-facing queries now order by [order asc, createdAt asc]: home
  ApplicationsDashboard + GlobalOperations, the footer apps list, and the
  HQ list itself. Existing rows default to 0 so current order is preserved
  until the editor drags something.

Verified: production build compiles, TypeScript clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 12:04:10 -05:00
davidherran fbfffb28d9 feat(analytics): activate GA4 (G-KQ1JRV3KN7) + GDPR privacy page + GSC support
Client provided the GA4 Measurement ID and approved the standard policy.

- Activate analytics: NEXT_PUBLIC_GA_ID set to the FLUX property
  G-KQ1JRV3KN7 in the env template, with the same value as the
  docker-compose build-arg fallback so it works out of the box on deploy.
  (GA Measurement IDs are public — they ship in page HTML — safe to commit.)
- New GDPR-compliant Privacy & Cookie Policy page at /[locale]/privacy
  (all 5 locales), linked from the consent banner. Includes a clearly
  marked template disclaimer for legal review and a TODO on the contact
  email. Added to sitemap.
- Consent banner now links via the locale-aware next-intl Link.
- Google Search Console: optional NEXT_PUBLIC_GSC_VERIFICATION env var
  emits the google-site-verification meta tag (Dockerfile arg +
  docker-compose wired). Empty by default.

Verified: build inlines G-KQ1JRV3KN7 into the client bundle; the 5
/privacy routes render; TypeScript clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 12:00:44 -05:00
davidherran 148aefc68f feat(team): public Team page + HQ CMS panel
New "Team" section — a LinkedIn-style minimal profile page for the FLUX
team, fully editable from the HQ Command Center.

Data model:
- New TeamMember model (name, role, bio, photoUrl, optional social links:
  email/linkedin/x/website, order, isActive, translationsJson).
- Additive migration 20260602120000_add_team_member (IF NOT EXISTS guards).
- Name stays as written; role + bio are translatable via the AI engine.

HQ panel (/hq-command/dashboard/team):
- Drag-to-reorder (same HTML5 pattern as the Hero panel).
- Inline auto-save for name/role/visibility; expandable editor for photo
  upload, bio, social links, and AI auto-translate to IT/VEC/ES/DE.
- Photo upload reuses /api/assets with a new flat "team" scope -> /public/team/.
- Dashboard tile added.

Public page (/[locale]/team):
- Responsive card grid (framer-motion stagger), portrait + name + role +
  bio + social icons (only the links that exist render).
- Per-member Person JSON-LD + breadcrumb for SEO.
- Localized via getLocalizedData; new TeamPage namespace in all 5 locales.
- NavBar item "Team" inserted before "Spare Parts" (translated 5 locales).
- Added to sitemap.

Infra:
- "team" scope registered in /api/assets (SCOPE_ROOTS + FLAT_SCOPES +
  buildPublicUrl) and revalidate.ts (RevalidateScope + path).
- Nginx serves /team/ from disk; docker-compose mounts public/team in both
  app and nginx (rw + ro).

Verified: production build compiles, all 5 /[locale]/team routes + the HQ
panel render; TypeScript clean; 5 message files valid JSON.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 07:17:09 -05:00
davidherran 1ee8288c7e 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>
2026-06-02 06:53:04 -05:00
davidherran 3a94e7c003 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>
2026-05-27 08:10:19 -05:00
davidherran 792dd6794b fix(ai): guard datasheet/gallery/videos against non-array values
Deploy to VPS / deploy (push) Has been cancelled
EquipmentConfigurator and CaseStudyViewer crashed with
"e.datasheet.find is not a function" when DB nodes had
malformed JSON in specificDatasheetJson. Both components now
normalize datasheet, gallery, and videos to safe arrays via
Array.isArray before any array method calls.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-07 09:12:55 -05:00
davidherran 7278d5d00f fix(ai): defensive navigation — auto-resolve phantom section IDs to page routes
Deploy to VPS / deploy (push) Has been cancelled
GPT-4o ignores tool description instructions and sends section:"news"
instead of url:"/news". The client now maintains a whitelist of valid
homepage DOM IDs and a fallback map (news→/news, heritage→/heritage,
parts→/parts). Any section value not in the homepage whitelist gets
auto-resolved to the correct page route via router.push.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-07 08:49:31 -05:00
davidherran 8941d1a2c3 feat(ai): FluxAI Level 2 — smart recommender, funnel-aware SPIN, contextual quick-replies
Deploy to VPS / deploy (push) Has been cancelled
Three improvements to the FluxAI sales intelligence:

1. New `recommend_application` tool: analyzes prospect's industry,
   problem, and process keywords against the database to rank-match
   the best FLUX products with confidence scores. Bridges the gap
   between "I have a problem" and "here's our solution."

2. Funnel-aware system prompt: replaces flat SPIN with 4-stage
   pipeline (Qualify → Recommend+Educate → Quantify+Prove →
   Specify+Convert) with clear rules for when to ask vs. act.

3. Contextual quick-reply buttons: after each AI response, dynamic
   suggestions appear based on which tools were used — guiding the
   prospect through the natural next step in the funnel.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-07 08:36:30 -05:00
davidherran 95132476ae feat(ai): extend FluxAI navigation with cross-page routing
The navigate_to_section tool now supports two modes:
A) Same-page scroll — scrollIntoView to real homepage DOM IDs
   (technology, applications-dashboard, applications-deep, global,
   our-story, legacy)
B) Cross-page routing — router.push to /applications/{slug},
   /news, /heritage, /parts with automatic locale prefix.

Fixed: system prompt listed phantom section IDs (hero, news,
heritage, timeline, parts-catalog, contact) that don't exist in
the DOM — causing all non-homepage navigations to silently fail.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-07 07:58:05 -05:00
davidherran c45a5be99e feat(i18n): translate hardcoded article page strings to 5 locales
Deploy to VPS / deploy (push) Has been cancelled
- Add ArticlePage namespace (backToNewsHub, backToNews, mediaGallery,
  joinLinkedIn, internalRelease) to all 5 locale message files
  (en, it, es, de, vec)
- Replace 5 hardcoded English strings in news/[slug]/page.tsx with
  getTranslations() calls

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-06 18:14:46 -05:00
davidherran cb7458cded feat(seo): visual breadcrumb navigation on article + application pages
- Create Breadcrumbs.tsx server component — semantic <nav> + <ol>/<li>
  with aria-current, ChevronRight separators, Apple-clean styling
- Add breadcrumbs to news article hero overlay (reuses JSON-LD crumbs)
- Add breadcrumbs to application detail hero (passed as prop to client
  component)
- Refactor breadcrumb data into shared array for JSON-LD + visual nav

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-06 18:10:49 -05:00
davidherran 7ec99734c5 feat(seo): LocalBusiness + CollectionPage structured data schemas
- Add localBusinessSchema() with geo coords, phone, opening hours for
  Google Local Pack and Knowledge Panel visibility
- Add collectionPageSchema() with ItemList for article listing pages
- Inject LocalBusiness alongside Organization+WebSite in root layout
- Inject CollectionPage in /news hub page with article items

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-06 18:06:12 -05:00
davidherran 8d80cbbc27 perf(seo): image sizes, semantic HTML, X-Robots-Tag headers
- Add `sizes` prop to 8 <Image> components across news, heritage, and
  application pages — tells the browser which srcset variant to download,
  improving LCP and reducing bandwidth
- Replace date <span> with <time dateTime={ISO}> on news pages —
  Google uses datetime for article freshness signals
- Wrap news cards and article content in <article> tags — semantic
  boundary for crawlers
- Add X-Robots-Tag: noindex, nofollow header to all /hq-command
  responses in proxy.ts — defense-in-depth alongside meta robots

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-06 18:04:40 -05:00
davidherran 6b9a94490b fix: add dedicated square favicon for HQ Command Center
Deploy to VPS / deploy (push) Has been cancelled
Programmatic icon.tsx using Next.js convention — generates a 32×32 PNG
with dark bg + cyan "F" glyph.  Replaces the inherited 16:9 fallback
that appeared stretched in browser tabs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-06 17:30:32 -05:00
davidherran c3d196df03 feat: auto-optimize images on CMS upload via AssetBucketBrowser
Deploy to VPS / deploy (push) Has been cancelled
Enables the existing Sharp pipeline for all uploads — WebP conversion,
auto-orient, 2560px cap, content-hash filenames.  Upload toast now
shows compression savings percentage.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-06 15:21:08 -05:00
davidherran 8ac372125a feat: dedicated 3D Models bucket in AssetBucketBrowser
Deploy to VPS / deploy (push) Has been cancelled
Add a "3D Models" bucket (path: models, accept: .glb/.gltf/.usdz)
to the cases scope. Previously 3D files were mixed into the Media
bucket at the root. Now they have their own tab with proper file
type hints and purple accent color matching the AR viewer UI.

- cases/media bucket no longer lists .glb/.gltf/.usdz in accept
- New bucketHint for models bucket warns on non-3D file uploads
- Network page 3D tab updated with purple accent + "3D Models"
  button label pointing to the dedicated bucket

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-06 15:15:44 -05:00
davidherran f6c3b89e08 feat: dashboard operations intelligence — analytics cards + signal breakdown
Deploy to VPS / deploy (push) Has been cancelled
Add real-time operational metrics to the HQ dashboard:

- Signals This Month: count with trend % vs previous 30 days
- Pending Actions: highlights when tickets need attention (rose border)
- Email Delivery: success rate percentage + sent/failed counts
- B2B Clients: approved vs total registered

Plus a Signal Breakdown panel showing all-time distribution by type
(Orders, Diagnostics, Consultations, B2B Access) with proportional
bars. All queries run in parallel via Promise.all for minimal latency.
Wrapped in try/catch so the dashboard never breaks if DB is slow.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-06 15:02:06 -05:00
davidherran 9c1e0cce01 feat: inbox UX polish — auto-save routing, search filter, email badges
Deploy to VPS / deploy (push) Has been cancelled
Three improvements to the Operations Inbox:

1. Auto-save email routing: typing in the routing config auto-saves
   after 800ms with inline "Saving…" / "✓ Saved" indicator. No more
   manual Save button per route.

2. Search filter: instant client-side search across name, company,
   email, and ticket ID. Composes with existing type/status filters.

3. No-email badge: signals without email delivery now show a dim
   MailQuestion icon in the list view instead of nothing, making it
   clear at a glance which tickets need attention.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-06 14:52:57 -05:00
davidherran 7502c9c674 feat: generateStaticParams for application + news slug pages
Deploy to VPS / deploy (push) Has been cancelled
Pre-render all known slugs at build time so first visits are instant
from cache. New slugs added after deploy render on-demand and get
cached by ISR (revalidate=60). try/catch ensures the build never
fails if the DB is unreachable during docker build — pages just
fall back to on-demand rendering.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-06 14:39:57 -05:00
davidherran ce8a13d7f8 fix: restore ISR on public pages — isolate DYNAMIC_SERVER_USAGE root cause
Deploy to VPS / deploy (push) Has been cancelled
Root cause: next-intl's getMessages/getTranslations internally resolves
requestLocale by reading cookies/headers, which trips DYNAMIC_SERVER_USAGE
under ISR. Fixed by calling setRequestLocale(locale) in layout + every
public page — caches the locale in React cache so next-intl never reads
cookies.

Changes:
- [locale]/layout.tsx: +setRequestLocale, +generateStaticParams (5 locales),
  wrap NavigationManager in <Suspense> (uses useSearchParams)
- 5 public pages: force-dynamic → revalidate=60, +setRequestLocale
- HQ dashboard pages: unchanged (still force-dynamic for auth)

Build verified: home/heritage/news pre-render as SSG with 1m revalidation,
slug pages render on-demand with ISR cache. Nginx s-maxage=60 remains as
safety net. Zero DYNAMIC_SERVER_USAGE errors.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-06 14:06:20 -05:00
davidherran 59a146ef10 feat: HQ-wide Toast + Confirm — no more browser alert()/confirm() popups
Deploy to VPS / deploy (push) Has been cancelled
The Inbox panel got the polished Toast + Confirm primitives a few
commits back. This commit propagates them across every other panel in
HQ Command so the editor experience is uniformly on-brand. No more
1990s browser dialogs interrupting the dark CMS look.

NINE PANELS, ELEVEN CALL SITES UPGRADED
- health/page.tsx       — DB export errors → toast
- network/page.tsx      — Delete deployment → confirm + toast
- heritage/page.tsx     — Delete section → confirm + toast
- users/page.tsx        — Revoke architect → confirm + error toast
- news/page.tsx         — Delete article → confirm + toast
- applications/page.tsx — Save error toast + delete-app confirm
- parts/page.tsx        — Delete component → confirm + toast
- hero/page.tsx         — Delete slide → confirm + toast
- timeline/page.tsx     — Delete milestone → confirm + toast

Each destructive confirm now spells out what it does ('Permanently
remove this case from the global map. The asset folder on disk is
kept for safety') instead of a generic 'Delete?' prompt — much
clearer for editors who aren't sure whether files get nuked too.

Each success toast names the action ('Component deleted', 'Slide
deleted', 'Architect access revoked') so the editor sees exactly
what fired. Errors come in as red toasts with the actual error text.

NO BACKEND CHANGES. Pure UX layer on top of existing actions.
The HqUiProvider was already mounted in src/app/hq-command/layout.tsx,
so wiring up was just useHqUi() per page + the replacement calls.
2026-05-05 21:16:02 -05:00
davidherran e177bca92f feat: HQ Hero — import existing /footage/main files as managed slides
Deploy to VPS / deploy (push) Has been cancelled
The hero CMS only listed HeroSlide rows from the database. Files dropped
directly into /public/footage/main were rendering on the live site (via
the home page's filesystem fallback) but invisible in the editor — so
the editor couldn't manage their focal point, alt text, ordering or
on/off toggle without re-uploading.

NEW SERVER ACTIONS (src/app/hq-command/dashboard/hero/actions.ts)
- listImportableFootage()
  Scans /public/footage/main, returns the list of files that aren't
  already referenced by a HeroSlide row. Each entry has filename,
  publicUrl, mediaType (image/video), file size and mtime.
- importFootageFiles(filenames[])
  For each selected filename: validates extension, checks existence,
  skips already-imported files, derives a sensible alt text (filename
  with extension stripped, dashes/underscores → spaces, leading
  numeric prefix removed), and creates a HeroSlide row at the next
  available order position. Returns {created, skipped} so the UI can
  show a precise toast.

UI (src/app/hq-command/dashboard/hero/page.tsx)
- New amber-tinted panel above the slide list, visible only when
  importable.length > 0. Shows every uncovered file as a thumbnail
  card with checkbox-style selection.
- 'Select all' / 'Clear' toggle, 'Import all' for one-click bulk
  import, 'Import N selected' once a subset is picked.
- After import: panel re-renders empty (since those files now have
  HeroSlide rows) and the new slides appear in the regular list,
  ready for focal-point and caption editing.

NO BACKEND-DATA CHANGES BEYOND ROW CREATION
- Filesystem untouched (no rename, no move). Files keep their original
  path under /public/footage/main, the new HeroSlide row simply points
  at /footage/main/<filename>.
- Public-facing pages render the same images either way (they read
  from HeroSlide first, footage scan second).
- After import: the home page now reads the slides from the DB and
  uses the focal-point + alt text data the editor sets.
2026-05-05 20:05:29 -05:00
davidherran 778b35f15a feat: AssetBucketBrowser polish — bulk select, drag-move, rename in place
Deploy to VPS / deploy (push) Has been cancelled
The unified bucket browser graduated from "acceptable" to "actually
useful for bulk work". Editors can now manage dozens of files in a
single session without dragging each one through a modal.

NEW FEATURES (frontend)

1. BULK SELECTION
   - Click on a file when nothing's selected → opens it as before.
   - Click on the corner checkbox, or click the file once selection is
     active → toggle that one in/out.
   - Shift-click → range select between last anchor and current item.
   - Cmd/Ctrl-click → toggle without affecting others.
   - "Select all" toggle in the toolbar respects the search filter.

2. BULK ACTIONS TOOLBAR
   When at least one file is selected the toolbar morphs into:
     [N selected] [Delete] [Move to: Videos | Renders | …]
   Delete fires the new bulk DELETE endpoint with filePaths[], shows
   a single toast for the whole batch + per-file failure breakdown.
   Move iterates PATCH /api/assets per file (sequential, with a 'Moving…'
   indicator in the bucket helper bar).

3. DRAG TO MOVE BETWEEN BUCKETS
   Drag any file (or the whole selection if you started the drag from
   a selected file) onto another bucket tab. The tab highlights green
   with 'drop to move' while you hover. Drop fires the same per-file
   PATCH flow. No dialog, no friction.

4. RENAME IN PLACE
   Double-click a filename (in either grid or list view) → input opens
   in place. Enter saves, Escape cancels, blur saves. Sanitizes to
   safe characters. PATCH endpoint refuses to overwrite an existing
   file (returns 409, surfaced as a toast).

5. KEYBOARD HINT FOOTER
   Bottom-of-modal cheat sheet: Click / ⇧Click / ⌘Click / 2× click /
   drag onto another tab. So new editors don't have to discover the
   power-user features.

NEW BACKEND (src/app/api/assets/route.ts)

PATCH method
   { scope, slug, fromPath, toPath } → fs.renameSync.
   Used for both rename (same dir, new name) and move (different bucket).
   Refuses to overwrite an existing destination (409 conflict).
   Creates intermediate folders if needed.

DELETE extended
   Now accepts either { filePath: "x" } or { filePaths: ["a", "b"] }.
   Bulk path deletes one-by-one and returns per-file success/failure
   so the UI can show a precise toast.

REVIEWED FOR REGRESSIONS
- Single-file API still works — old { filePath } DELETE shape preserved.
- The 4 inline AssetManager call sites (network, news, applications,
  parts) use AssetBucketBrowser via the alias added in the previous
  commit; their integration is unchanged. Same props, same onSelect
  callback shape.
- Toast/Confirm calls go through the existing HqUiProvider mounted in
  hq-command/layout.tsx — no extra wiring.
2026-05-05 19:40:06 -05:00
davidherran 330fecc3cc feat: favicon multi-variant — 1 upload generates 6 sizes + PWA manifest
Deploy to VPS / deploy (push) Has been cancelled
The single-favicon upload was leaving most platforms with the wrong icon:
Android Chrome looks for 192×192, iOS for 180×180, Windows for 48×48,
HiDPI tabs for 32×32, legacy tabs for 16×16, PWA splash for 512×512 —
six different files, all from the same source image.

ONE EDITOR ACTION → SIX FILES
- POST /api/branding/favicon takes a single PNG/JPG/WebP (≥ 192×192,
  ideally 512×512+) and runs it through sharp to produce:
    /branding/favicon-16.png    /branding/favicon-32.png
    /branding/favicon-48.png    /branding/favicon-180.png
    /branding/favicon-192.png   /branding/favicon-512.png
    /branding/favicon-master.png  (kept for re-generation)
- Square-ish source images get center-cropped automatically.
- Returns the variant list + soft warnings ("not square — center-cropped",
  "upload at least 512×512 for retina") so the editor sees what happened.
- Validates dimensions and file type, caps at 20MB.

ROOT LAYOUT (src/app/[locale]/layout.tsx)
- Detects whether the multi-variant set exists on disk. If yes, emits
  <link rel="icon" sizes="16x16 / 32x32 / 192x192">, an explicit
  Apple touch link, and a Safari pinned-tab mask-icon. If no, falls
  back to the legacy single faviconUrl from SiteSetting.
- Adds <link rel="manifest" href="/manifest.webmanifest"> so Android
  Chrome surfaces "Add to Home Screen" properly.

PWA MANIFEST (src/app/manifest.ts)
- Auto-served at /manifest.webmanifest via Next.js's MetadataRoute.
- Pulls themeColor from SiteSetting.branding so editor changes there
  cascade to the standalone-app theme.
- Lists 192 + 512 icons with both 'any' and 'maskable' purposes for
  Android adaptive icons.

HQ SETTINGS UX (src/app/hq-command/dashboard/settings/page.tsx)
- New <FaviconMasterField/> at the top of the Branding tab — replaces
  the old standalone Favicon ImageField. Shows a 32×32 preview, an
  upload button, the generation result with each variant's preview,
  and warnings when the source isn't ideal.
- The Apple Touch Icon ImageField below it stays as an override path
  for editors who want a different icon on iOS (rare, but supported).
- Cache buster query string on previews so re-uploads visibly refresh
  without forcing the editor to hard-reload.

NO BACKEND/DB CHANGES OUTSIDE FILE I/O.
The SiteSetting.branding row is unchanged. The favicon variants are
plain files on disk under public/branding/, served by Nginx with the
existing 5-min cache headers.
2026-05-05 19:07:16 -05:00
davidherran ea1300bfdc feat: HQ Toast + Confirm dialog primitives, replace browser alert()/confirm()
Deploy to VPS / deploy (push) Has been cancelled
The Operations Inbox panel was using browser-native alert() and
confirm() — those popups break out of the dark-themed CMS aesthetic
and look like a 1998 form validation. Worse, they're modal blocking,
so the editor can't see the surrounding context (the ticket card,
the activity feed) while the dialog is up.

NEW PRIMITIVES (src/components/hq/Toast.tsx)
- <HqUiProvider> mounts a global toast stack (bottom-right) and a
  global confirm dialog. Mounted in src/app/hq-command/layout.tsx so
  every panel under /hq-command/* can use it.
- useHqUi() returns { toast, confirm }:
    ui.toast("Saved", "success")        // ephemeral, 3s
    ui.toast("Save failed: ...", "error")  // 5s
    await ui.confirm({                     // returns boolean
      title: "Delete ticket",
      message: "This permanently...",
      confirmLabel: "Delete",
      destructive: true,
    })
- Toasts auto-dismiss with a manual close button. Confirm dialog
  uses red accent for destructive actions.
- Zero deps. ~140 lines total.

INBOX (src/app/hq-command/dashboard/inbox/page.tsx)
- All 5 alert() calls replaced with ui.toast() — success / error
  toned and persisting just long enough to read.
- All 4 confirm() calls replaced with ui.confirm() — destructive
  ones (delete ticket, purge files, delete client) get the red
  accent + 'cannot be undone' copy.
- Action descriptions are richer ('Resolve & purge attachments'
  instead of 'Resolve') so the editor knows exactly what fires.

NO BACKEND CHANGES. Pure UX layer on top of the existing actions.
2026-05-05 19:02:41 -05:00
davidherran aebcabd767 fix: nginx cache ignores Set-Cookie + adds explicit valid 60s
Deploy to VPS / deploy (push) Has been cancelled
The HTTP cache was always reporting MISS on /en even on consecutive
hits. Two reasons converging:

1) next-intl writes a NEXT_LOCALE cookie on response, so every
   upstream reply included a Set-Cookie header. Nginx refuses to
   cache responses with Set-Cookie by default — that's a safe
   default to avoid leaking session cookies, but it's the wrong
   default for our public marketing pages, where the cookie just
   records a locale preference and the HTML body is identical for
   every visitor on the same URL.

2) proxy_cache_valid wasn't set, so even when Cache-Control would
   have authorised caching, Nginx fell back to its conservative
   no-cache stance.

Fix:
- proxy_ignore_headers Set-Cookie X-Accel-Expires Expires;
- proxy_hide_header Set-Cookie;
- proxy_cache_valid 200 60s;

Net result: marketing pages now actually cache. The Set-Cookie is
still emitted by Next.js (the upstream is unchanged), Nginx just
strips it before storing/relaying — locale detection still works
because next-intl persists locale through the URL prefix anyway.

DEPLOY (David)
  cd /opt/flux-srl
  git pull
  docker compose restart nginx
  docker compose exec nginx nginx -s reload

Then verify:
  curl -sI https://rf-flux.com/en | grep -iE 'x-cache|cache-control|set-cookie'
  curl -sI https://rf-flux.com/en | grep -iE 'x-cache|cache-control|set-cookie'

Second hit should show: x-cache-status: HIT
2026-05-05 13:48:43 -05:00
davidherran 1a4abfc7f2 nginx: include lethepowerflux.com → rf-flux.com 301 redirect
Deploy to VPS / deploy (push) Has been cancelled
The VPS already had a server block redirecting lethepowerflux.com and
www.lethepowerflux.com to https://www.rf-flux.com, but it lived only on
the live config — not in git. That's why the latest pull complained
about local changes that would be overwritten.

Adding it here so the repo is the single source of truth for the Nginx
config again. Behaviour is unchanged on the VPS (redirect was already
in place) — this commit just lets future git pulls flow without
manual intervention.
2026-05-05 13:17:34 -05:00
davidherran abd75798ef feat: timeline + heritage HQ panels — drag-drop + inline auto-save
Deploy to VPS / deploy (push) Has been cancelled
Bringing the same UX pattern we shipped on Hero and Settings to the
Company Legacy and Our Heritage panels. No more modal-driven editing
where you have to click "Edit" → modify → click "Save" → wait → close
modal. Every field is now editable in place; saves fire on blur and
flash a "Saved ✓" confirmation; rows reorder via drag-drop.

NEW SERVER ACTIONS (additive — old formData-style actions still exist)
- timeline/actions.ts:
    patchTimelineEvent(id, partial)        — granular field update
    reorderTimelineEvents(ids[])           — single transaction
    createTimelineStub()                   — instant blank row
- heritage/actions.ts:
    patchHeritageSection(id, partial)      — granular field update
    reorderHeritageSections(ids[])         — single transaction
    createHeritageStub(type)               — text/image/video stubs

PAGES REWRITTEN
- timeline/page.tsx: 208 → 215 lines, but radically simpler — drag
  handle + inline year input + inline title + inline description
  textarea per row, plus eye toggle for isActive and a trash button.
  Global "Auto-translate edits" checkbox at top applies to every patch.
- heritage/page.tsx: 209 → 270 lines (more functionality fits in less
  cognitive load). Three coloured "Add" buttons (Text / Image / Video)
  spawn a stub that renders the right card type. Image/Video cards have
  a built-in MediaPicker with upload-or-paste-filename + live preview.

UX UPGRADES THE EDITOR SEES
- Type-and-tab to save. No modals, no submit buttons per row.
- Saving / Saved ✓ badge per row so it's obvious when a field has
  hit the database.
- Drag handle + drop target on every card → reordering becomes
  "grab and pull", same metaphor as Hero slides.
- AI translation is one toggle at the top, not a per-modal switch.
- Soft-warning empty state with a clear "do this first" hint instead
  of a blank page.

NO BREAKING CHANGES
- Old createTimelineEvent / updateTimelineEvent / createHeritageSection
  / updateHeritageSection actions are kept (in case anything still
  imports them — nothing in the repo does, but external scripts
  might). Internal call sites all use the new patch flow.
- Database schema unchanged.
- Public-facing /heritage page unchanged.
2026-05-05 12:28:57 -05:00
davidherran 7fe5108f66 feat: HTTP shared cache for public marketing pages
Deploy to VPS / deploy (push) Has been cancelled
Pages got fast again. Public marketing routes are still rendered
per-request by Next.js (force-dynamic, until the ISR bug gets isolated),
but their HTML is now cached at the Nginx layer for 60s with a 5-minute
stale-while-revalidate window. Result: only the first hit on a URL
inside a 60s window pays the SSR cost; every other visitor in that
window gets a sub-10ms cached response. While a cached entry is
revalidating, peers keep getting the stale copy — no cold starts, no
thundering herds.

NEXT.JS MIDDLEWARE (src/proxy.ts)
- isCacheablePublicPath() identifies routes safe to share-cache:
  /, /<locale>, /<locale>/applications, /<locale>/news,
  /<locale>/heritage. Excludes /<locale>/parts (auth-gated B2B portal)
  and /hq-command/*, /api/*, /_next/*.
- hasAuthCookie() short-circuits caching when the request carries a
  flux_session (admin CMS) or flux_b2b_session (client portal) cookie.
  Authenticated users always get a fresh per-account render.
- When both checks pass, the response gets:
    Cache-Control: public, s-maxage=60, stale-while-revalidate=300

NGINX (nginx/nginx.conf)
- New shared zone:
    proxy_cache_path /var/cache/nginx/flux levels=1:2
                     keys_zone=flux_html:50m max_size=1g inactive=24h
                     use_temp_path=off;
- Access log gets a `cache=$upstream_cache_status` field so we can
  audit hit/miss ratios in the live logs.

NGINX (nginx/conf.d/flux.conf — location /)
- proxy_cache flux_html + proxy_cache_revalidate on
- proxy_cache_use_stale: serves stale on backend errors / timeout /
  during update, so 502s during a Next.js restart never reach users.
- proxy_cache_background_update + proxy_cache_lock: only one upstream
  request fires when a cached entry expires; others keep getting stale.
- proxy_cache_bypass / proxy_no_cache wired to flux_session +
  flux_b2b_session cookies — admin and B2B traffic skips the shared
  cache entirely.
- X-Cache-Status response header (HIT/MISS/EXPIRED/STALE/UPDATING/BYPASS)
  for live debugging — open dev tools, refresh, watch the value flip.

WHAT YOU'LL FEEL
- First visitor on /en within a 60s window: ~150-300ms (SSR + DB).
- Second through Nth visitors in the same window: <10ms.
- Editor publishes a change in HQ Command → revalidatePath() inside
  the existing actions invalidates the Next.js cache; the next
  marketing-page request rebuilds and primes Nginx fresh. The 60s
  TTL bounds how long stale content can linger if revalidation is
  ever skipped.

NO BREAKING CHANGES
- Auth flows untouched (cookies bypass cache).
- HQ Command + API endpoints untouched (separate Nginx locations).
- Static assets (cases/, applications/, /branding/, /_next/static)
  unaffected — they had their own cache headers already.
- Server-side cache invalidation via revalidatePath() still works.

DEPLOY (David)
  cd /opt/flux-srl
  git pull
  docker compose up -d --build app
  docker compose exec nginx nginx -t
  docker compose exec nginx nginx -s reload
2026-05-05 12:20:39 -05:00
davidherran fece168486 fix: FluxAI image paths now include the node slug
Deploy to VPS / deploy (push) Has been cancelled
The two AI tool cards rendered inside the chat — CaseStudyViewer (the
"Show me proven installations" card) and EquipmentConfigurator (the
"Show equipment specs" card) — were composing image URLs without the
node-slug segment:

  /cases/<filename>            ← what the cards were emitting (404)
  /cases/<nodeSlug>/<filename> ← what the public site actually serves

Result: cover images and gallery thumbnails inside chat cards came back
as broken-image icons, while the same files rendered fine on
/en/applications/<slug> and inside the CaseStudyModal (those
already used the correct path).

Fix: both components now derive the slug from data.title with the same
nodeToSlug() the public pages use, and prefix it on every /cases/
URL — cover and gallery thumbnails alike.

CHANGES (2 files)
- src/components/ai/CaseStudyViewer.tsx
  - Added local nodeToSlug() helper (mirrors ApplicationClient + assetFolders)
  - coverSrc:        /cases/${nodeSlug}/${mediaFileName}
  - gallery image:   /cases/${nodeSlug}/${img}

- src/components/ai/EquipmentConfigurator.tsx
  - Added local nodeToSlug() helper
  - coverSrc:        /cases/${nodeSlug}/${mediaFileName}

No backend / API / DB changes. Pure client-side path correction.
2026-05-05 09:25:45 -05:00
davidherran 014a9eb094 refactor: unified AssetBucketBrowser replaces 4 inline AssetManagers
Deploy to VPS / deploy (push) Has been cancelled
Every HQ Command panel had its own ~210-line AssetManager component
copy-pasted into the page file. Same UI, same API, four diverging
implementations — and no consistent metaphor for "where does this file
go?". Editors had to think about subfolder names (videos/, renders/)
that the front-end implicitly expects.

ONE COMPONENT. CLEAR BUCKETS. SAME PATHS.

src/components/hq/AssetBucketBrowser.tsx — the single picker. Takes
scope + slug, shows bucket tabs (Media / Videos / Renders / etc.) and
maps each to the on-disk path the public site already reads from:

  cases:        Media (root) | Videos (/videos) | Renders (/renders)
  applications: Media (root) | Videos (/videos) | Renders (/renders)
  news:         Media (root)
  parts:        Media (root) | Renders (/renders)
  footage:      Hero Reel (root)
  branding:     Brand Assets (root)

Drop a file into the Videos tab → POSTs to /api/assets with path=videos
→ lands at /public/{scope}/{slug}/videos/<file> — exactly where
ApplicationClient.tsx and CaseStudyModal.tsx already look. Zero
front-end path changes, zero data migration.

UX upgrades the editor sees:
- Tabs make the bucket layout discoverable instead of buried in folder
  navigation. Each tab has its own description and accent colour.
- Soft-warning hints flag obvious mismatches ("Videos bucket usually
  holds .mp4 — this file may not display correctly") without blocking
  the upload.
- Search + grid/list views.
- Hover actions per file: copy URL, delete (with confirm).
- Persistent on-screen path (/{scope}/{slug}/{bucket}) so editors can
  always see the canonical location.

REPLACEMENT (4 page files)
- network/page.tsx:      719 → 513 lines (-206) — direct alias
- news/page.tsx:         484 → 313 lines (-171) — direct alias
- applications/page.tsx: 555 → 433 lines (-122) — adapter wraps the
  picker's onSelect into the markdown-syntax onInsert callback this
  panel uses. No call-site changes.
- parts/page.tsx:        413 → 320 lines  (-93) — direct alias

Net: -592 lines of duplicated UI, +560 lines of single shared component.
Future bucket-layout changes live in one file instead of four.

NO PATH/API CHANGES — /api/assets is unchanged. The on-disk layout is
unchanged. Existing assets keep rendering on the public site. Existing
DB rows (mediaFileName, galleryJson, videosJson, rendersJson) are
unaffected because we never moved files.
2026-05-05 08:55:20 -05:00
davidherran 9b28f8ffaf fix: nextjs primary group + auto-create asset folders on entity create
Deploy to VPS / deploy (push) Has been cancelled
THREE INTERLOCKING FIXES so editors stop hitting permission walls.

1) DOCKERFILE — gid 65533 (nogroup) on uploaded files
The container was creating files as 1001:65533 because Alpine's
`adduser --system --uid 1001 nextjs` doesn't set a primary group.
Files written through /api/assets ended up with `nogroup` ownership,
which surprised host sysadmins and made `chown -R 1001:1001` revert
on each fresh container start.

Fix: `adduser --system --uid 1001 --ingroup nodejs nextjs`. Now
every file written by the container is 1001:1001 (nextjs:nodejs),
matching the host conventions and the existing chown automation.

2) ENTRYPOINT — recursively normalise existing files
The recursive chown in scripts/docker-entrypoint.sh now sweeps every
subfolder of /app/public/branding|footage|applications|cases|news|
parts|operations-inbox|heritage on each container start, fixing any
files that previously slipped through with the wrong group. Single
fast pass, idempotent. Adds /app/public/heritage to the list (was
missing).

3) AUTO-CREATE ASSET BUCKETS on entity create
The big editor UX win: when an admin creates a Case (GlobalNode), an
Application or a News article in HQ Command, the server now also
mkdir's the well-known asset subfolders for that entity. So after
creating "Acme Industries" as a case, the editor immediately gets
/public/cases/acme-industries/{videos,renders,gallery,datasheet,models}
ready — no more "EACCES because the dir wasn't created" gotcha
when they upload their first video.

Implementation:
- src/lib/assetFolders.ts: typed helper with per-scope bucket lists
  + a titleToSlug helper that mirrors the front-end's slugger so the
  folder name matches what ApplicationClient expects when rendering
  /cases/<slug>/videos/<file>.
- network/actions.ts: createNode -> ensureAssetFolders("cases", slug).
  Plus a new server action ensureNodeAssetFolders(id) so the editor
  can fix existing nodes without recreating them (one-click "Repair").
- news/actions.ts: createNewsArticle -> ensureAssetFolders("news",slug)
- applications/actions.ts: createApplication -> ensureAssetFolders(...)

DEPLOY (David)
  cd /opt/flux-srl
  git pull
  docker compose up -d --build app
  # The entrypoint will fix existing 1001:65533 files automatically
  # as the container boots — no manual chown needed.
2026-05-05 08:01:45 -05:00
davidherran aa95be45d0 fix: stop ignoring scripts/ in .dockerignore
Deploy to VPS / deploy (push) Has been cancelled
The new docker-entrypoint.sh lives in scripts/, but .dockerignore was
excluding the whole folder from the build context — so the COPY in the
Dockerfile resolved to a missing file:

  COPY scripts/docker-entrypoint.sh: not found

Removing the `scripts/` line lets the build context include it. The
other ignored paths stay (node_modules, .next, .git, env files,
Dockerfile, docker-compose, nginx, certbot, prisma dev.db) — those
genuinely don't need to be sent to the daemon.
2026-05-04 18:29:18 -05:00
davidherran ba002ea9e6 fix: auto-chown mounted volumes + metadataBase warning
Deploy to VPS / deploy (push) Has been cancelled
THREE FIXES IN ONE SHOT.

1. UPLOAD EACCES (the crashing one)
The /app/public/branding upload was failing with EACCES because the
folder on the host was created by `debian` (uid 1000) but the container
runs as nextjs (uid 1001). Docker bind mounts preserve host ownership,
so the container couldn't write into branding/.

Fix: introduce a docker-entrypoint.sh that runs the container briefly
as root, chowns every public/* mount to uid 1001, runs Prisma migrate
deploy, then drops to nextjs via `su-exec`. From now on every deploy
self-heals permissions across all asset folders (branding, footage,
applications, cases, news, parts, operations-inbox) — even if a future
volume gets added with the wrong owner.

Dockerfile changes:
- Adds `su-exec` package (lightweight gosu equivalent for Alpine)
- Removes the static USER directive (entrypoint manages user transitions)
- Replaces CMD with an ENTRYPOINT pointing at the new script

2. metadataBase WARNING
Server logs were emitting:
  ⚠ metadataBase property in metadata export is not set ... using "http://localhost:3000"
That's the layout's generateMetadata not declaring metadataBase, so
Next.js couldn't resolve relative OG/Twitter image URLs to absolute
ones. Reading NEXT_PUBLIC_APP_URL (already set in docker-compose env)
and feeding it as `metadataBase: new URL(...)` silences the warning
and produces correct absolute URLs in social previews.

3. PERMISSIONS DOCS
The entrypoint chown is idempotent and silent on non-existent folders,
so future volumes added to docker-compose just work. No more "did you
sudo chown the new folder" gotchas.

DEPLOY (David)
  cd /opt/flux-srl
  # one-time fix for the existing branding folder so the next deploy
  # doesn't have to chown 65MB of data — but the entrypoint now handles
  # this automatically anyway:
  sudo chown -R 1001:1001 /opt/flux-srl/public/branding
  git pull
  docker compose up -d --build app
2026-05-04 18:17:39 -05:00
davidherran 1f4a95cc47 fix: revert ISR to force-dynamic + drop generateStaticParams (DYNAMIC_SERVER_USAGE)
Deploy to VPS / deploy (push) Has been cancelled
The DYNAMIC_SERVER_USAGE errors persisted even after passing locale
explicitly to next-intl. Some other Server Component in the tree is
still triggering an implicit dynamic API read under ISR — and chasing
it across next-intl, Prisma, the @ai-sdk libs, and the standalone
build was eating the deploy. Pragmatic call: stop trying to keep ISR
while we still have unstable bug surface, take the runtime back to
puro SSR (the working state from before the SEO commit), then bring
ISR back surgically once the site is stable.

CHANGES (5 page.tsx files)
- /[locale]/page.tsx                         revalidate=60 → dynamic="force-dynamic"
- /[locale]/news/page.tsx                    revalidate=60 → dynamic="force-dynamic"
- /[locale]/news/[slug]/page.tsx             revalidate=60 → dynamic="force-dynamic"
- /[locale]/heritage/page.tsx                revalidate=60 → dynamic="force-dynamic"
- /[locale]/applications/[slug]/page.tsx     revalidate=60 → dynamic="force-dynamic"

ALSO: removed generateStaticParams from news/[slug] and applications/[slug].
With it present (even returning [] in prod), Next.js still classified
those routes as SSG-eligible, which conflicted with the force-dynamic
flag and kept the ISR/dynamic boundary ambiguous. Removing it makes
the build output show all locale routes as ƒ (Dynamic) — pure SSR.

WHAT WE KEEP
- generateMetadata still runs per request, so all SEO benefits (canonical
  URLs, hreflang, OG tags, Twitter cards) remain.
- sitemap.xml and robots.txt are unaffected.
- JSON-LD still emits.
- revalidatePath() in /api/assets still works (just becomes a no-op for
  these pages since they're already dynamic — no cache to invalidate).
- Caching at the Nginx layer (max-age=300 + must-revalidate on /_next/image
  and /branding|/cases|/applications|/news|/parts|/footage) is unchanged,
  so static asset performance stays optimal.

WHAT WE LOSE TEMPORARILY
- Page HTML is generated on every request instead of every 60 seconds.
  At Flux's traffic levels this is negligible — Prisma queries are sub-50ms
  and Postgres has connection pooling. We'll move back to ISR once we've
  isolated the offending dynamic read.

DEPLOY (David — IMPORTANT, force a real rebuild this time)
  cd /opt/flux-srl
  git pull
  docker compose build --no-cache app
  docker compose up -d app
  docker compose logs app --tail=30
2026-05-04 17:38:59 -05:00
davidherran e879016879 fix: pass locale explicitly to next-intl helpers (DYNAMIC_SERVER_USAGE)
Deploy to VPS / deploy (push) Has been cancelled
Production logs were spamming "DYNAMIC_SERVER_USAGE" errors and every
ISR-cached page (home, news, heritage, applications, news/[slug]) was
500-ing. Root cause: when force-dynamic was replaced with revalidate=60
last commit batch, the implicit locale-resolution path through
next-intl's getTranslations() / getLocale() / getMessages() became a
problem. Without an explicit locale arg, next-intl reads cookies()
under the hood — which trips Next.js's "dynamic API used inside a
static / ISR render" guard and aborts with DYNAMIC_SERVER_USAGE.

Fix: pass the locale (already known from the URL via params) every
time we call next-intl on the server.

CHANGES
- src/components/layout/Footer.tsx
  - Now accepts `locale` as a prop instead of awaiting getLocale()
  - getTranslations({ locale, namespace: "Footer" })
- src/app/[locale]/layout.tsx
  - Passes locale prop to <Footer locale={locale} />
  - getMessages({ locale }) instead of getMessages()
- src/components/sections/PatrizioLegacy.tsx
  - Accepts `locale` prop, passes it to getTranslations
- src/app/[locale]/page.tsx
  - Renders <PatrizioLegacy locale={locale} />
- src/app/[locale]/news/page.tsx (body)
  - getTranslations({ locale, namespace: "NewsHub" })
- src/app/[locale]/heritage/page.tsx (body)
  - getTranslations({ locale, namespace: "HeritagePage" })

The generateMetadata / generateStaticParams paths in news/[slug] and
heritage already passed locale correctly — only the page bodies and
shared components were leaking dynamic reads.

Net effect: all five ISR pages render statically again, the error spam
in `docker compose logs app` clears, and /en/applications/digital-print
loads its server component without falling through to the error boundary.
2026-05-04 16:53:20 -05:00
davidherran 3d066fa67e fix: error boundaries + defensive try/catch on dynamic pages
Deploy to VPS / deploy (push) Has been cancelled
The /en/applications/digital-print page was still 500-ing after the
previous fixes. Without an error boundary, Next.js shows a generic
"Internal Server Error" with no detail — making remote diagnosis
require a `docker compose logs` round-trip every time.

ERROR BOUNDARIES (visible diagnostics)
- src/app/global-error.tsx: catches errors that bubble past every
  route's error.tsx, including ones from the root layout. Renders
  its own <html>/<body>.
- src/app/[locale]/error.tsx: locale-scoped boundary so the NavBar
  and Footer keep rendering around the error UI. Shows the actual
  error message + digest in a code block — much faster to diagnose
  than a blank 500.

DEFENSIVE WRAPPING (every async + every transform)
- applications/[slug]/page.tsx
  - getApplicationImages: try/catch around fs ops
  - generateMetadata: full body wrapped, falls back to safe defaults
  - getLocalizedData call wrapped (returns rawData if it throws)
  - Cases query already had try/catch — adds same for the locale map
  - JSON-LD build wrapped, falls back to empty array (still renders)
  - Default fallbacks for title/description/category to avoid
    productSchema receiving undefined fields
- news/[slug]/page.tsx
  - prisma.newsArticle.findUnique now has try/catch
  - getLocalizedData wrapped
  - JSON-LD build wrapped, only rendered if non-empty
  - publishedAt / updatedAt fallback to new Date() to avoid
    "Invalid time value" from articleSchema's date conversion

The combination means: if the underlying bug is in any of the SEO
helpers, JSON-LD generation, or i18n merging, the page now degrades
gracefully and shows the actual error in the UI instead of 500-ing.
2026-05-04 16:45:37 -05:00
davidherran 62506f10b4 fix: strip internal container port from redirect URLs
Deploy to VPS / deploy (push) Has been cancelled
The site was redirecting / -> https://rf-flux.com:3000/en, where :3000
is the container's internal port (only "expose"d, not published) — so
the browser saw ERR_CONNECTION_REFUSED.

Root cause: when running behind Nginx in standalone mode, Next.js (via
next-intl in this case) can build absolute redirect URLs that leak the
container's internal PORT/HOSTNAME env into the Location header.

TWO LAYERS OF DEFENCE
1. Nginx (nginx/conf.d/flux.conf)
   - Adds X-Forwarded-Host + X-Forwarded-Port so the upstream knows
     the public port (443) and host
   - proxy_redirect rewrites any Location header that still slips
     through with :3000 back to the public https://$host

2. Middleware (src/proxy.ts)
   - sanitizeRedirectLocation() runs after handleI18nRouting and
     scrubs Location headers that point at internal hostnames (app /
     localhost / 0.0.0.0) or the container port :3000, replacing them
     with the public host derived from x-forwarded-host / host header.

Either layer alone would fix the immediate symptom; together they
also prevent the same class of bug from showing up in any future
redirect path.
2026-05-04 16:32:45 -05:00
davidherran 5abd3a02f6 fix: ship full prod node_modules to runner so prisma migrate deploy works
Deploy to VPS / deploy (push) Has been cancelled
The previous container restart loop ("Error: Cannot find module 'effect'")
happened because the runner stage cherry-picked only specific Prisma
subdirs (.prisma, @prisma, prisma) but missed transitive runtime deps
of the Prisma CLI — like @prisma/config's dep on `effect`.

Cherry-picking is fragile: any minor Prisma upgrade changes the
required dep set and the container stops booting.

Real fix: introduce a dedicated prod-deps stage that runs
`npm ci --omit=dev --include=optional` and ship the resulting
node_modules wholesale to the runner. Trade-off: the runner image
grows by ~200-300MB, gaining bullet-proof prod CLI execution in
exchange. Subsequent rebuilds are fully cached after the first run.

What changed in Dockerfile:
- New stage `prod-deps` produces a prod-only node_modules tree
- Runner stage drops the explicit @prisma/prisma/sharp/@img copies
  (they're already in prod-deps' node_modules)
- Still copies prisma/, prisma.config.ts, .prisma generated client,
  and Next.js standalone artefacts
- CMD unchanged: migrate deploy + server.js
2026-05-04 16:22:47 -05:00
davidherran 320c0862df fix: pin all sharp platform binaries so npm ci works on any host
Deploy to VPS / deploy (push) Has been cancelled
The previous attempts (--include=optional, then a separate npm install
fallback) failed because npm ci runs sharp's install script DURING
installation — and that script crashes ("Please add node-gyp to your
dependencies") before the next Dockerfile step gets to run.

Real fix: pin every sharp platform binary as an optionalDependency in
package.json. npm now records URL+hash for all of them in the lock
file regardless of which OS generated the lock. On any build host,
npm ci picks the matching binary via the os/cpu/libc filters in those
packages and silently skips the rest.

Pinned binaries (sharp 0.34.5):
- @img/sharp-linuxmusl-x64   (Alpine x64 — our VPS)
- @img/sharp-linuxmusl-arm64 (Alpine arm64)
- @img/sharp-linux-x64       (glibc x64)
- @img/sharp-linux-arm64     (glibc arm64)
- @img/sharp-darwin-arm64    (Apple Silicon dev)
- @img/sharp-darwin-x64      (Intel Mac dev)

Side benefit: simplifies the Dockerfile. Drops the secondary
`npm install --no-save --cpu=x64 --os=linux --libc=musl sharp` step
and the vips-dev system package (no source compilation needed when
the prebuilt binary is guaranteed present). The runner stage still
needs `vips` runtime, that stays.
2026-05-04 15:52:22 -05:00
davidherran 4f75943317 fix: force sharp Alpine binary download in Docker build
Deploy to VPS / deploy (push) Has been cancelled
Sharp 0.34 ships a separate prebuilt binary per platform tuple as
optionalDependencies (@img/sharp-linuxmusl-x64 for Alpine, -darwin-arm64
for Apple Silicon, etc).

The lock file only records the dev machine's platform binary. `npm ci`
is strict — it installs exactly what's locked and refuses to add
platform-specific entries that weren't there at lock time. Result on
the VPS Alpine x64 build:

  sharp: Attempting to build from source via node-gyp
  sharp: Please add node-gyp to your dependencies

Fix: after the strict `npm ci`, run a separate `npm install --no-save
--cpu=x64 --os=linux --libc=musl sharp`. This downloads the Alpine
binary into node_modules without modifying package.json or the lock,
so the dev's Mac install stays untouched on the next git pull.

This is the recommended pattern from sharp's own docs for cross-target
Docker builds: https://sharp.pixelplumbing.com/install#cross-platform
2026-05-04 15:43:30 -05:00
davidherran 89505c73cc fix: pin @swc/helpers explicitly so npm ci works on npm 10 (Alpine)
Deploy to VPS / deploy (push) Has been cancelled
The Docker build on the VPS failed because:
  npm error Missing: @swc/helpers@0.5.21 from lock file

Cause: Next.js 16.1.6 declares @swc/helpers both as a direct dep at
0.5.15 AND as a peer dep ">=0.5.17". npm 11 (my local) accepts the
0.5.15 install and considers the peer satisfied. npm 10.9.7 (the
Alpine container in the VPS) is stricter — it tries to resolve the
peer to its own version and demands 0.5.21, which our lock file
didn't have.

Fix:
- Add @swc/helpers ^0.5.17 as a direct dependency so both npm 10 and
  npm 11 pick the same up-to-date version (resolved to 0.5.21).
- Regenerate package-lock.json from a clean install so it matches.

`npm ci` now succeeds in the docker-compose build.
2026-05-04 15:35:20 -05:00
davidherran f8606a45ff feat: branding asset serving + footer email/phone fields
Deploy to VPS / deploy (push) Has been cancelled
Two changes that together make Site Settings actually work end-to-end.

BRANDING ASSET SERVING (the broken thumbnails fix)
The favicon/logo previews were broken because uploaded files in
/public/branding had no path to reach the browser:
  1. The folder wasn't mounted into the app container, so uploads
     vanished on next deploy
  2. Nginx had no location block, so /branding/foo.png returned 404
     (everything not in cases/applications/news/parts/footage was a
     proxy_pass to Next.js, which doesn't serve from /public/branding
     in standalone mode)

Fix:
- docker-compose.yml: ./public/branding mounted to /app/public/branding
  (write side) AND /srv/branding (read-only side for Nginx)
- nginx/conf.d/flux.conf: new "location /branding/" block, same
  cache strategy as the other asset locations (max-age=300, must-revalidate)

FOOTER EMAIL + PHONE (David's request)
- siteSettingsTypes.ts: hqEmail and hqPhone fields added to FooterSettings,
  pre-filled with sales@lethepowerflux.com and +39 0424 287 492
- Footer.tsx: clickable mailto: and tel: links with Mail / Phone icons
  shown right under the HQ address. Hidden when fields are empty so the
  layout stays clean for editors who want to suppress contact info.
- /hq-command/dashboard/settings: new "Headquarters contact" group in
  the Footer tab with the two fields (auto-translate ignores them, since
  emails and phone numbers don't need translation).

DEPLOY (David)
  cd /opt/flux-srl
  mkdir -p public/branding   # one-time, creates the folder if missing
  git pull
  docker compose up -d --build app
  docker compose exec nginx nginx -t
  docker compose exec nginx nginx -s reload
2026-05-04 15:24:06 -05:00
davidherran 01a84edee9 fix: prisma migrate now runs at container startup + dotenv optional
Deploy to VPS / deploy (push) Has been cancelled
Two related fixes for the deploy pipeline so DB schema changes never
again leave the site half-deployed.

PRISMA CONFIG (prisma.config.ts)
- "import 'dotenv/config'" was hard-required, but dotenv isn't installed
  in the production runtime image (env vars come from docker-compose).
- Wrapped in try/catch so it loads .env locally and silently no-ops in
  the container — `prisma migrate deploy` works in both environments.

DOCKERFILE
- Copies node_modules/prisma + prisma.config.ts to the runner stage so
  the CLI is available at runtime, not just at build.
- New CMD runs `prisma migrate deploy` before booting the server.
  Idempotent — already-applied migrations are skipped. If the DB is
  unreachable, the container exits and docker-compose retries.
- This means: from now on, `git pull && docker compose up -d --build app`
  is the entire deploy. No more "did you remember to run migrations?".

DEFENSIVE TRY/CATCH (applications/[slug]/page.tsx)
- prisma.application.findUnique and prisma.globalNode.findMany now have
  try/catch with logged errors. A transient DB hiccup or missing
  Application slug now degrades gracefully (renders "not found" or empty
  cases wall) instead of triggering a 500 Internal Server Error.

DEPLOY (David, this is the recovery sequence on the VPS)
  cd /opt/flux-srl
  git pull
  docker compose up -d --build app
  # The container will run pending migrations on its own.
  # No need to run `prisma migrate deploy` manually anymore.
2026-05-04 15:04:35 -05:00
davidherran a199891a3c feat: FluxAI multi-step autonomy + rate limiting + image pipeline
Deploy to VPS / deploy (push) Has been cancelled
Two production-grade hardening additions and one cost optimisation.

FLUXAI AUTONOMY RESTORED (api/chat)
- Brings back the multi-step agentic flow that the system prompt was
  always designed for. The "temporarily removed maxSteps" comment is
  gone — replaced with the AI SDK 6 equivalent stopWhen: stepCountIs(5).
- Cap at 5 chained tool calls per turn bounds latency + LLM cost.
- maxDuration raised 30s → 60s to absorb tool-chain runs.
- Result: one user prompt now triggers, e.g. search_installations →
  energy_savings_calculator → show_case_study → schedule_consultation
  in a single turn — exactly the SPIN methodology in the prompt.

RATE LIMITING (src/lib/rateLimit.ts + api/chat)
- Token-bucket per IP: 30 messages burst, sustained 30/minute. Trips
  to 429 with Retry-After + X-RateLimit-Remaining headers when abused.
- IP extracted from x-forwarded-for (Nginx already passes this).
- In-memory Map with 10-min GC of stale buckets — no Redis dep.
  If we scale to multiple replicas later, swap the Map for Upstash.
- Protects the OpenAI quota from someone hammering the chat endpoint.

IMAGE PIPELINE (src/lib/imageOptimizer.ts)
- sharp-based optimizer: auto-orient (EXIF), cap at 2560px long side,
  re-encode WebP@85, content-hash filename. Re-uploads with same
  content reuse the same hash; new content gets a new URL — perfect
  cache invalidation without header tricks.
- Opt-in via optimize=1 form/query param on /api/assets POST.
- Hero CMS and Site Settings uploads turn it on automatically (those
  are user-facing brand assets where compression matters most).
- App/news/parts uploads remain untouched (editors may be uploading
  CAD drawings, datasheets, etc. that shouldn't be transcoded).
- Falls back gracefully to a no-op for unsupported formats (SVG, GIF,
  videos, anything sharp can't decode) so it never breaks an upload.

DOCKERFILE
- Adds vips/vips-dev for sharp on Alpine + --include=optional so the
  @img/sharp-linuxmusl-x64 prebuilt is downloaded
- Explicitly copies node_modules/sharp + node_modules/@img to the
  runner stage (Next.js trace can miss conditional deps).

NO DB SCHEMA CHANGES.
2026-05-04 14:48:37 -05:00
davidherran 09e6d0c7cf seo: dynamic sitemap + robots + per-page metadata + JSON-LD
Brings the site up to enterprise SEO standards. Google now gets a complete
machine-readable map of the content, with multilingual hreflang tags,
structured data for the knowledge panel, and rich Open Graph cards on
LinkedIn / WhatsApp / Twitter.

NEW
- src/app/sitemap.ts: dynamic sitemap.xml from Prisma. Emits 5 locales x
  every active application + every active news article, with hreflang
  alternates linking each translation. Hourly revalidation.
- src/app/robots.ts: robots.txt blocks /hq-command/, /api/, /parts (B2B
  auth-gated), points crawlers at the sitemap.
- src/lib/seo.ts: helpers for canonical URLs, hreflang alternates, and
  JSON-LD schemas (Organization, WebSite, Article, Product, BreadcrumbList).
- src/components/seo/JsonLd.tsx: server component that emits one
  application/ld+json script tag per page.

PER-PAGE generateMetadata
- Home: localized titles + descriptions in EN/IT/VEC/ES/DE
- News hub: title built from translations, hreflang tags
- News article: title/description from DB, OG image = cover, type=article,
  publishedTime + modifiedTime for date freshness signals
- Applications: title/description from DB, type=product, hero image
- Heritage: localized title/description

JSON-LD STRUCTURED DATA
- Site-wide (in root layout): Organization (with HQ address, founder,
  contact, social profiles) + WebSite — drives Google knowledge panel
- Article pages: Article schema with publisher/datePublished/dateModified
  — required for Google News / Discover eligibility
- Application pages: Product schema (FLUX brand, RF Industrial category)
  + BreadcrumbList — drives rich-snippet breadcrumb in search results

NOTES
- Open Graph metadataBase set from NEXT_PUBLIC_APP_URL so absolute URLs
  for OG images are correct (LinkedIn previews require absolute paths)
- All pages have canonical URLs to prevent duplicate-content penalties
- /parts already has noindex meta (B2B portal) — also blocked in robots
- No DB schema changes. Pure additions to /src/lib and /src/app.
2026-05-04 14:42:43 -05:00
davidherran f931ae281c i18n: preserve technical industry terms across all locales
Deploy to VPS / deploy (push) Has been cancelled
Some technical terms were being translated literally and reading awkwardly
to industrial buyers — fixed across IT, ES, VEC, DE so they match the
English source and industry convention.

PRESERVED IN ENGLISH (industry-standard, never translate)
- "Solid-State RF" — was "RF a Stato Solido" / "RF de Estado Sólido" /
  "RF a Stato Sołido" / "Solid-State-RF"
- "Microwave Systems" — was "Sistemi a Microonde" / "Sistemas de
  Microondas" / "Mikrowellensysteme" / "Sistemi Microwave"
- "Radio Frequency (RF)" — was "Radiofrequenza" / "Radiofrecuencia" /
  "Radiofrequensa" / "Hochfrequenztechnologie" (kept as the technical
  proper noun, with the RF acronym in parentheses for first reference)
- "Pulse Wave" — was "onde pulsate" / "ondas pulsadas" / "onde pulsà" /
  "Pulswellen-Technologie"

Files: messages/it.json, messages/es.json, messages/vec.json,
messages/de.json. messages/en.json unchanged. JSON syntax validated.
2026-05-04 13:14:20 -05:00
davidherran b9a744bdbc feat: site settings CMS — favicon, logo, footer, social, OG image
Adds a full settings dashboard at /hq-command/dashboard/settings so the
client can update favicon, logos, footer text, social links and OG image
without code changes — wired into the SiteSetting model from the previous
commit.

NEW
- src/lib/siteSettingsTypes.ts: pure types + defaults (client-safe import)
- src/lib/siteSettings.ts: server-only loader using the SiteSetting model
- /api/assets gains a "branding" flat scope that writes to /public/branding
- /hq-command/dashboard/settings/{actions.ts, page.tsx} with three tabs:
    1. Branding — favicon, Apple touch icon, main logo, email logo, OG
       image, theme color. Each field has helper text with recommended
       size/format and live preview.
    2. Footer — CTA banner, HQ address, legal links. Optional one-click
       AI translation to IT, VEC, ES, DE.
    3. Social — LinkedIn, Instagram, YouTube, contact email.

WIRED INTO LAYOUT
- src/app/[locale]/layout.tsx now uses generateMetadata + generateViewport
  to pull favicon, OG image and theme color dynamically. Adds Twitter
  Card metadata. Falls back to the default flux-logo when SiteSetting
  table is empty.
- src/components/layout/Footer.tsx reads CTA/HQ/legal copy from DB,
  supports per-locale overrides via translationsJson, and renders social
  icons (LinkedIn / Instagram / YouTube / Mail) only for filled fields.

UX FOR THE EDITOR (David's "12-year-old test")
- Drop-zone uploaders next to URL inputs — paste-or-upload either way
- Live image previews next to every branding field
- "Saved — live in 60 seconds" inline confirmation, no extra modals
- Recommended sizes spelled out next to each field (e.g. "PNG, square,
  minimum 512×512" for favicon)
- Tooltip explaining why each image is needed

NO SCHEMA CHANGES — uses the SiteSetting table created in the previous
commit. Existing rows untouched.
2026-05-04 12:47:10 -05:00
davidherran b9201a437c feat: hero carousel CMS + responsive mobile/iPad fix + flat-scope assets
Replaces the filesystem-scan hero (fs.readdirSync of /public/footage/main)
with a fully CMS-driven HeroSlide model. Editors can now drag-drop reorder,
toggle slides on/off, set focal points for proper mobile cropping, and
auto-translate per-slide captions.

NEW SCHEMA (additive — does not touch existing tables)
- HeroSlide: mediaUrl, mediaType, altText, order, isActive, focalPointX,
  focalPointY, translationsJson, timestamps
- SiteSetting: key-value JSON store for site-wide config (favicon, logo,
  footer, OG image) — wired up in next commit
- Migration 20260504120000_add_hero_slides_and_site_settings/migration.sql
  uses CREATE TABLE IF NOT EXISTS, additive only

HERO REEL REFACTOR (Bug #4 — responsive mobile/iPad)
- Switches from `images: string[]` to `slides: HeroSlideData[]` while
  keeping a backwards-compat path so legacy callers still work
- w-screen → w-full max-w-[100vw] (no horizontal scroll on iOS)
- h-[100vh] → h-[100svh] so iOS Safari URL bar doesn't push content
- Reduces title font sizes on small viewports (text-3xl → text-4xl
  → text-5xl → text-[5.5rem]) so the headline stays inside the canvas
- objectPosition driven by focal-point fields per slide
- Native <video> support for video slides

HQ COMMAND — /hq-command/dashboard/hero
- Drag-drop reorder, click-to-set-focal-point, inline alt-text editing
- Auto-save with "Saving…" / "Saved ✓" indicators
- Per-slide caption overrides (title, subtitle, descriptions)
- Optional one-click AI translation to IT, VEC, ES, DE
- Drop-zone uploader → /api/assets (scope=footage, flat folder)

API — /api/assets
- New flat scopes: "footage" (writes to /public/footage/main) and
  "branding" (writes to /public/branding) — slug-less for site-wide assets
- New buildPublicUrl helper centralises the URL convention
- Revalidate helper expanded with branding + settings scopes

HOME PAGE
- Reads hero slides from DB first; falls back to filesystem scan when
  HeroSlide table is empty (so production keeps working immediately
  after migration runs but before the editor populates rows)

DEPLOY NOTES
- After git pull on VPS, run the migration ONCE:
    docker compose exec app npx prisma migrate deploy
  Then:
    docker compose up -d --build app
  Existing data (AdminUser w/ 2FA, ClientUser, GlobalNode, Application,
  TimelineEvent, NewsArticle, HeritageSection, SparePart, OperationsSignal,
  NotificationRoute, PageContent) is NOT touched. Migration only creates
  two new tables.
2026-05-04 09:34:49 -05:00
davidherran 6e46808c27 fix: instant CMS uploads + heritage dark/light + ISR caching
Eliminates the need to run "docker compose build" after uploading
images via HQ Command. Heritage page now respects light/dark mode.

CACHE INVALIDATION
- New helper src/lib/revalidate.ts called from /api/assets and
  /api/public-upload after every upload, delete, folder create
- Pages switch from force-dynamic to ISR with revalidate=60
  (regenerated on demand whenever content changes, plus 60s safety)
- Nginx now sends "max-age=300, must-revalidate" instead of "expires 30d"
  on /cases/, /applications/, /news/, /parts/, /footage/, /operations-inbox/
  so browsers revalidate via If-Modified-Since (304s on unchanged files)
- Next.js Image Optimizer aligned with same TTL via minimumCacheTTL=300
  and adds /_next/image location block in Nginx for correct headers

HERITAGE DARK/LIGHT FIX (Bug #8)
- Replaces hardcoded #0A0A0C / #00F0FF / text-white with proper
  light + dark variants throughout markdown renderer (tables, lists,
  headings, blockquotes, paragraphs, images)
- Hero section, navigation pill, and CMS-driven sections now switch
  with the global theme toggle

SECURITY HARDENING
- Server actions bodySizeLimit reduced from 500MB to 50MB
  (large uploads still go through /api/assets which uses Nginx 500MB cap)

DEPLOY NOTES
- Run on VPS:
    git pull
    docker compose up -d --build app
    docker compose exec nginx nginx -s reload
- No DB schema changes in this commit. Existing 2FA users / data untouched.
2026-05-04 09:27:46 -05:00