Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
3 changes: 3 additions & 0 deletions app/app.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ useHead(() => ({
link: i18nHead.value.link,
meta: i18nHead.value.meta,
}))

// Sync Better Auth session → PostHog identity & org group
await usePostHogIdentity()
</script>

<template>
Expand Down
58 changes: 58 additions & 0 deletions app/composables/useAnalyticsConsent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/**
* Composable for managing PostHog analytics consent (GDPR compliance).
*
* By default, PostHog captures events. Users can opt out via this composable.
* The consent state is persisted in localStorage.
Comment on lines +3 to +5
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

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

The JSDoc comment says "By default, PostHog captures events. Users can opt out via this composable." but this is incorrect given the configuration in nuxt.config.ts which sets opt_out_capturing_by_default: true. In reality, PostHog starts in opted-out mode and users must opt in for any capturing to occur. The docstring should read "By default, PostHog does not capture events. Users can opt in via this composable."

Copilot uses AI. Check for mistakes.
*
* Usage:
* const { hasConsented, acceptAnalytics, declineAnalytics } = useAnalyticsConsent()
*/
const CONSENT_KEY = 'reqcore-analytics-consent'

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

export function useAnalyticsConsent() {
// usePostHog() is auto-imported by @posthog/nuxt, but the module is
// conditionally loaded. Replicate the safe accessor so this composable
// works even when PostHog is not configured (CI, self-hosted without key).
const posthog = (useNuxtApp() as Record<string, unknown>).$posthog as ((() => { opt_in_capturing: () => void, opt_out_capturing: () => void }) | undefined)
const ph = posthog?.()

const consentState = useState<ConsentState>('analytics-consent', () => {
if (import.meta.server) return null
return (localStorage.getItem(CONSENT_KEY) as ConsentState) || null
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

const hasConsented = computed(() => consentState.value === 'granted')
const hasDeclined = computed(() => consentState.value === 'denied')
const needsConsent = computed(() => consentState.value === null)

function acceptAnalytics() {
consentState.value = 'granted'
if (import.meta.client) {
localStorage.setItem(CONSENT_KEY, 'granted')
}
ph?.opt_in_capturing()
}

function declineAnalytics() {
consentState.value = 'denied'
if (import.meta.client) {
localStorage.setItem(CONSENT_KEY, 'denied')
}
ph?.opt_out_capturing()
}

// Apply stored consent on mount
if (import.meta.client && consentState.value === 'denied') {
ph?.opt_out_capturing()
}

return {
hasConsented,
hasDeclined,
needsConsent,
acceptAnalytics,
declineAnalytics,
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
50 changes: 50 additions & 0 deletions app/composables/usePostHogIdentity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/**
* Composable that syncs the Better Auth session with PostHog identity.
* Call once in a root-level layout or app.vue to enable automatic
* user identification and organization group analytics.
*
* Must be called in `<script setup>` context (not in a plugin).
*/
export async function usePostHogIdentity() {
const { $posthogIdentifyUser, $posthogSetOrganization, $posthogReset } = useNuxtApp()

if (!$posthogIdentifyUser) return

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

// Watch session changes to identify/reset user
watch(
() => session.value,
(currentSession, previousSession) => {
const user = currentSession?.user

if (user?.id) {
($posthogIdentifyUser as (user: { id: string, name?: string, createdAt?: string }) => void)({
id: user.id,
name: user.name || undefined,
createdAt: user.createdAt ? String(user.createdAt) : undefined,
})
}
else if (previousSession?.user?.id) {
($posthogReset as () => void)()
}
},
{ immediate: true },
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// Watch active organization for group analytics
watch(
() => activeOrgState.value?.data,
(org) => {
if (org?.id) {
($posthogSetOrganization as (org: { id: string, name?: string, slug?: string }) => void)({
id: org.id,
name: org.name || undefined,
slug: org.slug || undefined,
})
}
},
{ immediate: true },
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}
61 changes: 61 additions & 0 deletions app/plugins/posthog-identity.client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/**
* Client-only plugin that identifies the current user and organization
* in PostHog whenever the auth session is available.
*
* - Calls posthog.identify() with the user's ID and safe properties
* - Calls posthog.group() for the active organization (group analytics)
* - Resets PostHog on sign-out to avoid cross-user data leakage
*/
export default defineNuxtPlugin({
name: 'posthog-identity',
parallel: true,
setup() {
// usePostHog() is auto-imported by @posthog/nuxt, but the module is
// conditionally loaded (only when POSTHOG_PUBLIC_KEY is set). In CI and
// local-dev without the key the auto-import doesn't exist, so we replicate
// its safe accessor here: $posthog is a function provided by the module's
// plugin — when the module isn't loaded it simply won't be on NuxtApp.
const $ph = (useNuxtApp() as Record<string, unknown>).$posthog as (() => import('posthog-js').PostHog) | undefined
const posthog = $ph?.()
if (!posthog) return

// ── Privacy: strip query params and hashes from captured URLs ──
// These may contain tokens, invitation IDs, or other PII.
const originalCapture = posthog.capture.bind(posthog)
posthog.capture = (eventName: string, properties?: Record<string, unknown>, options?: unknown) => {
const props = { ...properties }
if (typeof props['$current_url'] === 'string') {
try {
const url = new URL(props['$current_url'])
url.search = ''
url.hash = ''
props['$current_url'] = url.toString()
}
catch { /* keep original if parsing fails */ }
}
return originalCapture(eventName, props, options as never)
}

// Provide a hook that components/composables can use to identify the user
// after the app has mounted and auth session is available.
return {
provide: {
posthogIdentifyUser: (user: { id: string, name?: string, createdAt?: string }) => {
posthog.identify(user.id, {
name: user.name || undefined,
createdAt: user.createdAt || undefined,
})
},
posthogSetOrganization: (org: { id: string, name?: string, slug?: string }) => {
posthog.group('organization', org.id, {
name: org.name || undefined,
slug: org.slug || undefined,
})
},
posthogReset: () => {
posthog.reset()
},
},
}
},
})
35 changes: 34 additions & 1 deletion drizzle.config.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,43 @@
import { defineConfig } from 'drizzle-kit'

/**
* Resolve a valid database URL, with a fallback for Railway PR/preview environments
* where DATABASE_URL may have an empty hostname due to unresolved variable references.
* Falls back to individual PG* and RAILWAY_TCP_PROXY_* variables when available.
*/
function resolveDatabaseUrl(): string {
const raw = process.env.DATABASE_URL ?? ''

try {
const parsed = new URL(raw)
if (parsed.hostname) return raw
}
catch {
// fall through to individual-variable reconstruction
}

const host = process.env.PGHOST ?? process.env.RAILWAY_TCP_PROXY_DOMAIN ?? ''
const port = process.env.PGPORT ?? process.env.RAILWAY_TCP_PROXY_PORT ?? '5432'
const user = process.env.PGUSER ?? 'postgres'
const password = process.env.PGPASSWORD ?? ''
const database = process.env.PGDATABASE ?? 'railway'

if (host) {
return `postgresql://${encodeURIComponent(user)}:${encodeURIComponent(password)}@${host}:${port}/${database}`
}

throw new Error(
`DATABASE_URL is missing a hostname and no PGHOST fallback is available.\n`
+ `Raw DATABASE_URL: "${raw}"\n`
+ `In Railway PR environments, ensure the Postgres service variables are linked to this service.`,
)
}
Comment on lines +8 to +34
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

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

The resolveDatabaseUrl() function is duplicated verbatim in both drizzle.config.ts and server/scripts/seed.ts. The only difference is the error handling when no host is found (drizzle.config throws, seed.ts returns an empty string and exits with process.exit). This shared logic should be extracted into a utility module (e.g., server/utils/resolveDatabaseUrl.ts or a shared config helper) to avoid the risk of the two implementations diverging.

Copilot uses AI. Check for mistakes.

export default defineConfig({
schema: './server/database/schema/index.ts',
out: './server/database/migrations',
dialect: 'postgresql',
dbCredentials: {
url: process.env.DATABASE_URL!,
url: resolveDatabaseUrl(),
Comment on lines +3 to +41
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

Apply the same DB URL fallback in the runtime path.

drizzle.config.ts now handles Railway-style split PG vars, but server/utils/env.ts still validates only a raw DATABASE_URL, and server/utils/db.ts still connects with env.DATABASE_URL directly. In preview environments that means migrations can pass while the app still fails to boot.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@drizzle.config.ts` around lines 3 - 41, drizzle.config.ts implements
resolveDatabaseUrl() to fall back to individual PG*/RAILWAY_TCP_PROXY_* vars;
replicate that logic in runtime by extracting or reusing resolveDatabaseUrl for
application startup so env validation and DB connections use the same fallback.
Update server/utils/env.ts to validate/process the DB URL via the same
resolveDatabaseUrl (or a new shared getDatabaseUrl helper) instead of only
checking process.env.DATABASE_URL, and update server/utils/db.ts to call that
helper when constructing the DB client (replace direct use of env.DATABASE_URL).
Ensure the helper preserves encoding/port/user/password behavior and throws the
same descriptive error when no host can be resolved so migrations and runtime
behave identically.

},
})
45 changes: 37 additions & 8 deletions nuxt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,40 @@ export default defineNuxtConfig({
compatibilityDate: '2025-07-15',
devtools: { enabled: true },

modules: ['@nuxtjs/i18n', '@nuxtjs/seo', '@nuxt/content'],
modules: [
'@nuxtjs/i18n',
'@nuxtjs/seo',
'@nuxt/content',
// Only load PostHog module when the API key is available;
// the SDK crashes during prerender/build if the key is empty.
...(process.env.POSTHOG_PUBLIC_KEY ? ['@posthog/nuxt' as const] : []),
],

css: ['~/assets/css/main.css'],

// ─────────────────────────────────────────────
// PostHog — privacy-focused product analytics & feature flags
// ─────────────────────────────────────────────
posthogConfig: {
publicKey: process.env.POSTHOG_PUBLIC_KEY || '',
host: process.env.POSTHOG_HOST || 'https://eu.i.posthog.com',
clientConfig: {
// ── Privacy: disable invasive features ──
autocapture: false,
disable_session_recording: true,
enable_recording_console_log: false,
disable_surveys: true,
opt_out_capturing_by_default: false,
respect_dnt: true,
secure_cookie: true,
capture_pageview: true,
capture_pageleave: true,
// ── Persistence ──
persistence: 'localStorage+cookie',
cross_subdomain_cookie: false,
},
},

i18n: {
baseUrl: siteUrl,
defaultLocale: i18nDefaultLocale,
Expand Down Expand Up @@ -81,18 +111,17 @@ export default defineNuxtConfig({
meta: [
{ name: 'theme-color', content: '#09090b' },
],
script: [
{
defer: true,
'data-domain': 'reqcore.com',
src: 'https://test-plausible.kjadfu.easypanel.host/js/script.js',
},
],
// Plausible removed — PostHog handles all analytics
},
},

runtimeConfig: {
public: {
/** PostHog public key and host for server-side event capture */
posthog: {
publicKey: process.env.POSTHOG_PUBLIC_KEY || '',
host: process.env.POSTHOG_HOST || 'https://eu.i.posthog.com',
},
/** When set, the dashboard shows a read-only demo banner for this org slug */
demoOrgSlug: process.env.DEMO_ORG_SLUG || (isRailwayPreview ? 'reqcore-demo' : ''),
/** Public live-demo account email used to prefill sign-in */
Expand Down
Loading
Loading