diff --git a/.gitea/workflows/deploy-beta.yml b/.gitea/workflows/deploy-beta.yml index 59d6d03..547a25e 100644 --- a/.gitea/workflows/deploy-beta.yml +++ b/.gitea/workflows/deploy-beta.yml @@ -1,7 +1,10 @@ name: Deploy Beta +# Auto-deploy beta after push to master (mirrors dta_webapp). on: - workflow_dispatch: {} + push: + branches: + - master jobs: unit-tests: diff --git a/.gitea/workflows/deploy-prod.yml b/.gitea/workflows/deploy-prod.yml index bb055e3..82d96cf 100644 --- a/.gitea/workflows/deploy-prod.yml +++ b/.gitea/workflows/deploy-prod.yml @@ -1,15 +1,31 @@ name: Deploy Prod -# Runs after Unit Tests completes on master. Direct pushes only (not PRs). +# Manual prod deploy only (mirrors dta_webapp). Push to master deploys beta. on: - workflow_run: - workflows: [Unit Tests] - types: [completed] - branches: [master] + workflow_dispatch: {} jobs: - deploy: - if: gitea.event.workflow_run.conclusion == 'success' && gitea.event.workflow_run.event == 'push' + unit-tests: + runs-on: self-hosted + defaults: + run: + working-directory: llm-fe + steps: + - uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Install dependencies + run: npm ci + + - name: Run unit tests + run: npm run test:ci + + deploy-prod: + needs: unit-tests runs-on: self-hosted env: SERVER_INFRA_ROOT: /home/westfarn/Documents/repos/server-infra @@ -19,4 +35,4 @@ jobs: "$SERVER_INFRA_ROOT/scripts/deploy.sh" \ --app chat_web_app \ --env prod \ - --ref "${{ gitea.event.workflow_run.head_sha }}" + --ref "${{ gitea.sha }}" diff --git a/README.md b/README.md index 3f36ac6..7e3a8af 100644 --- a/README.md +++ b/README.md @@ -92,10 +92,12 @@ to be visible in the browser bundle. | File | Used when | Backend (current) | |------|-----------|-------------------| | `.env.development` | `npm start` | `http://127.0.0.1:8001` | -| `.env.production` | `npm run build` / `build:prod` | `https://chatbackend.aimloperations.com` | -| `.env.beta` | `npm run build:beta` | `https://beta.chatbackend.aimloperations.com` | +| `.env.production` | `npm run build` / `build:prod` | `https://chatbackend.aimloperations.com` + prod Tianji ID | +| `.env.beta` | `npm run build:beta` | `https://beta.chatbackend.aimloperations.com` + beta Tianji ID | | `.env.mobile` | `npm run build:mobile` | prod by default (override for beta shell) | +Each committed env sets `REACT_APP_DEPLOY_ENV` (`production` / `beta`) and `REACT_APP_TIANJI_WEBSITE_ID`. + Required keys (already set in the committed env files): ```env @@ -184,20 +186,22 @@ npm run test:ci # one-shot (matches Gitea Unit Tests) ## CI / deployment -Gitea Actions workflows in `.gitea/workflows/`: +Gitea Actions workflows in `.gitea/workflows/` (same pattern as `dta_webapp`): | Workflow | Trigger | Behavior | |----------|---------|----------| | `unit-tests.yml` | push/PR to `master` | `npm ci` + `npm run test:ci` in `llm-fe/` | -| `deploy-prod.yml` | after green **Unit Tests** on `master` **push** | `server-infra/scripts/deploy.sh --app chat_web_app --env prod --ref ` | -| `deploy-beta.yml` | `workflow_dispatch` | tests, then deploy `--env beta` | +| `deploy-beta.yml` | **push** to `master` (after unit tests in-workflow) | `server-infra/scripts/deploy.sh --app chat_web_app --env beta --ref ` | +| `deploy-prod.yml` | **manual** `workflow_dispatch` only | tests, then deploy `--env prod` | + +Push/merge to `master` auto-deploys **beta** only. Prod requires the Gitea **Run workflow** button on Deploy Prod. Deploy flow (on each webserver listed in `host_apps`): 1. Git checkout at the pinned ref under `/opt/apps/src/chat_web_app_` 2. `npm ci` in `llm-fe/` 3. `npm run build:` → writes the static tree under `/var/www/...` -4. **web-static** nginx serves that root on the host port (prod **8082**, beta **8083** if enabled) +4. **web-static** nginx serves that root on the host port (prod **8082**, beta **8083**) Manual deploy from the control node: @@ -209,9 +213,21 @@ Manual deploy from the control node: NPM (not managed by Ansible) should balance public Hesychia domains to: - `adama:8082` + `roslin:8082` + `ai-server-4080:8082` (prod) +- beta hosts on **8083** / `beta.chat.aimloperations.com` (see server-infra) See [server-infra IMPLEMENTATION.md](https://git.aimloperations.com/ai_ml_operations/server-infra). +### Tianji analytics + +| Build | Env file | Website ID | +|-------|----------|------------| +| prod | `.env.production` | `REACT_APP_TIANJI_WEBSITE_ID` (prod site) | +| beta | `.env.beta` | distinct beta site ID (set after creating website in Tianji) | + +- Script: `https://tianji.aimloperations.com/tracker.js` +- **Page views** always load on prod/beta (no consent gate) +- **Custom events / identify** require analytics consent (`AnalyticsConsentBanner` + `trackEvent` / `identifyUser`) + ## Related repos - **Backend:** [chat_backend](https://git.aimloperations.com/ai_ml_operations/chat_backend) — Django API + WebSocket diff --git a/llm-fe/.env.beta b/llm-fe/.env.beta index 34f65ba..57f27ff 100644 --- a/llm-fe/.env.beta +++ b/llm-fe/.env.beta @@ -1,4 +1,8 @@ # Beta API hosts (current). Target public product domain: hesychia.ai # Flip these to hesychia.ai API hosts when server-infra / DNS cut over. +REACT_APP_DEPLOY_ENV=beta REACT_APP_BACKEND_REST_API_BASE_URL=https://beta.chatbackend.aimloperations.com/api/ REACT_APP_BACKEND_WS_API_BASE_URL=wss://beta.chatbackend.aimloperations.com/ws/chat_again/ +# Tianji website id for Hesychia beta (distinct from prod so analytics stay separate). +# Create "Hesychia Beta" in Tianji UI (workspace cm7w8087y020lddswyhamadj2), then paste id here. +REACT_APP_TIANJI_WEBSITE_ID=cms38bw671mf9n5jjw3xp1j3q diff --git a/llm-fe/.env.production b/llm-fe/.env.production index c206eb4..f7d84d0 100644 --- a/llm-fe/.env.production +++ b/llm-fe/.env.production @@ -1,4 +1,7 @@ # Production API hosts (current). Target public product domain: hesychia.ai # Flip these to hesychia.ai API hosts when server-infra / DNS cut over. +REACT_APP_DEPLOY_ENV=production REACT_APP_BACKEND_REST_API_BASE_URL=https://chatbackend.aimloperations.com/api/ REACT_APP_BACKEND_WS_API_BASE_URL=wss://chatbackend.aimloperations.com/ws/chat_again/ +# Tianji website id (prod). Page views always on; custom events need consent. +REACT_APP_TIANJI_WEBSITE_ID=cm7x7m52m03kbddswbswrt17y diff --git a/llm-fe/src/App.tsx b/llm-fe/src/App.tsx index 559ed9f..b2fdf91 100644 --- a/llm-fe/src/App.tsx +++ b/llm-fe/src/App.tsx @@ -19,6 +19,8 @@ import AnalyticsPage from './llm-fe/pages/Analytics/Analytics'; import PasswordResetConfirmation from './llm-fe/pages/PasswordResetConfirmation/PasswordReset'; import GlobalThemeWrapper from './llm-fe/components/GlobalThemeWrapper/GlobalThemeWrapper'; import Tracker from './llm-fe/components/Tracker/Tracker'; +import { AnalyticsConsentProvider } from './llm-fe/contexts/AnalyticsConsentContext'; +import AnalyticsConsentBanner from './llm-fe/components/AnalyticsConsentBanner/AnalyticsConsentBanner'; const ProtectedRoutes = () => { const { authenticated, loading } = useContext(AuthContext); @@ -40,7 +42,9 @@ class App extends Component { render() { return ( + +
@@ -76,6 +80,7 @@ class App extends Component {
+
diff --git a/llm-fe/src/index.tsx b/llm-fe/src/index.tsx index 8588059..6fe949f 100644 --- a/llm-fe/src/index.tsx +++ b/llm-fe/src/index.tsx @@ -21,7 +21,6 @@ const root = ReactDOM.createRoot( root.render( - diff --git a/llm-fe/src/llm-fe/components/AnalyticsConsentBanner/AnalyticsConsentBanner.test.tsx b/llm-fe/src/llm-fe/components/AnalyticsConsentBanner/AnalyticsConsentBanner.test.tsx new file mode 100644 index 0000000..7589137 --- /dev/null +++ b/llm-fe/src/llm-fe/components/AnalyticsConsentBanner/AnalyticsConsentBanner.test.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import { MemoryRouter } from 'react-router-dom'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { AnalyticsConsentProvider } from '../../contexts/AnalyticsConsentContext'; +import AnalyticsConsentBanner from './AnalyticsConsentBanner'; + +const renderBanner = () => + render( + + + + + , + ); + +describe('AnalyticsConsentBanner', () => { + const originalEnv = process.env; + + beforeEach(() => { + localStorage.clear(); + process.env = { ...originalEnv, REACT_APP_DEPLOY_ENV: 'production' }; + }); + + afterEach(() => { + process.env = { ...originalEnv }; + }); + + it('shows until the user accepts or declines', () => { + renderBanner(); + expect(screen.getByRole('dialog', { name: /analytics consent/i })).toBeInTheDocument(); + expect(screen.getByText(/Help us improve Hesychia/i)).toBeInTheDocument(); + }); + + it('hides after accept and persists granted', () => { + renderBanner(); + fireEvent.click(screen.getByRole('button', { name: /accept analytics/i })); + expect(screen.queryByRole('dialog', { name: /analytics consent/i })).not.toBeInTheDocument(); + expect(localStorage.getItem('hesychia_analytics_consent')).toBe('granted'); + }); + + it('hides after decline and persists denied', () => { + renderBanner(); + fireEvent.click(screen.getByRole('button', { name: /decline/i })); + expect(screen.queryByRole('dialog', { name: /analytics consent/i })).not.toBeInTheDocument(); + expect(localStorage.getItem('hesychia_analytics_consent')).toBe('denied'); + }); +}); diff --git a/llm-fe/src/llm-fe/components/AnalyticsConsentBanner/AnalyticsConsentBanner.tsx b/llm-fe/src/llm-fe/components/AnalyticsConsentBanner/AnalyticsConsentBanner.tsx new file mode 100644 index 0000000..6703848 --- /dev/null +++ b/llm-fe/src/llm-fe/components/AnalyticsConsentBanner/AnalyticsConsentBanner.tsx @@ -0,0 +1,75 @@ +import React from 'react'; +import { Link as RouterLink } from 'react-router-dom'; +import { Box, Button, Link, Paper, Stack, Typography } from '@mui/material'; +import { useAnalyticsConsent } from '../../contexts/AnalyticsConsentContext'; + +/** + * Opt-in for custom Tianji events / identify. Page views already load via Tracker. + */ +const AnalyticsConsentBanner = (): JSX.Element | null => { + const { showBanner, acceptAnalytics, declineAnalytics } = useAnalyticsConsent(); + + if (!showBanner) return null; + + return ( + + + + + + Help us improve Hesychia + + + We always collect anonymous page views and referral data to understand traffic. + With your consent, we also collect how you use features (sign-in, checkout, chat + actions, and similar). We do not use advertising cookies. When you accept and are + signed in, we may associate that activity with your account. See our{' '} + + Terms of Service + {' '} + for details. + + + + + + + + + + ); +}; + +export default AnalyticsConsentBanner; diff --git a/llm-fe/src/llm-fe/components/Tracker/Tracker.test.tsx b/llm-fe/src/llm-fe/components/Tracker/Tracker.test.tsx new file mode 100644 index 0000000..59f27ab --- /dev/null +++ b/llm-fe/src/llm-fe/components/Tracker/Tracker.test.tsx @@ -0,0 +1,58 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import Tracker from './Tracker'; + +const TRACKER_SCRIPT_ID = 'hesychia-tianji-tracker'; + +describe('Tracker', () => { + const originalEnv = process.env; + + beforeEach(() => { + document.body.innerHTML = ''; + process.env = { + ...originalEnv, + REACT_APP_DEPLOY_ENV: 'production', + REACT_APP_TIANJI_WEBSITE_ID: 'cm7x7m52m03kbddswbswrt17y', + }; + }); + + afterEach(() => { + process.env = { ...originalEnv }; + document.body.innerHTML = ''; + }); + + it('loads Tianji tracker without analytics consent (page views always on)', () => { + render(); + + const script = document.getElementById(TRACKER_SCRIPT_ID) as HTMLScriptElement | null; + expect(script).not.toBeNull(); + expect(script?.src).toBe('https://tianji.aimloperations.com/tracker.js'); + expect(script?.getAttribute('data-website-id')).toBe('cm7x7m52m03kbddswbswrt17y'); + }); + + it('loads distinct website id for beta builds', () => { + process.env = { + ...originalEnv, + REACT_APP_DEPLOY_ENV: 'beta', + REACT_APP_TIANJI_WEBSITE_ID: 'beta-website-id-test', + }; + + render(); + + const script = document.getElementById(TRACKER_SCRIPT_ID) as HTMLScriptElement | null; + expect(script?.getAttribute('data-website-id')).toBe('beta-website-id-test'); + }); + + it('does not load Tianji tracker outside analytics environments', () => { + process.env = { + ...originalEnv, + NODE_ENV: 'development', + REACT_APP_DEPLOY_ENV: undefined, + REACT_APP_TIANJI_WEBSITE_ID: undefined, + }; + + render(); + + expect(document.getElementById(TRACKER_SCRIPT_ID)).toBeNull(); + }); +}); diff --git a/llm-fe/src/llm-fe/components/Tracker/Tracker.tsx b/llm-fe/src/llm-fe/components/Tracker/Tracker.tsx index a5ec347..5c1e282 100644 --- a/llm-fe/src/llm-fe/components/Tracker/Tracker.tsx +++ b/llm-fe/src/llm-fe/components/Tracker/Tracker.tsx @@ -1,23 +1,42 @@ import React, { useEffect } from 'react'; +import { flushAnalyticsQueue } from '../../utils/analytics'; +import { isAnalyticsEnvironment } from '../../utils/analyticsConsent'; +const TRACKER_SCRIPT_ID = 'hesychia-tianji-tracker'; + +/** + * Loads Tianji tracker.js for automatic page views on prod/beta builds. + * Page views always run (no consent gate). Custom events use analytics.ts + consent. + */ const Tracker: React.FC = () => { - useEffect(() => { - if (process.env.NODE_ENV === 'production') { - const script = document.createElement('script'); - script.src = "https://tianji.aimloperations.com/tracker.js"; - script.async = true; - script.defer = true; - script.setAttribute('data-website-id', 'cm7x7m52m03kbddswbswrt17y'); + useEffect(() => { + if (!isAnalyticsEnvironment()) return; - document.body.appendChild(script); + const websiteId = process.env.REACT_APP_TIANJI_WEBSITE_ID || ''; + if (!websiteId || document.getElementById(TRACKER_SCRIPT_ID)) return; - return () => { - document.body.removeChild(script); - }; - } - }, []); + const script = document.createElement('script'); + script.id = TRACKER_SCRIPT_ID; + script.src = 'https://tianji.aimloperations.com/tracker.js'; + script.async = true; + script.defer = true; + script.setAttribute('data-website-id', websiteId); + script.setAttribute('data-do-not-track', 'true'); + script.onload = () => { + flushAnalyticsQueue(); + }; - return null; + document.body.appendChild(script); + + return () => { + const loaded = document.getElementById(TRACKER_SCRIPT_ID); + if (loaded?.parentNode) { + loaded.parentNode.removeChild(loaded); + } + }; + }, []); + + return null; }; export default Tracker; diff --git a/llm-fe/src/llm-fe/contexts/AnalyticsConsentContext.tsx b/llm-fe/src/llm-fe/contexts/AnalyticsConsentContext.tsx new file mode 100644 index 0000000..f787480 --- /dev/null +++ b/llm-fe/src/llm-fe/contexts/AnalyticsConsentContext.tsx @@ -0,0 +1,87 @@ +import React, { + createContext, + ReactNode, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from 'react'; +import { + ANALYTICS_CONSENT_CHANGED_EVENT, + AnalyticsConsentStatus, + getAnalyticsConsent, + isAnalyticsEnvironment, + setAnalyticsConsent, +} from '../utils/analyticsConsent'; +import { flushAnalyticsQueue } from '../utils/analytics'; + +type AnalyticsConsentContextValue = { + consent: AnalyticsConsentStatus; + hasConsent: boolean; + isResolved: boolean; + showBanner: boolean; + acceptAnalytics: () => void; + declineAnalytics: () => void; +}; + +const AnalyticsConsentContext = createContext( + undefined, +); + +type AnalyticsConsentProviderProps = { + children?: ReactNode; +}; + +export const AnalyticsConsentProvider = ({ children }: AnalyticsConsentProviderProps) => { + const [consent, setConsent] = useState(() => getAnalyticsConsent()); + + useEffect(() => { + const onConsentChanged = (event: Event) => { + const next = (event as CustomEvent).detail; + if (next === 'granted' || next === 'denied') { + setConsent(next); + } else { + setConsent(getAnalyticsConsent()); + } + }; + + window.addEventListener(ANALYTICS_CONSENT_CHANGED_EVENT, onConsentChanged); + return () => window.removeEventListener(ANALYTICS_CONSENT_CHANGED_EVENT, onConsentChanged); + }, []); + + const acceptAnalytics = useCallback(() => { + setAnalyticsConsent('granted'); + setConsent('granted'); + flushAnalyticsQueue(); + }, []); + + const declineAnalytics = useCallback(() => { + setAnalyticsConsent('denied'); + setConsent('denied'); + }, []); + + const value = useMemo( + () => ({ + consent, + hasConsent: consent === 'granted', + isResolved: consent !== 'pending', + showBanner: isAnalyticsEnvironment() && consent === 'pending', + acceptAnalytics, + declineAnalytics, + }), + [acceptAnalytics, consent, declineAnalytics], + ); + + return ( + {children} + ); +}; + +export const useAnalyticsConsent = (): AnalyticsConsentContextValue => { + const context = useContext(AnalyticsConsentContext); + if (!context) { + throw new Error('useAnalyticsConsent must be used within AnalyticsConsentProvider'); + } + return context; +}; diff --git a/llm-fe/src/llm-fe/types/tianji.d.ts b/llm-fe/src/llm-fe/types/tianji.d.ts new file mode 100644 index 0000000..f5aff87 --- /dev/null +++ b/llm-fe/src/llm-fe/types/tianji.d.ts @@ -0,0 +1,10 @@ +export {}; + +declare global { + interface Window { + tianji?: { + track: (eventName: string, data?: Record) => void; + identify: (userInfo: Record) => void; + }; + } +} diff --git a/llm-fe/src/llm-fe/utils/analytics.test.ts b/llm-fe/src/llm-fe/utils/analytics.test.ts new file mode 100644 index 0000000..3ae4dac --- /dev/null +++ b/llm-fe/src/llm-fe/utils/analytics.test.ts @@ -0,0 +1,56 @@ +import { flushAnalyticsQueue, identifyUser, trackEvent } from './analytics'; + +describe('analytics event helpers', () => { + const originalEnv = process.env; + + beforeEach(() => { + localStorage.clear(); + process.env = { ...originalEnv, REACT_APP_DEPLOY_ENV: 'production' }; + delete (window as { tianji?: unknown }).tianji; + }); + + afterEach(() => { + process.env = { ...originalEnv }; + delete (window as { tianji?: unknown }).tianji; + }); + + it('does not track custom events without consent', () => { + const track = jest.fn(); + window.tianji = { track, identify: jest.fn() }; + + trackEvent('Login Success'); + expect(track).not.toHaveBeenCalled(); + }); + + it('tracks custom events when consent granted', () => { + localStorage.setItem('hesychia_analytics_consent', 'granted'); + const track = jest.fn(); + window.tianji = { track, identify: jest.fn() }; + + trackEvent('Login Success', { method: 'password' }); + expect(track).toHaveBeenCalledWith('Login Success', { method: 'password' }); + }); + + it('queues events until tianji is ready then flushes after consent', () => { + localStorage.setItem('hesychia_analytics_consent', 'granted'); + + trackEvent('Checkout Started'); + const track = jest.fn(); + window.tianji = { track, identify: jest.fn() }; + flushAnalyticsQueue(); + + expect(track).toHaveBeenCalledWith('Checkout Started', undefined); + }); + + it('identify requires consent', () => { + const identify = jest.fn(); + window.tianji = { track: jest.fn(), identify }; + + identifyUser({ userId: 'u1' }); + expect(identify).not.toHaveBeenCalled(); + + localStorage.setItem('hesychia_analytics_consent', 'granted'); + identifyUser({ userId: 'u1' }); + expect(identify).toHaveBeenCalledWith({ userId: 'u1' }); + }); +}); diff --git a/llm-fe/src/llm-fe/utils/analytics.ts b/llm-fe/src/llm-fe/utils/analytics.ts new file mode 100644 index 0000000..f25876c --- /dev/null +++ b/llm-fe/src/llm-fe/utils/analytics.ts @@ -0,0 +1,77 @@ +import { hasAnalyticsConsent, isAnalyticsEnvironment } from './analyticsConsent'; + +type QueuedCall = + | { type: 'track'; eventName: string; data?: Record } + | { type: 'identify'; userInfo: Record }; + +const queue: QueuedCall[] = []; + +/** Named events for product analytics. Page views stay on automatic tracker.js. */ +export const AnalyticsEvents = { + LOGIN_SUCCESS: 'Login Success', + LOGIN_FAILED: 'Login Failed', + SIGNUP_STARTED: 'Sign Up Started', + SIGNUP_SUCCESS: 'Sign Up Success', + SIGNUP_FAILED: 'Sign Up Failed', + LOGOUT: 'Logout', + CHECKOUT_STARTED: 'Checkout Started', + PAYMENT_SUCCESS: 'Payment Success', + PAYMENT_CANCELED: 'Payment Canceled', + CONVERSATION_CREATED: 'Conversation Created', + MESSAGE_SENT: 'Message Sent', + TOS_ACKNOWLEDGED: 'ToS Acknowledged', + BILLING_PORTAL_OPENED: 'Billing Portal Opened', +} as const; + +export type AnalyticsEventName = (typeof AnalyticsEvents)[keyof typeof AnalyticsEvents]; + +/** Custom events + identify require consent. Automatic page views do not. */ +const isEventTrackingEnabled = (): boolean => + isAnalyticsEnvironment() && hasAnalyticsConsent(); + +const runQueuedCall = (call: QueuedCall): void => { + if (!window.tianji) return; + + if (call.type === 'track') { + window.tianji.track(call.eventName, call.data); + return; + } + + window.tianji.identify(call.userInfo); +}; + +export const flushAnalyticsQueue = (): void => { + if (!isEventTrackingEnabled() || !window.tianji) return; + + while (queue.length > 0) { + const call = queue.shift(); + if (call) runQueuedCall(call); + } +}; + +export const trackEvent = ( + eventName: AnalyticsEventName | string, + data?: Record, +): void => { + if (!isEventTrackingEnabled()) return; + + const payload = data ? { ...data } : undefined; + + if (window.tianji?.track) { + window.tianji.track(eventName, payload); + return; + } + + queue.push({ type: 'track', eventName, data: payload }); +}; + +export const identifyUser = (userInfo: Record): void => { + if (!isEventTrackingEnabled()) return; + + if (window.tianji?.identify) { + window.tianji.identify(userInfo); + return; + } + + queue.push({ type: 'identify', userInfo: { ...userInfo } }); +}; diff --git a/llm-fe/src/llm-fe/utils/analyticsConsent.test.ts b/llm-fe/src/llm-fe/utils/analyticsConsent.test.ts new file mode 100644 index 0000000..448137c --- /dev/null +++ b/llm-fe/src/llm-fe/utils/analyticsConsent.test.ts @@ -0,0 +1,90 @@ +import { + getAnalyticsConsent, + hasAnalyticsConsent, + isAnalyticsConsentResolved, + isAnalyticsEnvironment, + setAnalyticsConsent, + ANALYTICS_CONSENT_CHANGED_EVENT, +} from './analyticsConsent'; + +const CONSENT_KEY = 'hesychia_analytics_consent'; + +describe('isAnalyticsEnvironment', () => { + const originalEnv = process.env; + + afterEach(() => { + process.env = { ...originalEnv }; + }); + + it('returns true for production and beta deploy env', () => { + process.env = { ...originalEnv, REACT_APP_DEPLOY_ENV: 'production' }; + expect(isAnalyticsEnvironment()).toBe(true); + + process.env = { ...originalEnv, REACT_APP_DEPLOY_ENV: 'beta' }; + expect(isAnalyticsEnvironment()).toBe(true); + }); + + it('returns false in development without deploy env', () => { + process.env = { + ...originalEnv, + NODE_ENV: 'development', + REACT_APP_DEPLOY_ENV: undefined, + REACT_APP_TIANJI_WEBSITE_ID: undefined, + }; + expect(isAnalyticsEnvironment()).toBe(false); + }); +}); + +describe('analytics consent storage', () => { + const originalEnv = process.env; + + beforeEach(() => { + localStorage.clear(); + process.env = { ...originalEnv, REACT_APP_DEPLOY_ENV: 'production' }; + }); + + afterEach(() => { + process.env = { ...originalEnv }; + }); + + it('defaults to pending in production when unset', () => { + expect(getAnalyticsConsent()).toBe('pending'); + expect(hasAnalyticsConsent()).toBe(false); + expect(isAnalyticsConsentResolved()).toBe(false); + }); + + it('auto-grants consent outside analytics environments', () => { + process.env = { + ...originalEnv, + NODE_ENV: 'development', + REACT_APP_DEPLOY_ENV: undefined, + REACT_APP_TIANJI_WEBSITE_ID: undefined, + }; + expect(getAnalyticsConsent()).toBe('granted'); + expect(hasAnalyticsConsent()).toBe(true); + }); + + it('reads granted and denied from localStorage', () => { + localStorage.setItem(CONSENT_KEY, 'granted'); + expect(getAnalyticsConsent()).toBe('granted'); + expect(isAnalyticsConsentResolved()).toBe(true); + + localStorage.setItem(CONSENT_KEY, 'denied'); + expect(getAnalyticsConsent()).toBe('denied'); + expect(hasAnalyticsConsent()).toBe(false); + }); + + it('persists consent and dispatches change event', () => { + const listener = jest.fn(); + window.addEventListener(ANALYTICS_CONSENT_CHANGED_EVENT, listener); + + setAnalyticsConsent('denied'); + expect(localStorage.getItem(CONSENT_KEY)).toBe('denied'); + expect(listener).toHaveBeenCalled(); + + setAnalyticsConsent('granted'); + expect(localStorage.getItem(CONSENT_KEY)).toBe('granted'); + + window.removeEventListener(ANALYTICS_CONSENT_CHANGED_EVENT, listener); + }); +}); diff --git a/llm-fe/src/llm-fe/utils/analyticsConsent.ts b/llm-fe/src/llm-fe/utils/analyticsConsent.ts new file mode 100644 index 0000000..f95642f --- /dev/null +++ b/llm-fe/src/llm-fe/utils/analyticsConsent.ts @@ -0,0 +1,44 @@ +export type AnalyticsConsentStatus = 'granted' | 'denied' | 'pending'; + +const CONSENT_STORAGE_KEY = 'hesychia_analytics_consent'; +export const ANALYTICS_CONSENT_CHANGED_EVENT = 'hesychia-analytics-consent-changed'; + +/** Prod + beta builds only (CRA NODE_ENV is production for both). */ +export const isAnalyticsEnvironment = (): boolean => { + const deployEnv = process.env.REACT_APP_DEPLOY_ENV; + if (deployEnv === 'production' || deployEnv === 'beta') return true; + // Fallback: production builds that omit DEPLOY_ENV still load tracker when a website id is set. + return ( + process.env.NODE_ENV === 'production' && + Boolean(process.env.REACT_APP_TIANJI_WEBSITE_ID) + ); +}; + +export const getAnalyticsConsent = (): AnalyticsConsentStatus => { + if (!isAnalyticsEnvironment()) return 'granted'; + + try { + const value = localStorage.getItem(CONSENT_STORAGE_KEY); + if (value === 'granted' || value === 'denied') return value; + } catch { + /* localStorage unavailable */ + } + + return 'pending'; +}; + +export const hasAnalyticsConsent = (): boolean => getAnalyticsConsent() === 'granted'; + +export const isAnalyticsConsentResolved = (): boolean => getAnalyticsConsent() !== 'pending'; + +export const setAnalyticsConsent = (status: 'granted' | 'denied'): void => { + try { + localStorage.setItem(CONSENT_STORAGE_KEY, status); + } catch { + /* localStorage unavailable */ + } + + window.dispatchEvent( + new CustomEvent(ANALYTICS_CONSENT_CHANGED_EVENT, { detail: status }), + ); +};