Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 23 additions & 22 deletions app/composables/useAnalyticsConsent.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,24 @@
/**
* Composable for managing PostHog analytics consent.
*
* Cookieless tracking is ALWAYS active — no consent needed.
* Accepting analytics upgrades to full cookie-based tracking
* (UTM, sessions, identity). Declining keeps cookieless mode.
* Two-tier model
* --------------
* - **No choice yet OR declined**: PostHog runs in cookieless mode
* (`persistence: 'memory'`, `person_profiles: 'identified_only'`).
* Logged-in users are still identified by their opaque user.id (so we
* can count returning users and per-user metrics) but no email/name is
* forwarded and nothing is stored on the visitor's device.
*
* - **Accepted**: PostHog persistence is upgraded to `localStorage+cookie`
* so the distinct id survives reloads, then `identify(userId, { email,
* name })` is re-fired with full PII. PostHog automatically aliases the
* current anonymous distinct id → user id, stitching the pre-signup
* funnel into the user's profile.
*/

/** Cookie name — shared across reqcore-web and applirank */
export const CONSENT_COOKIE_NAME = 'reqcore-consent'

/** @deprecated Old localStorage key — used only for one-time migration */
const LEGACY_STORAGE_KEY = 'reqcore-analytics-consent'

type ConsentState = 'granted' | 'denied' | null

export function useAnalyticsConsent() {
Expand All @@ -32,32 +39,26 @@ export function useAnalyticsConsent() {
sameSite: 'lax',
})

// One-time migration: move consent from localStorage to cookie for users
// who consented before this change, then clean up localStorage.
if (import.meta.client && !consentCookie.value) {
const legacy = localStorage.getItem(LEGACY_STORAGE_KEY)
if (legacy === 'granted' || legacy === 'denied') {
consentCookie.value = legacy
localStorage.removeItem(LEGACY_STORAGE_KEY)
}
}

const hasConsented = computed(() => consentCookie.value === 'granted')
const hasDeclined = computed(() => consentCookie.value === 'denied')
const needsConsent = computed(() => !consentCookie.value)

