From 966ad7aba4e2a054057fb518e5783b8c69d818f4 Mon Sep 17 00:00:00 2001 From: Ryan Westfall Date: Mon, 27 Jul 2026 06:54:19 -0700 Subject: [PATCH] Enable beta auto-deploy + Tianji beta ID / consent-gated events (#35) (#42) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Closes #35. - **Flip deploy workflows** (mirror `dta_webapp`): push/merge to `master` → auto-deploy **beta**; **Deploy Prod** is `workflow_dispatch` only - **Tianji**: `REACT_APP_TIANJI_WEBSITE_ID` + `REACT_APP_DEPLOY_ENV` in `.env.production` / `.env.beta`; Tracker loads page views always on prod/beta - **Consent policy**: page views always live; custom events / identify gated via `analyticsConsent` + `AnalyticsConsentBanner` + `trackEvent`/`identifyUser` helpers (call sites remain #36) - Remove invalid Tianji ` 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 }), + ); +};