function acceptAnalytics() {
consentCookie.value = 'granted'
if (import.meta.client) localStorage.removeItem(LEGACY_STORAGE_KEY)
if (!ph) return
// Upgrade from cookieless to full cookie-based persistence

// Upgrade from cookieless to cookie+localStorage persistence so the
// distinct id survives reloads and new tabs. After this, the watcher
// in `usePostHogIdentity` (which depends on `hasConsented`) re-fires
// and re-identifies the user with full PII — that identify() call
// automatically aliases the current anonymous distinct id → user id.
ph.set_config({
persistence: 'localStorage+cookie',
person_profiles: 'identified_only',
cross_subdomain_cookie: true,
})
// Register UTM + attribution now that we have persistence

if (import.meta.client) {
// Capture UTM + first-touch attribution now that we have persistence
const params = new URLSearchParams(window.location.search)
const utmKeys = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term'] as const
const utmProps: Record<string, string> = {}
Expand All @@ -83,8 +84,8 @@ export function useAnalyticsConsent() {

function declineAnalytics() {
consentCookie.value = 'denied'
if (import.meta.client) localStorage.removeItem(LEGACY_STORAGE_KEY)
// No action needed — cookieless tracking continues without cookies or person profiles
// No PostHog action needed — cookieless mode (memory + identified_only)
// continues, no person profile properties are sent, no cookies are set.
}

return {
Expand Down
98 changes: 59 additions & 39 deletions app/composables/usePostHogIdentity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,79 +5,99 @@
*
* Must be called in `<script setup>` context (not in a plugin).
*
* Identity and group calls are gated on analytics consent so that events are
* never sent before the user has opted in. When a user grants consent during
* their session (ConsentBanner), the watchers re-fire and identify them
* immediately without requiring a page reload.
* Identity model
* --------------
* Logged-in users are ALWAYS identified by their opaque `user.id` so that
* funnel + retention analytics work even for visitors who never accept
* cookies. Email and name are forwarded ONLY when the user has explicitly
* consented to analytics — keeping the dashboard PII-free for non-consenters
* while still letting consented users be looked up by email in PostHog.
*
* The watcher depends on `hasConsented`, so when a user clicks "Accept" in
* the banner the identify() call re-fires immediately and PostHog aliases
* the anonymous distinct id to the user id (preserving the in-session
* funnel from anonymous visit → signup → identified user).
*/
export async function usePostHogIdentity() {
const { $posthogIdentifyUser, $posthogSetOrganization, $posthogReset, $posthogResetGroups } = useNuxtApp()
const { $posthogIdentifyUser, $posthogSetOrganization, $posthogReset, $posthogResetGroups, $posthogSetDemoFlag } = useNuxtApp()

if (!$posthogIdentifyUser) return

const { data: session } = await authClient.useSession(useFetch)
const activeOrgState = authClient.useActiveOrganization()

// Share the consent reactive state. useAnalyticsConsent also applies the
// stored consent flag to PostHog on the client, so calling it here means
// consent is active before the immediate watchers fire below.
const { hasConsented } = useAnalyticsConsent()

// Watch session AND consent so identify re-fires when a new user accepts
// consent during their visit, and is skipped when PostHog is opted-out.
const config = useRuntimeConfig()
const liveDemoEmail = (config.public.liveDemoEmail as string | undefined) || ''
const demoOrgSlug = (config.public.demoOrgSlug as string | undefined) || ''

watch(
[() => session.value, hasConsented] as const,
([currentSession, consented], prev) => {
const user = currentSession?.user
const previousUser = (prev?.[0] as typeof session.value)?.user

if (user?.id && consented) {
// Forward person properties for opted-in users so they're identifiable
// in PostHog. GDPR-safe: gated on explicit user consent.
;($posthogIdentifyUser as (userId: string, properties?: Record<string, string | undefined>) => void)(
user.id,
{
if (user?.id) {
// Forward person properties (email, name) ONLY if consent was given.
// Without consent we still identify with the opaque user.id so
// returning users are stable in PostHog — but no PII is attached.
const identify = $posthogIdentifyUser as (
userId: string,
properties?: Record<string, string | undefined>,
) => void
if (consented) {
identify(user.id, {
email: user.email,
name: user.name || undefined,
},
)
})
}
else {
identify(user.id)
}

// Demo tagging by user email — fires the moment the demo account
// signs in, even before an organisation context is loaded.
const setDemoFlag = $posthogSetDemoFlag as ((isDemo: boolean) => void) | undefined
if (setDemoFlag && liveDemoEmail) {
setDemoFlag(user.email === liveDemoEmail)
}
}
else if (previousUser?.id && !user?.id) {
// Always reset on log-out regardless of consent state so that
// no user identity leaks into the next anonymous session.
// Reset also clears the registered super properties (incl. is_demo).
($posthogReset as () => void)()
}
},
{ immediate: true },
)

// Watch org AND consent for group analytics — same gating logic as above.
watch(
[() => activeOrgState.value?.data, hasConsented] as const,
async ([org, consented]) => {
if (consented) {
if (org?.id) {
// Fetch the current member's role to enrich group properties — useful
// for debugging permission issues without exposing personal data.
let memberRole: string | undefined
try {
const { data } = await authClient.organization.getActiveMemberRole()
memberRole = data?.role ?? undefined
}
catch { /* non-critical; role is just an enrichment property */ }

// Only org id, name, and member role are forwarded; slug is omitted to minimise data.
;($posthogSetOrganization as (org: { id: string, name?: string, member_role?: string }) => void)({
id: org.id,
name: org.name || undefined,
member_role: memberRole,
})
if (org?.id) {
// Forward org name only for consenters; anonymous users get an
// opaque org-id-only group so per-org metrics still aggregate.
const setOrg = $posthogSetOrganization as (org: { id: string, name?: string }) => void
if (consented) {
setOrg({ id: org.id, name: org.name || undefined })
}
else {
// Clear org group when user has no active organization to avoid
// attributing events to the previously selected org.
($posthogResetGroups as (() => void) | undefined)?.()
setOrg({ id: org.id })
}

// Demo tagging by org slug — covers self-hosted deployments
// where the demo org may be owned by a non-demo email account.
// We only set true here; the email-based watcher above handles
// clearing the flag when the user is not the demo user.
const setDemoFlag = $posthogSetDemoFlag as ((isDemo: boolean) => void) | undefined
if (setDemoFlag && demoOrgSlug && org.slug === demoOrgSlug) {
setDemoFlag(true)
}
}
Comment on lines +90 to +98
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Demo flag is never cleared when the active org changes from demo → non-demo.

The org watcher only sets is_demo to true; it relies on the email watcher to clear it. But the email watcher keys on session.value and hasConsented — switching the active org does not change session.value (it lives in activeOrgState), so the email watcher won't re-fire.

Concrete failure mode:

  • User with a non-demo email is currently active in the demo org → org watcher sets is_demo=true.
  • User switches to a non-demo org → org watcher sees org.slug !== demoOrgSlug and does nothing. Email watcher doesn't fire either.
  • is_demo stays true for events from a real user in a real org → those events get filtered out of "real user" funnels/dashboards, silently corrupting metrics.

Set the flag from the org context unconditionally (or from the OR of both signals):

🐛 Proposed fix
-        // Demo tagging by org slug — covers self-hosted deployments
-        // where the demo org may be owned by a non-demo email account.
-        // We only set true here; the email-based watcher above handles
-        // clearing the flag when the user is not the demo user.
-        const setDemoFlag = $posthogSetDemoFlag as ((isDemo: boolean) => void) | undefined
-        if (setDemoFlag && demoOrgSlug && org.slug === demoOrgSlug) {
-          setDemoFlag(true)
-        }
+        // Demo tagging by org slug — covers self-hosted deployments where
+        // the demo org may be owned by a non-demo email account. We must
+        // set both true AND false here, because the email-based watcher
+        // does not re-fire when only the active org changes.
+        const setDemoFlag = $posthogSetDemoFlag as ((isDemo: boolean) => void) | undefined
+        if (setDemoFlag && demoOrgSlug) {
+          const isDemoUser = !!user?.email && user.email === liveDemoEmail
+          setDemoFlag(isDemoUser || org.slug === demoOrgSlug)
+        }

You'll need access to the current user inside the org watcher (e.g. read it from session.value?.user at the top of the callback) to combine both signals safely.

else {
($posthogResetGroups as (() => void) | undefined)?.()
}
},
{ immediate: true },
Expand Down
97 changes: 12 additions & 85 deletions app/composables/useTrack.ts
Original file line number Diff line number Diff line change
@@ -1,114 +1,41 @@
/**
* Composable for privacy-respecting PostHog funnel tracking.
* Composable for privacy-respecting PostHog event tracking.
*
* Wraps posthog.capture() with:
* - Consent gating (no-ops when user hasn't opted in)
* - Pre-consent event buffering (replayed when user consents)
* - Auto-enrichment with page context (route path, viewport width)
* Wraps posthog.capture() with auto-enrichment (route path, viewport width).
*
* Events are ALWAYS captured — even before the consent banner is answered —
* because PostHog runs in `persistence: 'memory'` mode by default, so the
* anonymous distinct id never reaches the visitor's storage (no cookies, no
* localStorage). When the user later accepts cookies, persistence is
* upgraded and `identify(userId, …)` automatically aliases the in-memory
* anonymous id to the user id, so the in-session funnel is preserved.
*/
import type { PostHog } from 'posthog-js'

interface PendingEvent {
eventName: string
properties: Record<string, unknown>
}

// Module-level buffer for events captured before consent is granted.
// Flushed to PostHog when the user accepts analytics; discarded on decline.
// Persisted to sessionStorage so the buffer survives hard page reloads
// (e.g. window.location.href navigation in createOrg/switchOrg).
const MAX_PENDING_EVENTS = 50
const STORAGE_KEY = 'ph-pending-events'

function restoreBuffer(): PendingEvent[] {
if (!import.meta.client) return []
try {
const stored = sessionStorage.getItem(STORAGE_KEY)
return stored ? JSON.parse(stored) as PendingEvent[] : []
}
catch { return [] }
}

function persistBuffer() {
if (!import.meta.client) return
try {
if (pendingEvents.length > 0) {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(pendingEvents))
}
else {
sessionStorage.removeItem(STORAGE_KEY)
}
}
catch { /* storage full or unavailable */ }
}

const pendingEvents: PendingEvent[] = restoreBuffer()

function getPostHog(): PostHog | undefined {
const $ph = (useNuxtApp() as Record<string, unknown>).$posthog as (() => PostHog) | undefined
return $ph?.()
}

/**
* Flush buffered pre-consent events to PostHog.
* Called by useAnalyticsConsent.acceptAnalytics() after opting in.
*/
export function flushPendingEvents() {
const ph = getPostHog()
if (!ph || !ph.has_opted_in_capturing()) return
while (pendingEvents.length > 0) {
const event = pendingEvents.shift()!
ph.capture(event.eventName, event.properties)
}
persistBuffer()
}

/**
* Discard buffered pre-consent events (user declined analytics).
*/
export function discardPendingEvents() {
pendingEvents.length = 0
persistBuffer()
}

export function useTrack() {
const route = useRoute()

/**
* Send a custom event to PostHog (consent-gated).
* Automatically includes current route path and viewport width.
*
* If PostHog is not yet opted-in (consent pending), the event is
* buffered and replayed when/if the user grants consent.
*/
function track(eventName: string, properties?: Record<string, unknown>) {
if (!import.meta.client) return
const ph = getPostHog()
if (!ph) return

const enrichedProps = {
ph.capture(eventName, {
path: route.path,
viewport_width: window.innerWidth,
...properties,
}

if (ph.has_opted_in_capturing()) {
ph.capture(eventName, enrichedProps)
}
else if (pendingEvents.length < MAX_PENDING_EVENTS) {
pendingEvents.push({ eventName, properties: enrichedProps })
persistBuffer()
}
})
}

/**
* Report a caught error to PostHog's error tracking (consent-gated).
* Use for errors that are handled in catch blocks but still worth logging.
*/
function captureError(error: unknown, properties?: Record<string, unknown>) {
if (!import.meta.client) return
const ph = getPostHog()
if (!ph || !ph.has_opted_in_capturing()) return
if (!ph) return

ph.captureException(error instanceof Error ? error : new Error(String(error)), {
path: route.path,
Expand Down
Loading
Loading